How to attach sefer-alloc to a project as the global allocator and tune
its three operational knobs at compile time: size limit, release period,
release trigger.
This guide is a consolidated answer to "how do I use it in production".
For the wider documentation map see README.md §
Documentation map.
Pick the feature set that matches the workload. The recommended starter
for multi-thread / long-running processes is production:
[dependencies]
sefer-alloc = { version = "0.3", features = ["production"] }production is an alias for `alloc-global + alloc-xthread + alloc-decommit
- fastbin
— the drop-inGlobalAlloc` face, lock-free cross-thread free, M6 decommit (returns empty segments to the OS), and the per-thread fast-bin magazine.
Other valid feature shapes:
| Shape | features = [...] |
Use case |
|---|---|---|
| Handle store only (default) | omit | Region<T> / Handle<T> for typed slot storage |
no_std + alloc core |
default-features = false |
embedded targets |
| Single-thread allocator | ["alloc-global"] |
single-thread process |
| Multi-thread allocator | ["alloc-global", "alloc-xthread"] |
multi-thread, no segment recycling (1024-segment ceiling) |
| Recommended for servers | ["production"] |
long-running multi-thread (DBMS, async runtime) |
production + NUMA |
["production", "numa-aware"] |
multi-socket NUMA hardware |
Full feature matrix: README.md.
One declaration at the crate root (binary or library top-level):
use sefer_alloc::SeferAlloc;
#[global_allocator]
static GLOBAL: SeferAlloc = SeferAlloc::new();
fn main() {
// Every Vec/Box/String/HashMap allocation in this process — including
// those made by tokio, async-std, std collections, third-party crates —
// now goes through sefer-alloc.
let v: Vec<u8> = (0..1024).collect();
println!("{}", v.len());
}That's the full integration. Everything below is optional tuning via
the LargeCacheConfig const builder — a code change, no recompile needed.
The large-segment cache is the only piece of the allocator with tunable
policy. It governs how aggressively empty large blocks are returned to
the OS versus held in a per-shard free-list for reuse. Configuration is
via LargeCacheConfig — a Copy + Clone + const fn builder — passed to
SeferAlloc::with_config(...). All methods are const fn, so the config
lives in a static initialiser and is resolved at compile time (zero
runtime overhead, no env reads, no parse errors).
Two knobs work together. The cache admits any large free until the budget is hit; headroom is the level below which the decay does not pull memory back to the OS (anti-thrashing floor).
| Builder method | Default | Meaning |
|---|---|---|
.budget_bytes(n) |
None (unbounded) |
Per-shard hard ceiling on cached bytes. 0 = cache disabled (every span released to the OS immediately). No admission limit when unset; FIFO eviction fires only when this is set and the new span would exceed it. |
.headroom_bytes(n) |
256 MiB |
Floor below which the periodic decay is a no-op. Above this, excess = cached − headroom is the amount eligible for release. |
Containers / RSS-sensitive deployments: call .budget_bytes(512 * 1024 * 1024)
(or whatever your RSS ceiling is). Without it the cache will retain
whatever the workload churned through until the OS or the decay clock
pulls it back.
| Builder method | Default | Meaning |
|---|---|---|
.decay_interval_ms(n) |
1000 (1 s) |
Minimum wall-clock ms between two consecutive decay ticks. A tick computes excess = cached − headroom and releases excess × rate back to the OS. |
.decay_rate_percent(n) |
10 (10 %/tick) |
Fraction of the excess released on each tick, in integer percent [1, 100]. Values outside the range are clamped. |
The model is self-damping exponential decay: each tick removes a
constant fraction of the current excess, so the cache approaches
headroom aggressively when far above it and gently when near it. No
oscillation, no spike. An idle process pays nothing — the tick is gated
by the very next alloc/free that happens to be a large one (the "lazy"
trigger below).
| Builder method | Default | Meaning |
|---|---|---|
.mode(m) |
LargeCacheMode::Lazy |
Selects how the decay tick fires. |
-
LargeCacheMode::Lazy(default, fully implemented). Event-driven: each largeallocandfreechecks whetherdecay_interval_mshas elapsed since the previous tick; if so, exactly one decay step runs inline on that call. No background thread, no extra syscall on the common path, no allocation. Idle process → no work. This is the mobile/embedded/serverless-friendly mode.LargeCacheModeis#[non_exhaustive];Lazyis currently the only variant. A future background-scavenger mode (e.g. a dedicated thread visiting idle shards on a timer so a quiescent shard still gets decay) may be added as a non-breaking change, but it does not exist today — earlierBackground/Bothplaceholders were never implemented and have been removed from the enum.
Practical recommendation: keep the default Lazy. It already covers
DBMS / tokio servers / async runtimes where allocation pressure is
continuous, as well as idle workloads (a tick is gated by the next large
op, but headroom bounds how much can accumulate before that op fires).
A containerised tokio server with a 512 MiB RSS ceiling, aggressive trimming every 200 ms:
# Cargo.toml
[dependencies]
sefer-alloc = { version = "0.3", features = ["production"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }// src/main.rs
use sefer_alloc::{SeferAlloc, LargeCacheConfig, LargeCacheMode};
const CACHE_CONFIG: LargeCacheConfig = LargeCacheConfig::new()
.budget_bytes(512 * 1024 * 1024) // hard ceiling: 512 MiB per shard
.headroom_bytes(64 * 1024 * 1024) // floor: don't decay below 64 MiB
.decay_interval_ms(200) // tick every 200 ms
.decay_rate_percent(25) // release 25 % of excess per tick
.mode(LargeCacheMode::Lazy); // event-driven (no background thread)
#[global_allocator]
static GLOBAL: SeferAlloc = SeferAlloc::with_config(CACHE_CONFIG);
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
// ... your server ...
}What this means in practice:
- Cache will never hold more than 512 MiB of large segments per shard.
- Below 64 MiB the cache is left alone (anti-thrashing).
- Every 200 ms, on the next large alloc/free, 25 % of the excess above 64 MiB is returned to the OS.
- Idle process → no ticks, no work, no syscalls.
For a desktop / dev profile that retains memory more aggressively for
throughput, use SeferAlloc::new() (equivalent to
SeferAlloc::with_config(LargeCacheConfig::DEFAULT)): the defaults
(headroom=256 MiB, interval=1 s, rate=10 %/tick, unbounded budget)
are already tuned for the throughput-first case.
Honest scope: the dbg_* config accessors (dbg_decay_config,
dbg_large_cache_mode, dbg_large_cache_used, dbg_large_cache_hits, …)
live on AllocCore, the low-level per-thread segment substrate — not
on SeferAlloc itself. There is currently no method on SeferAlloc (the
type you actually hold as #[global_allocator]) that reaches into its
internal per-thread AllocCore to read these back; the accessors are
exercised directly against a standalone AllocCore::new_with_config(cfg)
instance in the crate's own test suite (see tests/large_cache_config_knobs.rs,
tests/large_cache_mode.rs), which is a different, additional AllocCore
built purely to assert the config plumbing — not the one servicing your
process's actual #[global_allocator] allocations.
If you want to confirm your LargeCacheConfig is wired the way you expect
before deploying it as #[global_allocator], build the identical config
against a standalone AllocCore in a unit test and read it back the same
way the crate's own tests do:
# #[cfg(feature = "alloc-decommit")]
# {
use sefer_alloc::alloc_core::{AllocCore, LargeCacheConfig, LargeCacheMode};
const CONFIG: LargeCacheConfig = LargeCacheConfig::new()
.budget_bytes(512 * 1024 * 1024)
.decay_rate_percent(25)
.mode(LargeCacheMode::Lazy);
let ac = AllocCore::new_with_config(CONFIG).expect("OS reservation failed");
let (rate_bp, _interval_ms, _headroom) = ac.dbg_decay_config();
assert_eq!(rate_bp, 2500); // dbg_decay_config's rate is in basis points (25% = 2500 bp)
assert_eq!(ac.dbg_large_cache_mode(), LargeCacheMode::Lazy);
# }This verifies the LargeCacheConfig builder produced the values you
intended — it does NOT verify the #[global_allocator] process's live
per-thread heap, because that heap is not reachable from outside the crate.
Track RSS over time with ps/top/docker stats against the REAL
running process instead — this is the only externally-observable signal for
the live #[global_allocator] configuration today. The decay profile
becomes visible: aggressive settings produce a sawtooth that damps toward
headroom; default settings produce a gentle slope. A follow-up
improvement (not yet implemented) would be a public SeferAlloc::dbg_*
passthrough for exactly this purpose.
If you only want one of sefer-alloc's building blocks, you can cargo add it independently — these are real crates.io packages, not just
internal modules:
| Crate | What it gives you | When to use |
|---|---|---|
sefer-region |
Region<T> / Handle<T> / SyncRegion<T> typed handle store |
typed slot storage without an allocator stack |
aligned-vmem |
SEGMENT-aligned mmap / VirtualAlloc + page decommit/recommit |
building your own allocator on top of a verified OS aperture |
numa-shim |
NUMA detection + binding (mbind / VirtualAllocExNuma, no libnuma) |
NUMA-aware code without C dependencies |
malloc-bench-rs |
portable GlobalAlloc benchmark harness (larson + mstress) |
benchmarking your own allocator |
sefer-alloc re-exports sefer-region's surface, so existing code
using use sefer_alloc::{Region, Handle, SyncRegion}; continues to
work unchanged.
-
Forgetting
#[global_allocator]. The dependency builds and the type compiles, but every allocation still goes through the system allocator. The declaration must be on astaticat the crate root, not inside a function. -
Wrong feature set.
cargo add sefer-allocwithout--features production(or at least--features alloc-global) gives you the handle store only —SeferAllocis not exported. -
with_configonly available underalloc-decommit. TheLargeCacheConfigtype andSeferAlloc::with_configare only compiled when thealloc-decommitfeature is on (included inproduction). Without it, useSeferAlloc::new()(the only constructor). -
Setting
budget_bytessmaller thanheadroom_bytes. Legal but pointless — the cache will be forced into FIFO eviction at the budget ceiling before decay ever kicks in. Keepheadroom < budget(or leavebudgetunset for the throughput-first default).