Skip to main content

Crate nordisk_alloc

Crate nordisk_alloc 

Source
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" resolves libmimalloc-sys, whose build.rs compiles the v3 C sources unless the v2 cargo feature is set. Nothing in this constellation sets it, so every fat node ships mimalloc v3.

  • mimalloc v2 defaulted purge_delay to 10 ms. mimalloc v3 defaults it to 1000 ms (c_src/mimalloc/v3/src/options.c, the purge_delay row).

  • MEASURED on mimalloc 0.1.52 / libmimalloc-sys 0.1.49, a program that builds a 2 GB heap of ~1 KB Strings and drops it, three times over:

    configurationpeak RSSRSS after the drop
    as shipped (v3 default 1000)2006 MB2006 MB — never returned
    MIMALLOC_PURGE_DELAY=102006 MB156 MB
    MIMALLOC_PURGE_DELAY=02006 MB7 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:

runPEAK RSSsettled floor
as shipped3.08 GB2.53 GB
purge_delay=102.71 GB2.46 GB
as shipped3.38 GB2.49 GB
purge_delay=102.72 GB2.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. The assert! is the test’s actual gate; the emit is the matrix observation.

Structs§

AllocTuning
What tune_for_long_lived_server actually 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§

PurgeDelayPlan
What purge_delay_ms decided, 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_delay this 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_delay default — the number this crate exists because of, and the fingerprint tune_for_long_lived_server uses to confirm it is writing to the row it means to.

Functions§

current_purge_delay_ms
The purge_delay the 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_from over the real environment: <PRODUCT>_… then GENERIC_PURGE_DELAY_ENV.
purge_delay_plan
Decide the purge_delay to 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. None off Linux or when /proc is absent.
tune_for_long_lived_server
Configure the running mimalloc so freed memory goes back to the OS. Call once, as early in main as the process can.