Expand description
§nordisk-alloc — the fleet’s allocator, configured for a process that lives for weeks
Every fat nordisk binary installs mimalloc as its process-wide global
allocator. That choice is right: the workloads are many-thread allocation storms
(nornir’s deep-scan flush, korp’s ingest, gunnar’s pack walk) and mimalloc’s
per-thread heaps scale them near-linear where glibc’s arena contention does not.
What was missing everywhere is the other half of that choice — telling mimalloc to hand freed memory back to the OS.
§The defect, and why it is a FLEET defect
-
mimalloc = "0.1"resolveslibmimalloc-sys, whosebuild.rscompiles the v3 C sources unless thev2cargo feature is set. Nothing in this constellation sets it, so every fat node ships mimalloc v3. -
mimalloc v2 defaulted
purge_delayto 10 ms. mimalloc v3 defaults it to 1000 ms (c_src/mimalloc/v3/src/options.c, thepurge_delayrow). -
MEASURED on
mimalloc0.1.52 /libmimalloc-sys0.1.49, a program that builds a 2 GB heap of ~1 KBStrings and drops it, three times over:configuration peak RSS RSS after the drop as shipped (v3 default 1000) 2006 MB 2006 MB — never returned MIMALLOC_PURGE_DELAY=102006 MB 156 MB MIMALLOC_PURGE_DELAY=02006 MB 7 MB At the shipped default the freed 2 GB is retained by the allocator for the life of the process. RSS is therefore not “memory in use” — it is the running maximum of every transient the process has ever built.
§INCIDENT 2026-08-17/18 — dwarves-server OOM-killed the 62 GB dwarf box
Eleven kills over two days, the last four at 22:43 · 23:23 · 00:20 · 01:15, each at
anon-rss 49–53 GB with total-vm 61–73 GB, after which the host itself died at
02:17:31 with journald reporting 152 × “Under memory pressure, flushing caches”.
anon-rss at each kill EQUALLED systemd’s reported memory peak for that
invocation — RSS being the high-water mark is exactly what that equality means.
Near-identical kill RSS across all eleven: the ceiling is set by the same sweep
peak each time, not by anything that accumulates per request.
On a real embedded-nornir republish (357 dep crates, 13 344 files, 12 023 symbol scans), two independent pairs, same binary, allocator the only variable:
| run | PEAK RSS | settled floor |
|---|---|---|
| as shipped | 3.08 GB | 2.53 GB |
purge_delay=10 | 2.71 GB | 2.46 GB |
| as shipped | 3.38 GB | 2.49 GB |
purge_delay=10 | 2.72 GB | 2.43 GB |
The peak falls 12–20 %, reproducibly — the term that matters for an OOM kill, since the process dies AT its high-water mark. The settled floor barely moves, because at ~2.5 GB it is live memory, not allocator-held. This crate lowers the ceiling the kernel trips on. It is not a claim that 50 GB becomes 500 MB.
§Why a shared leaf and not a copy per repo
The fix was measured, written and proved inside dwarves — and stayed there. As of
2026-08-25 alloc.rs existed only under dwarves/crates/dwarves/src/, while
nornir/src/bin/nornir-server.rs:19 still installed a bare
mimalloc::MiMalloc with no tuning at all, and purge_delay had zero hits
anywhere in edda. So the one process that was measured ratcheting got the fix and
every other nordisk binary — each running the same allocator, with the same v3
default, for the same weeks at a time — did not. LAW #5: one owner, and the
consumers point at it.
Nothing here is re-derived. The option indices, the fingerprint check, the 10 ms value and the reasoning are the ones dwarves MEASURED; this leaf is where they live so the other five binaries can have them.
§What this crate does — and what it deliberately does not do
tune_for_long_lived_server sets purge_delay to DEFAULT_PURGE_DELAY_MS
(10 ms — mimalloc v2’s long-standing value) and pins arena_purge_mult to 1, so a
freed arena slice is madvised back on the same schedule as a freed page. A
transient becomes transient again and RSS tracks live memory instead of history.
It is not a memory limit (that is a MemoryMax= in the unit —
skidbladnir::service::ServiceSpec::memory_max), not a periodic mi_collect
sweeper, and it does not make any individual transient smaller. Shrinking the
transients themselves belongs to whichever repo builds them.
§Why the option is set through FFI and not MIMALLOC_PURGE_DELAY
mimalloc initialises each option lazily on first read and then latches it
(MI_OPTION_UNINIT → MI_OPTION_INITIALIZED). purge_delay is already read
during pre-main allocation, so std::env::set_var at the top of main is too
late — MEASURED: the env-in-main variant retained the full 2006 MB, exactly like
the untuned default, while the mi_option_set variant returned it. Only
mi_option_set works after the fact, which is why libmimalloc-sys is linked with
its extended feature. An operator who wants the env route still has it: setting
MIMALLOC_PURGE_DELAY in the unit is read by mimalloc before main and the
off opt-out below then leaves it alone.
This is the LAW’s last-resort FFI clause used honestly: mimalloc is a C allocator, there is no Rust reimplementation of it to reach for, and the alternative is not a Rust version of the option table — it is no fix at all.
§Using it
// In the final artifact — only an artifact may declare a global allocator.
#[global_allocator]
static GLOBAL: nordisk_alloc::MiMalloc = nordisk_alloc::MiMalloc;
fn main() {
// As early as possible; the product name picks the per-product env override.
eprintln!("{}", nordisk_alloc::tune_for_long_lived_server("nornir").log_line());
}Modules§
- selftest
- Functional-status emit bridge —
nordisk-alloc→ test matrix
Macros§
- assert_
emit assert!-with-emit: assert on the real return value AND record the verdict as a matrix row. Theassert!is the test’s actual gate; the emit is the matrix observation.
Structs§
- Alloc
Tuning - What
tune_for_long_lived_serveractually did to the running allocator — returned (not just logged) so a caller and a test can both read it. - MiMalloc
- The allocator itself, re-exported so a consumer needs ONE dependency to both
install and tune it:
#[global_allocator] static G: nordisk_alloc::MiMalloc = …. Drop-in mimalloc global allocator.
Enums§
- Purge
Delay Plan - What
purge_delay_msdecided, and why — a value rather than a log line, so the decision is unit-testable without a process.
Constants§
- DEFAULT_
PURGE_ DELAY_ MS - The
purge_delaythis crate installs, in milliseconds — mimalloc v2’s long-standing default, and the value measured above to return a dropped 2 GB transient to the OS. - GENERIC_
PURGE_ DELAY_ ENV - The fleet-wide env override: a value in milliseconds, or
off. - MIMALLOC_
V3_ DEFAULT_ PURGE_ DELAY_ MS - The value mimalloc v3 ships as the
purge_delaydefault — the number this crate exists because of, and the fingerprinttune_for_long_lived_serveruses to confirm it is writing to the row it means to.
Functions§
- current_
purge_ delay_ ms - The
purge_delaythe running allocator is currently using, in milliseconds. - product_
purge_ delay_ env - The per-product env override for
product, consulted FIRST:product_purge_delay_env("dwarves")is"DWARVES_MIMALLOC_PURGE_DELAY_MS". - purge_
delay_ ms purge_delay_plan_fromover the real environment:<PRODUCT>_…thenGENERIC_PURGE_DELAY_ENV.- purge_
delay_ plan - Decide the
purge_delayto install from ONE explicit env value — pure, so the ladder is testable without mutating process environment. - purge_
delay_ plan_ from - The two-rung ladder, pure: the per-product value wins over the fleet-wide one, and an EMPTY per-product value is not a decision — it falls through to the generic rather than shadowing it.
- self_
rss_ kb - This process’ resident set in KiB, read from
/proc/self/statm(field 2, in pages). The one measurement that distinguishes “freed” from “returned”, and the thing the regression test asserts on.Noneoff Linux or when/procis absent. - tune_
for_ long_ lived_ server - Configure the running mimalloc so freed memory goes back to the OS. Call once,
as early in
mainas the process can.