perf(alloc): compile jemalloc's never-purge policy into the binary - #996
MauroToscano wants to merge 2 commits into
Conversation
jemalloc is unchanged and stays: it is here because the platform allocator keeps
freed arena chunks resident, and the recursion campaign measured the same proves
reading up to 13 GiB higher under glibc. What changes is its decay policy. Each
jemalloc `#[global_allocator]` site now exports jemalloc's compile-time
configuration string beside it:
dirty_decay_ms:-1,muzzy_decay_ms:-1
The decay timers hand freed pages back to the OS. The prover allocates and frees
multi-hundred-MiB host buffers continuously, so those pages are re-faulted almost
immediately, and the fault lands on the worker threads doing the proving. The
default policy cost about 13 M extra minor faults and about 15 s of extra system
time per run on the arms below.
Measured on an RTX 5090 box on the recursion campaign's branches, ABBA in each,
every arm at one commit and one set of knobs:
* WHIR prover (keccak, whir/lfm @ 64393da) — 39.69-39.88 s a block with the
setting against 43.94-44.07 s without.
* Per-table STARK tree (0e4f461) — 187 / 163 / 166 / 171 s, both never-purge
arms under both default arms, 5-24 s a block, proof bytes unmoved.
The cost is peak RSS: +1.4 GiB and +2.9-3.3 GiB respectively, an order below the
13 GiB the allocator choice itself is worth, which is why the lever is the decay
setting and not the allocator. `background_thread:true` recovers none of it: the
cost is the re-touch, not the `madvise` call. This branch's own pipeline has not
been measured under the setting.
`MALLOC_CONF` / `_RJEM_MALLOC_CONF` in the environment still override the
compiled-in default, which is how a benchmark arm puts the old policy back.
Nothing about the export is compiler-checked: a misspelled symbol, a wrong value
type, or a jemalloc built without the `_rjem_` prefix each leave a binary that
links, runs, and quietly purges. `prover/tests/jemalloc_conf.rs` reads
`opt.dirty_decay_ms` and `opt.muzzy_decay_ms` back out of the allocator serving
the test process and asserts both are -1, after asserting neither environment
variable is set so it cannot pass for the wrong reason. It is its own test binary
because the check needs a jemalloc process of its own; that the shipped binary
carries the symbol is a link-time property, read with `nm`.
No proof byte moves: an allocator is not an input to any transcript.
|
/bench |
|
/bench-memory |
Benchmark — real block (
|
| Metric | main | PR | Δ |
|---|---|---|---|
| Peak heap | 46173 MB | 46551 MB | +378 MB (+0.8%) ❔ |
| Prove time | 110.639s | 101.496s | -9.143s (-8.3%) ❔ |
⚠️ The cached baseline was noisy when it was recorded (prove-time spread 3.2%), so the Δ column compares against an unreliable number and no verdict is drawn. Refresh it (Actions → "Benchmark (PR)" → Run workflow on main), then re-run/bench— or use/bench-abba, which measures both sides itself.Prove-time spread 0.5% (101.496s / 101.380s / 101.839s)
Memory Growth
ethrex distinct-account transfers · default parallelism · 1 sample per point
| Transfers | main (MB) | PR (MB) | Δ |
|---|---|---|---|
| 4 | 10319 | 10229 | -90 MB (-0.9%) |
| 8 | 14341 | 12744 | -1597 MB (-11.1%) |
| 12 | 16405 | 15742 | -663 MB (-4.0%) |
| 16 | 18185 | 18304 | +119 MB (+0.7%) |
| 20 | 19609 | 20340 | +731 MB (+3.7%) |
Growth rate: 645 MB / transfer (main: 561, Δ: +15.0%)
Fit: R² = 0.9962 (main: 0.9536)
⚠️ Memory scaling regression — growth rate increased by +15.0%
Commit: 6456ede · Baseline: cached · Runner: self-hosted bench
|
/bench-gpu |
GPU Benchmark (ABBA) —
|
|
/bench-growth |
The escape hatch named a variable this binary does not read. `tikv-jemalloc-sys` builds with `--with-jemalloc-prefix=_rjem_` under default features, and jemalloc picks exactly one env name at configure time (`obtain_malloc_conf`, source 3), so only `_RJEM_MALLOC_CONF` is read here — plain `MALLOC_CONF` is inert. An arm that followed the comment to "put the default policy back" would have measured never-purge on both sides, with no warning, and read the lever as dead. The prefixed file source `/etc/_rjem_malloc.conf` is now named too, since it is the one remaining way to set `opt.*` from outside the binary. `jemalloc_conf.rs` was described in two places as what fails if the export stops being read. It carries its own copy of the block and reads its own process, so deleting either production export leaves it green: it pins the pattern, not the two sites that ship it. Both comments now say that, and the test's module doc says it of itself. "An order below the 13 GiB" holds for the +1.4 GiB figure (9x) but not for +2.9-3.3 GiB (4x). Now "a ninth to a quarter of". "+13 M minor faults and +15 s of system time per arm" sits under the WHIR bullet and is that arm's figure; the body says "arms". Scoped to the arm it belongs to. The only non-comment change is the order of the two names in the env guard, so the prefixed one — the one that can actually affect the reading — is checked first. Both still have to be unset; plain `MALLOC_CONF` stays guarded so a future unprefixed build cannot pass here silently.
What this changes
jemalloc stays exactly as it is. What changes is its purge policy: each of the two
jemalloc
#[global_allocator]sites now exports jemalloc's compile-timeconfiguration string beside it,
so the never-purge policy lives in the binary rather than in whatever environment
happens to launch it.
The mechanism
jemalloc returns freed pages to the OS on decay timers. The prover allocates and
frees multi-hundred-MiB host buffers continuously, so pages handed back are
re-faulted almost immediately, and the fault is taken on the worker threads that
are doing the proving. The cost shows up as kernel time, not user time: on the
arms below the default policy cost about 13 million extra minor faults and about
15 seconds of extra system time per run.
Turning the timers off trades that for resident memory, because freed pages stay
mapped and get reused instead of being returned and re-acquired.
background_thread:truewas tried and recovers none of it, which is whatidentifies the cost as the re-touch rather than the
madvisecall.Measurements, and where they come from
Both figures are from the recursion campaign's branches on an RTX 5090 box, ABBA
in each, every arm at one commit and one set of knobs. Neither is from this
branch.
whir/lfm@ 64393daThe STARK tree arms ran A/B/B/A in that order, so both never-purge arms sit under
both default arms; the spread puts the effect at 5–24 seconds a block. The proof
bytes are unmoved across the pair — 30 identical lines, 0 differing.
For scale on the memory side: the reason jemalloc is the allocator at all is that
the same proves read up to 13 GiB higher under the platform allocator, which keeps
freed arena chunks resident. The 1.4–3.3 GiB this costs is an order below that, so
the lever is the decay setting and not the allocator.
This branch's own pipeline has not been measured under the setting. The numbers
above come from branches whose prover shares the same allocation pattern, and the
mechanism is allocator-level rather than pipeline-specific, but no arm has been run
on this branch and none is claimed.
The test
prover/tests/jemalloc_conf.rsreadsopt.dirty_decay_msandopt.muzzy_decay_msback out of the allocator serving the test process and asserts both are −1.
Nothing about the export is compiler-checked — a misspelled symbol, a wrong value
type, or a jemalloc built without the
_rjem_prefix each leave a binary thatcompiles, links, runs, and quietly purges — so the read-back is the only evidence
the string was read. The test asserts
MALLOC_CONFand_RJEM_MALLOC_CONFareunset before reading, because those set the same options from the environment and
would otherwise let the test pass without the export doing anything.
It is its own test binary because the check needs a jemalloc process of its own:
the prover's lib tests run under the platform allocator, and
calibration.rssitsbehind the
disk-spillfeature and pays for a full proof.What the test pins is the export pattern — symbol name, value type, initializer,
edition spelling — shared verbatim with both production sites. That the shipped
clibinary carries the symbol is a link-time property and was checked withnmrather than asserted in a test.
It passes as written —
test result: ok. 1 passed; 0 failed, 1 test listed inthat binary — and run with the export commented out it fails on:
The shipped binary was checked separately:
nm -m target/release/cligivesexternalrather than weak, which is what makes it override jemalloc's own weakdefinition of the symbol at link time.
Notes
MALLOC_CONF/_RJEM_MALLOC_CONFin the environment still override thecompiled-in default, which is how a benchmark arm puts the old policy back.
#[unsafe(export_name = ...)]; a bare#[export_name]does not compile on edition 2024.