Skip to content

perf(alloc): compile jemalloc's never-purge policy into the binary - #996

Open
MauroToscano wants to merge 2 commits into
mainfrom
chore/jemalloc-never-purge-main
Open

MauroToscano wants to merge 2 commits into
mainfrom
chore/jemalloc-never-purge-main

Conversation

@MauroToscano

Copy link
Copy Markdown
Contributor

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-time
configuration string beside it,

dirty_decay_ms:-1,muzzy_decay_ms:-1

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:true was tried and recovers none of it, which is what
identifies the cost as the re-touch rather than the madvise call.

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.

Arm Default policy Never purge Peak RSS cost
WHIR prover, keccak, whir/lfm @ 64393da 43.94–44.07 s 39.69–39.88 s +1.4 GiB
Per-table STARK tree, 0e4f461 187 s, 171 s 163 s, 166 s +2.9–3.3 GiB

The 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.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.

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
compiles, links, runs, and quietly purges — so the read-back is the only evidence
the string was read. The test asserts MALLOC_CONF and _RJEM_MALLOC_CONF are
unset 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.rs sits
behind the disk-spill feature 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
cli binary carries the symbol is a link-time property and was checked with nm
rather than asserted in a test.

It passes as written — test result: ok. 1 passed; 0 failed, 1 test listed in
that binary — and run with the export commented out it fails on:

assertion `left == right` failed: opt.dirty_decay_ms is 10000, not -1

The shipped binary was checked separately: nm -m target/release/cli gives

0000000100458030 (__DATA_CONST,__const) external __rjem_malloc_conf

external rather than weak, which is what makes it override jemalloc's own weak
definition of the symbol at link time.

Notes

  • 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.
  • No proof byte moves. An allocator is not an input to any transcript.
  • The attribute is spelled #[unsafe(export_name = ...)]; a bare #[export_name]
    does not compile on edition 2024.

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.
@MauroToscano

Copy link
Copy Markdown
Contributor Author

/bench

@MauroToscano

Copy link
Copy Markdown
Contributor Author

/bench-memory

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

Benchmark — real block (ethrex_mainnet_25368371.bin) (median of 3)

continuations · epoch 2^22 · 8 epochs

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

@MauroToscano

Copy link
Copy Markdown
Contributor Author

/bench-gpu

@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown

GPU Benchmark (ABBA) — 6456ededfa vs main (14 pairs)

RTX 5090 · AMD Ryzen 9 9950X 16-Core Processor (32 threads) · Vast.ai datacenter @ $1.0041666666666667/hr · prover/cuda · ethrex real block, continuations · drift-free A/B/B/A

=== ABBA paired result  (improvement: - = PR faster) ===
  pairs: 14   mean A (PR): 25.052s   mean B (base): 27.729s

  [parametric] paired-t   mean -9.57%   sd 3.49%   se 0.93%
               95% CI: [-11.58%, -7.56%]   (t df=13 = 2.16)
  [robust]     median -9.20%   Wilcoxon W+=0 W-=105  p(exact)=0.0001  (z=-3.26)

  --- server stability (this run; compare across servers) ---
  run-to-run jitter:    A CV 2.06%   B CV 3.08%        (lower = steadier)
  within-session drift: -0.03% over the run, 1st->2nd half -0.05%
    (jitter -> Tier-1 cached gate floor; drift -> whether the cached baseline can be trusted)

  VERDICT: REAL IMPROVEMENT - PR faster by ~9.57% (t-CI and Wilcoxon agree)

  raw pairs: /tmp/abba_run/pairs.csv

- = PR faster. Trust the verdict when paired-t and Wilcoxon agree.

@MauroToscano

Copy link
Copy Markdown
Contributor Author

/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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants