Skip to content

perf: make bump the default guest allocator - #869

Merged
MauroToscano merged 33 commits into
mainfrom
perf/dlmalloc-guest-allocator
Aug 13, 2026
Merged

perf: make bump the default guest allocator#869
MauroToscano merged 33 commits into
mainfrom
perf/dlmalloc-guest-allocator

Conversation

@jotabulacios

@jotabulacios jotabulacios commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

The guest allocator now uses a monotonic bump allocator by default instead of embedded-alloc's TLSF heap. The dlmalloc-alloc feature remains available for executions with unbounded allocation churn; it reuses freed allocations on top of a page-aligned bump provider. Bump allocation is constant-time, makes dealloc a no-op, and skips the alloc_zeroed memset because guest memory is zero-initialized. TLSF is removed from the guest allocator configuration.

Motivation

TLSF spends guest instructions on free-list bookkeeping on every allocation, and a guest instruction is a trace row. Guest allocation patterns are dominated by short-lived buffers within one execution, so paying for reuse is paying for something the workload barely uses.

What changed

  • Default guest allocator is a monotonic bump allocator: alloc moves a cursor, dealloc is empty, realloc grows the top block in place when the block being grown is the one the cursor sits on, and alloc_zeroed skips the memset.
  • dlmalloc-alloc puts Doug Lea's malloc on a bump "system" provider that hands it page-aligned segments. Its footprint is bounded by live bytes rather than total bytes ever allocated, and it can grow a buried block in place.
  • embedded-alloc and the tlsf-alloc feature are gone, along with the guest lockfile churn they carried.

The alloc_zeroed memset skip depends on freshly bumped memory reading as zero. That holds at three independent layers: executor misses return unwrap_or_default(), the prover's replay fills (0, 0), and page genesis is zero_init bound to the static zero-page root, which the verifier rebuilds from the ELF rather than trusting the prover. The cursor is monotonic by construction — realloc never rewinds it on shrink, which would re-expose written bytes and break exactly that invariant.

Measurements

All figures are post-#861. Thin LTO inlines the per-allocation bookkeeping dlmalloc and TLSF pay and bump avoids by construction, which closed about two thirds of the gap the same comparison showed before it; any pre-LTO number for this change reads larger than it should.

Bump against dlmalloc, on the ethrex transfer fixtures and a real Hoodi block:

Metric Result
Guest cycles, 20-transfer block -7.0%
Guest cycles, 150-transfer block -6.2%
Guest cycles, real Hoodi block -2.9%
Prove time, monolithic -2.6%
Peak RSS, monolithic -3.9%
Prove time, continuations at 2^21 and 2^22 -1.2%

The in-place realloc takes a further 0.57..1.34% off guest cycles across four ethrex fixtures; it has not been re-run against dlmalloc.

Where bump loses, both deterministic:

  • Proof bundle is 0.6..1.0% larger at every epoch. Memory that is never reused spans more pages, and every page touched pays PAGE rows.
  • At epoch 2^20 dlmalloc proves ~2.3% faster, in 8 of 9 paired rounds: eight epochs amplify that page cost. Larger epochs are ~26% cheaper in absolute terms, so the configuration anyone runs is the one bump wins.

Scope of the prove-time numbers: they come from the synthetic transfer fixtures on the monolithic path. On the real block this change is not resolvable. The bench box swings ~15% run to run on 8-minute runs; the CPU real-block run reported no significant change (-40 MB peak heap, -1.9% prove time, within spread), and the GPU ABBA run at n=32 came back INCONCLUSIVE with a point estimate of -0.01% and a 95% CI of [-0.24%, +0.22%]. The win is claimed on the fixtures, not there.

Bump's ceiling

Bump never reclaims, so the quantity that matters is cumulative allocation, against ~3 GiB of [_end, MAX_MEMORY_SIZE). Measured execute-only over eight ethrex fixtures spanning 0.42M to 63M gas:

Gas Cumulative alloc Marginal B/gas
2.43M (real, contract-heavy) 9.4 MB
4.24M (real, contract-heavy) 15.1 MB
31.5M (1500 transfers) 72.1 MB 2.211
63M (3000 transfers) 142.0 MB 2.219

alloc = 2.55 MB + 2.213 B/gas × gas, R² = 0.999993, with the marginal rate flat between 2.175 and 2.289 across a 150× range of gas. Allocation is linear: there is no superlinear term, which was the only route to exhaustion within one block.

The quantity that binds is bytes per gas, not transactions per gas, and transfers roughly minimise it. At the highest rate measured — 3.87 B/gas, on the contract-heavy blocks — a 60M-gas block lands at ~232 MB, a 13.9× margin, and exhaustion needs ~832M gas. Both contract-heavy fixtures are small blocks (2.4M and 4.2M gas), so that rate still carries the ~2.5 MB constant inside its average and no gas-full contract-heavy block has been measured: treat the margin as an extrapolation. If a block ever exceeds it, dlmalloc-alloc is a one-flag fallback.

A guest that processes many blocks in one execution has no per-block bound at all, which is the case dlmalloc-alloc exists for. Continuations do not stress it: they split proving into epochs, not execution.

Known limitation, not fixed here

Heap exhaustion does not fail cleanly. alloc returns null, handle_alloc_error panics, and the guest #[panic_handler] is loop {}, so execution spins instead of aborting; nothing on the proving path bounds cycles, since --cycle-budget is opt-in and only on execute.

This is not shipped as part of this change, deliberately:

  • It is preexisting. TLSF also returned null on exhaustion and hung through the same panic handler, and any guest panic hangs the prover the same way. The allocator choice moves the threshold, not the failure mode.
  • The fix is not allocator-local. HALT constrains the exit code to zero at the constraint level (prover/src/tables/halt.rs:16,194, spec halt:c:read_zero_exit_code), so a nonzero exit is not a provable state. A clean abort needs either a committed failure marker or a non-provable abort ecall. #[alloc_error_handler] is not the lever: it is unstable, and the default handler already panics.

Two follow-ups: a host-side cycle bound on the prove path, which is cheap and removes most of the operational risk without touching the VM, and then the panic-to-abort design above.

How to test

make lint
cd syscalls && cargo test && cargo test --features dlmalloc-alloc
make compile-programs-rust

pr_main.yaml covers both allocator profiles. To exercise the fallback in a guest, build it with --features lambda-vm-syscalls/dlmalloc-alloc; nothing in the repo selects it today, so the riscv64 path has no consumer yet.

@diegokingston

Copy link
Copy Markdown
Collaborator

/bench

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

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

continuations · epoch 2^22 · 10 epochs

Metric main PR Δ
Peak heap 47814 MB 47633 MB -181 MB (-0.4%) ⚪
Prove time 141.796s 136.839s -4.957s (-3.5%) ⚪

-3.5% — beyond what 3 runs resolve. Use /bench-abba for a paired test of the same block (default 12 pairs, ~72 min, resolves ~1%).

Prove-time spread 1.2% (138.118s / 136.500s / 136.839s)

Commit: 4ba26cc · Baseline: cached · Runner: self-hosted bench

@diegokingston

Copy link
Copy Markdown
Collaborator

/bench

@jotabulacios
jotabulacios marked this pull request as ready for review July 29, 2026 13:51
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

GPU Benchmark (ABBA) — 3bb45e0312 vs main (32 pairs)

RTX 5090 · Intel(R) Core(TM) Ultra 7 265K (20 threads) · Vast.ai datacenter @ $0.6680555555555555/hr · prover/cuda · ethrex real block, continuations · drift-free A/B/B/A

=== ABBA paired result  (improvement: - = PR faster) ===
  pairs: 32   mean A (PR): 74.734s   mean B (base): 74.741s

  [parametric] paired-t   mean -0.01%   sd 0.63%   se 0.11%
               95% CI: [-0.24%, +0.22%]   (t df=31 = 2.042)
  [robust]     median +0.02%   Wilcoxon W+=272 W-=256  p(exact)=0.8900  (z=+0.14)

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

  VERDICT: INCONCLUSIVE - effect not separable from 0 at n=32.
           Point estimate ~+0.02% (median). Need more pairs to resolve.

  raw pairs: /tmp/abba_run/pairs.csv

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

@MauroToscano

Copy link
Copy Markdown
Contributor

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench-gpu

@jotabulacios jotabulacios changed the title Perf/dlmalloc guest allocator perf: make bump the default guest allocator Aug 4, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Benchmark Results for modified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
head ecsm 2.6 ± 0.1 2.5 2.9 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head hashmap 109.0 ± 1.8 106.7 112.0 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head keccak 125.8 ± 2.9 121.7 132.1 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head syscall_commit 86.9 ± 7.7 83.7 108.8 1.00

@MauroToscano

Copy link
Copy Markdown
Contributor

Reviewed the allocator swap end to end (both arms built for riscv64 and executed against an alloc probe; no correctness bugs found). The zero-init invariant the alloc_zeroed memset skip now depends on holds at three independent layers — executor misses return unwrap_or_default(), the prover's replay fills (0,0), and page genesis is zero_init bound to the static zero-page root, which the verifier rebuilds from the ELF rather than trusting the prover. Worth noting that skip is newly load-bearing: embedded-alloc never implemented alloc_zeroed, so main got std's default alloc+memset.

Three things to settle, all in the docs rather than the code.

1. The ceiling claim — now measured, and it holds. Execute-only heap probe over 8 fixtures spanning 0.42M–63M gas:

gas cumulative alloc marginal B/gas
2.43M (real, contract-heavy) 9.4 MB
4.24M (real, contract-heavy) 15.1 MB
31.5M (1500 transfers) 72.1 MB 2.211
63M (3000 transfers) 142.0 MB 2.219

alloc = 2.55 MB + 2.213 B/gas × gas, R² = 0.999993. The marginal rate is flat (2.175–2.289) across a 150× gas range, so allocation is linear — there is no superlinear term, which was the only route to exhaustion. At the contract-heavy rate of 3.87 B/gas a gas-full block lands at ~232 MB, a 13.9× margin; exhaustion needs ~832M gas.

This also puts the first number on the doc's own claim: 1500 transfers measured 72.1 MB at 528,619,812 cycles (the doc says 523M — same block). "Room to spare" is right, and it's 44×.

So the ceiling is fine. But the ceiling note should say so with the numbers, and restore the exception 7a35864a had — "contract-heavy blocks allocate more per transaction and are not covered by that bound" — which the current text replaced with the universal "A single block cannot reach it" without a new measurement in between. The gas argument bounds transactions per gas; the binding quantity is bytes per gas, and transfers roughly minimise it.

2. The gas-limit figure in that note is stale. It says a gas-full block is 31.5M gas; mainnet's limit is 60M, so the margin as written reads ~2× better than it is. (Still 13.9× after correcting.)

3. Narrow the performance claims to what was measured. As written they don't support the change:

  • "~9% fewer guest cycles" is bump vs dlmalloc — an allocator that was never the default, so it isn't an argument for replacing TLSF.
  • "~11% faster than TLSF to prove" is the only bump-vs-TLSF number, and it comes from the synthetic transfer fixtures via the monolithic path — the workload benchmark-pr.yml documents as "~5.8x the work of the synthetic block with a ~18x different keccak:ecrecover mix", dropped from /bench because "a prover change can move the synthetic number and the real one in opposite directions".
  • On the real block this PR measures no significant change (CPU) and INCONCLUSIVE at +0.30% (GPU, p=0.67, n=14).
  • "never came out behind on any deterministic metric on any workload" is falsified by the PR's own bot: peak heap +510 MB (+1.1%), which scripts/BENCHMARKS.md classifies as deterministic.

Suggested: state the win as measured on the transfer fixtures, and say plainly it is not resolvable on the real block.

Also worth a line: nothing in the repo enables dlmalloc-alloc — no guest manifest, no Makefile rule, no CI build. It is never compiled for riscv64 anywhere, so DlGlobal is currently exercised by nothing. Given the recursion guest is being retired and no remaining guest has unbounded churn, that's defensible — but the docs should say the feature has no consumer today rather than implying one needs it.

Separately, still open from the earlier pass: heap exhaustion spins rather than failing, so OOM is an unbounded-cycle hazard rather than a rejectable error. The 13.9× margin makes that unlikely to trigger, but an #[alloc_error_handler]/abort path protects every future block rather than the ones measured here. Worth an explicit ship / don't-ship decision.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Benchmark Results for unmodified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
base binary_search 50.1 ± 0.6 49.5 51.0 1.00
head binary_search 50.1 ± 0.6 49.3 51.3 1.00 ± 0.02
Command Mean [ms] Min [ms] Max [ms] Relative
base bitwise_ops 50.8 ± 0.5 50.2 51.5 1.00 ± 0.02
head bitwise_ops 50.7 ± 0.6 49.9 51.4 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
base fibonacci_26 53.0 ± 0.6 51.8 53.8 1.00
head fibonacci_26 53.2 ± 0.9 51.8 54.8 1.00 ± 0.02
Command Mean [ms] Min [ms] Max [ms] Relative
base matrix_multiply 52.1 ± 0.4 51.7 53.0 1.00
head matrix_multiply 52.3 ± 0.5 51.4 52.8 1.00 ± 0.01
Command Mean [ms] Min [ms] Max [ms] Relative
base modular_exp 50.8 ± 0.7 49.7 51.7 1.00
head modular_exp 50.9 ± 0.7 50.0 52.0 1.00 ± 0.02
Command Mean [ms] Min [ms] Max [ms] Relative
base quicksort 53.5 ± 0.6 52.7 54.1 1.00
head quicksort 59.7 ± 13.2 52.8 92.7 1.12 ± 0.25
Command Mean [ms] Min [ms] Max [ms] Relative
base sieve 54.6 ± 0.4 54.1 55.1 1.00
head sieve 54.8 ± 0.5 53.9 55.3 1.00 ± 0.01
Command Mean [ms] Min [ms] Max [ms] Relative
base sum_array 62.3 ± 0.5 61.7 63.0 1.00
head sum_array 62.7 ± 0.4 61.7 63.1 1.01 ± 0.01

… dlmalloc guest-allocator fallback tests and the ethrex-crypto host tests
@nicole-graus

Copy link
Copy Markdown
Collaborator

/bench-verify

@github-actions

Copy link
Copy Markdown

Benchmark started on the bench server. Two verifier arms (monolithic + continuations over an ethrex 20-tx block), then the recursion-guest cycle comparison, which adds guest builds on top — longer on a cold runner. The bench server is occupied until it finishes.

@github-actions

Copy link
Copy Markdown

Verifier benchmark — a28f1c42dc vs main (20 pairs, monolithic + continuations)

ethrex 20-tx block · monolithic · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 20 pairs) 2.601s 2.609s +0.32% 🔴
Proof size (exact, 1 reading) 116.22 MiB 116.22 MiB +0.00% ⚪
  pairs: 20   mean A (PR): 2.609s   mean B (main): 2.601s
  [parametric] paired-t   mean +0.32%   sd 0.59%   se 0.13%
               95% CI: [+0.04%, +0.60%]   (t df=19 = 2.093)
  [robust]     median +0.31%   Wilcoxon W+=163 W-=27  p(exact)=0.0046  (z=+2.72)

  run-to-run jitter:    A CV 0.40%   B CV 0.50%        (lower = steadier)
  within-session drift: -0.14% over the run, 1st->2nd half -0.05%

🔴 REAL REGRESSION — PR verifies ~0.32% slower (paired-t and Wilcoxon agree).

ethrex 20-tx block · continuations, epoch 2^20 (5 epochs) · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 8 pairs) 4.705s 4.715s +0.23% ⚪
Proof size (exact, 1 reading) 270.16 MiB 270.16 MiB +0.00% ⚪
  pairs: 8   mean A (PR): 4.715s   mean B (main): 4.705s
  [parametric] paired-t   mean +0.23%   sd 0.86%   se 0.30%
               95% CI: [-0.48%, +0.95%]   (t df=7 = 2.365)
  [robust]     median +0.20%   Wilcoxon W+=25 W-=11  p(exact)=0.3828  (z=+0.91)

  run-to-run jitter:    A CV 0.31%   B CV 0.58%        (lower = steadier)
  within-session drift: -0.11% over the run, 1st->2nd half -0.04%

INCONCLUSIVE — effect not separable from 0 at n=8 (point estimate ~+0.20%). Add pairs to resolve.

Verify-time rows only: drift-free interleaved A/B/B/A, with paired-t and exact Wilcoxon — trust the verdict when the two agree. Proof sizes are single exact readings (no averaging). - = PR faster.


Recursion guest cycles — verifier running INSIDE the VM (main vs PR)

empty program · monolithic · blowup=2, 1 query (diagnostic — NOT a real verifier cost)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 462.2M 331.6M -130.6M (-28.26%)
Keccak calls 3029 3029 0
  baseline  origin/main  58160b6fb5  guest=recursion-min.elf
  PR        a28f1c42dc6b76b76cfd9ce2840dc8e9b6a07957  a28f1c42dc  guest=recursion-min.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=58160b6fb538cc651bd9da093a7168b4dca0d9c7 ref_b_elf=recursion-min.elf ref_b_cycles=462212753 ref_b_keccak=3029 ref_b_execute_wall_s=9
ref_a_sha=a28f1c42dc6b76b76cfd9ce2840dc8e9b6a07957 ref_a_elf=recursion-min.elf ref_a_cycles=331608291 ref_a_keccak=3029 ref_a_execute_wall_s=10
delta_cycles=-130604462 delta_keccak=0

ethrex 20-tx block · continuations, epoch 2^21 (main 3 / PR 2 epochs) · blowup=2, 219 queries (128-bit)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 3206.1M 2279.1M -927.0M (-28.91%)
Keccak calls 4311131 3534145 -776986
  baseline  origin/main  58160b6fb5  guest=recursion-cont-blowup2.elf
  PR        a28f1c42dc6b76b76cfd9ce2840dc8e9b6a07957  a28f1c42dc  guest=recursion-cont-blowup2.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=58160b6fb538cc651bd9da093a7168b4dca0d9c7 ref_b_elf=recursion-cont-blowup2.elf ref_b_cycles=3206074638 ref_b_keccak=4311131 ref_b_execute_wall_s=51
ref_a_sha=a28f1c42dc6b76b76cfd9ce2840dc8e9b6a07957 ref_a_elf=recursion-cont-blowup2.elf ref_a_cycles=2279093691 ref_a_keccak=3534145 ref_a_execute_wall_s=37
delta_cycles=-926980947 delta_keccak=-776986

@Oppen

Oppen commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Design feedback on the allocator choice. Not a defect in the diff, and not an argument against the measured win — it's about where the decision lives.

Who knows a program's allocation pattern? The program. Nothing else can.

Today the #[global_allocator] is installed by a library: syscalls/src/allocator.rs:73, behind #[cfg(target_arch = "riscv64")], in the ECALL ABI crate that every guest plus executor, crypto and ethrex-crypto depend on. The allocator uses nothing from that ABI — no ecall is involved in bumping a cursor. It lives there because entrypoint.rs:11 happens to be where init_allocator() is called.

Three things follow from that placement:

  1. A default set in the shared crate is a claim about every program's allocation pattern, made in the one place that can't know any of them. ethrex block execution, the recursion verifier and fibonacci have nothing in common here.
  2. It explains the loose end the PR already notes: dlmalloc-alloc has no consumer today, not because no guest needs reclaiming, but because "which guest needs it" isn't expressible where the choice currently lives. The risk is carried by all guests and mitigated by a flag none of them set.
  3. It made the host question unanswerable. Bump is gated on target_arch = "riscv64" inside the guest ABI crate, so "host" can only ever mean cargo test of syscalls — which is what :54-56 documents. But the host verifier does the same bounded work as the recursion guest: same bound, execution mode rather than environment. It never entered the comparison because the layering excluded it.

The convention matches the reasoning: libraries don't install #[global_allocator], binaries do. Export BumpAlloc / DlGlobal and let each guest's main.rs write the one line. Arena bounds likewise — [_end, MAX_MEMORY_SIZE) is the executor's memory-map fact, restated in the SDK; as a caller argument the guest passes the linker range and a host binary passes one mmap. The ~3 GiB ceiling and "PAGE rows track the footprint" then read as properties of that arena choice rather than as intrinsic to bump, which is how :28-40 currently reads.


The deeper question: for bounded lifetimes, what gives cheap allocation and cheap deallocation? Neither option in this PR.

alloc dealloc lifetime info
global bump cursor never discarded
dlmalloc free lists per-object bookkeeping discarded, then rediscovered at runtime
arena + scoped reset cursor O(1) for the whole scope supplied by the program

Bounded lifetime is knowledge the program already has. A global allocator has no channel to receive it, so bump answers "when does this die?" with never, and dlmalloc pays, per object, to rediscover what the call site knew statically.

It fits this workload specifically: the verifier's work is phase-structured — per-epoch, per-query, per-FRI-layer — and each phase's allocations die together at the phase boundary. That's one reset, not 10^6 frees. bumpalo::collections covers Vec/String today without waiting on stable allocator_api, it is no_std-capable, and one arena per scope per worker means there is no shared cursor to synchronize — on guest or host.

What that reframes:

  • The ceiling stops being cumulative-ever and becomes peak-live-per-phase. The 2.213 B/gas fit, the 13.9x margin and the dlmalloc-alloc escape hatch are all artifacts of measuring an allocator that cannot reclaim because nothing ever told it when.
  • The OOM-spin hazard shrinks with it: a spin is far less reachable when the high-water mark is one phase rather than the whole execution.
  • The global allocator is then left serving only genuinely dynamic, un-scoped allocation — where being slow is fine, because it's rare.

None of this argues against landing the measured win. It argues that the win is evidence for "this workload should use bump", installed by that workload, rather than for a new default applied to every program.

@diegokingston

Copy link
Copy Markdown
Collaborator

/bench

MauroToscano
MauroToscano previously approved these changes Aug 13, 2026

@MauroToscano MauroToscano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The three doc issues raised in the earlier review pass are resolved, and the rewrite goes further than what was asked.

Resolved:

  • "Never came out behind on any deterministic metric on any workload" is gone. The header now states the trade honestly — bump "pays for that with a 0.6..1.0% larger proof bundle at every epoch and a loss at epoch 2^20" — and says plainly that the real block does not resolve the difference.
  • The stale gas figure is gone, along with the assertion it supported. 1500 transfers (31.5M gas) allocate 72.1 MB is now presented as one measured fixture rather than as a gas-full block.
  • dlmalloc-alloc now records that nothing selects it: "CI builds and tests the feature on host, but no guest manifest or Makefile rule turns it on, so the riscv64 #[global_allocator] below is a fallback with no consumer yet."

Better than asked. The earlier pass concluded the gas-linear fit gave a ~13.9x safety margin. The current text correctly declines that framing — "no gas rule bounds that, so the fit below describes honest blocks and is not a safety margin" — and then shows why, with the adversarial path: every CALL copies its argument region into a fresh, never-reclaimed heap buffer while memory expansion is charged once as max(args, retdata), reaching ~561 B/gas, ~145x the honest contract-heavy rate. That is a stronger and more useful statement than the margin it replaces, and it reframes the operative limit as prover cost (PAGE rows per touched page, plus a per-page GLOBAL_MEMORY table on the continuation path that does not reset per epoch) rather than the ~3 GiB address range.

Exhaustion behaviour is now documented precisely, including that std guests never reach a panic handler at all — __rust_alloc_error_handler -> unimp, and execution spins. Shipping that as a documented known limitation is a reasonable call now that it is written down rather than implicit.

Scope of this approval: I verified the doc claims against the measurements and the code, and the earlier pass found no correctness bugs — the zero-init invariant that the alloc_zeroed memset skip depends on holds at three independent layers, and realloc now has a real override with a test. I did not re-review the work merged in from main since that pass (#914, #904, #909, #876 among others); each was reviewed on its own PR.

@MauroToscano
MauroToscano dismissed their stale review August 13, 2026 14:42

Dismissing my own approval — I did not earn it. I approved at 2539282 having read only the allocator doc comment block, relying on a review pass against 3bb45e0. Those heads differ by 62 files / +6596, and among the unreviewed commits is b3699df 'Grow the top bump block in place on realloc' — a change to the exact mechanism (realloc) that the earlier pass flagged as its elevated risk. Re-reviewing the current head properly; nicole-graus's approval is unaffected.

This PR removes the TLSF heap, so the test no longer exercises TLSF init.
It proves the same program against whichever allocator is built in, so name
the step rather than the implementation.

Comment-only.
@MauroToscano
MauroToscano added this pull request to the merge queue Aug 13, 2026

@MauroToscano MauroToscano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving, on a review of the current head this time.

Context for the dismissal above: I had approved on the strength of a review pass against 3bb45e03 after reading only the allocator's doc comment. That head and this one differ by 62 files / +6596, and the gap included b3699df1 "Grow the top bump block in place on realloc" — a change to the exact mechanism an earlier pass had flagged as its elevated risk. That approval wasn't earned, so it was withdrawn. This one replaces it.

The zeroing invariant holds. alloc_zeroed is { self.alloc(layout) } — no memset — which is sound only if every byte handed out is already zero. The invariant that delivers it: every byte in [HEAP_POS, HEAP_END) has never been part of a returned block, and HEAP_POS never decreases. All three mutators preserve it — alloc returns a region ending at the new cursor, the in-place grow fires only when ptr + layout.size() == HEAP_POS so it claims only [HEAP_POS_old, ptr+new_size), and shrink/dealloc never move the cursor.

Tested rather than argued: ~6M randomized ops across 16K/64K/256K heaps with realloc biased onto the top block, using a served-byte bitmap so a byte handed out twice is caught even if nothing ever writes it. double_serves = 0, no aliasing, no dirty alloc_zeroed, no lost realloc bytes, no cursor rewind. Counters: inplace_grow=157806 copy_grow=57614 shrink_inplace=52790 shrink_copy=18490 zero_size_inplace_grow=7457.

The zero-size case does fire — all 42,158 zero-size allocs land exactly at the cursor and 7,457 grow in place — and is safe: such a block owns no bytes, and the equality is self-invalidating, since whichever candidate grows first advances the cursor and knocks the other onto the copy path. It's also unreachable from Rust guest code, as Global short-circuits size-0 before GlobalAlloc. Concurrency is unreachable three ways: target spec "singlethread": true, no A extension (-C passes=lower-atomic), and no thread/clone/futex ecall.

The recursion-guest churn question was adjudicated and refuted. The structural half is real — the per-epoch loop does run inside the guest (bench_vs/lambda/recursion/src/main.rs:97verify_continuation_and_attestverify_continuation_archivedverify_continuation_view), and Makefile:247 selects no dlmalloc-alloc. But the consequence doesn't follow: measured heap is ~5.02–5.16 MB/epoch at blowup4, exactly linear, so crossover against ~3 GiB is ~590 epochs against a real block's ~112. And the binding constraint sits upstream — MAX_PRIVATE_INPUT_SIZE = 512 MiB is enforced in Executor::new before any cycle runs, and clamped again guest-side in get_private_input_slice. Since heap and blob are both linear in epoch count, the ceiling is a closed form independent of N: ~143 MB at blowup4. A 112-epoch bundle (~3.9 GB) is rejected as a clean error, not a hang. get_private_input{,_slice} are a guest's only input channels, so there is no streaming path around the cap.

CI coverage is sound. make test-syscalls runs both profiles for the default arm; a dedicated job runs dlmalloc-alloc in both; and the init-guard test is profile-split, with a #[cfg(not(debug_assertions))] case for the release path where the debug_assert! is compiled out — which is the profile guests actually build in.

Non-blocking follow-ups, none of which I'd hold the merge for:

  • The hyperfine trigger added here can't produce a before/after comparison: any syscalls/** change alters all four syscalls-linked bench ELFs, so they land in the modified arm, which benchmarks head only. This PR's own run shows every row at Relative 1.00 with no base column — the allocator swap produced zero comparative data.
  • The new in-place realloc path has no guest-side coverage; allocator.elf does one alloc, never a realloc.
  • The PR body's "Continuations do not stress it" is true of the inner guest but doesn't address the outer recursion guest, whose execution does loop.
  • The ~561 B/gas adversarial figure in the module doc is untraceable — absent from the PR description, and levm is a pinned git dep. Every other header number has a table row behind it.
  • hint_min/hint_multi lockfiles still list the removed embedded-alloc (merge-order artifact from #876; cosmetic, nothing uses --locked).

Scope of this approval: verified directly — the guest call chain, both private-input clamps, the CI wiring, the lockfile state, and the TLSF removal; plus the allocator stress and both test arms run locally. Not verified — benchmarks were not re-run, riscv64 × dlmalloc-alloc is compiled nowhere so that arm's guest build is argued rather than proven, and the prosecutor side of the churn pair never reported, so that finding rests on the defense's measurements plus my own check of the call chain and the cap.

Merged via the queue into main with commit d52f37d Aug 13, 2026
23 checks passed
@MauroToscano
MauroToscano deleted the perf/dlmalloc-guest-allocator branch August 13, 2026 16:29
MauroToscano added a commit that referenced this pull request Aug 18, 2026
Brings in the four commits that landed since the campaign base 58160b6:
the bump guest allocator default (#869), the VRAM-pressure/R2-race fix
(#914), the cuda table scheduler K = num_airs default (#911), and the
device-only cliff recovery at R4 DEEP / comp-tree / R3 barycentric (#935).

Conflicts resolved (one file, three hunks, all the same collision):

- crypto/stark/src/prover.rs — the Stage-2 H-threading parameterized
  Round1/Round2 over the hasher, while #935 changed the same parameters
  from shared to mutable so the cliff recovery can download resident
  device data back into the host buffers. Rule: keep both — main's `&mut`
  mutability and this branch's `H` parameter. Applied at
  round_3_evaluate_polynomials_in_out_of_domain_element,
  round_4_compute_and_run_fri_on_the_deep_composition_polynomial, and
  compute_deep_composition_poly_evaluations. The recovery paths therefore
  run through the H-generic signatures; nothing is un-genericized.

Everything else merged without conflict. Checked by hand rather than
trusted to the textual merge:

- crypto/stark/src/gpu_lde.rs — the two sides are disjoint. #935 appends
  the host-download helpers and the sticky fault hooks; the H-threading
  edits sit in the tree-building and FRI-commit entries. main's one hunk
  inside threaded territory is comment-only.
- crypto/math-cuda/src/lib.rs — both sides add one `pub mod` to the same
  alphabetized list (`blake3` here, `faults` on main); both survive.
- crypto/math-cuda/src/device.rs — touched only by this branch, so #935's
  math-cuda edits (barycentric, deep, faults, merkle) do not collide.
- prover/tests/calibration.rs and prover/src/auto_storage.rs — #911 splits
  the scheduler's `k` from the storage estimate's, so both call sites move
  to `storage_estimate_parallelism()`. This branch never touched either
  file, so main's version lands whole and the RAM-vs-Disk decision is
  unmoved.
- The `table_parallelism()` call site takes main's `table_parallelism(num_airs)`,
  which clamps internally to the same range this branch clamped by hand.
MauroToscano added a commit that referenced this pull request Aug 18, 2026
Carries origin/main (cf3b1e9) onto the flip branch: the bump guest allocator
default (#869), the VRAM-pressure/R2-race fix (#914), the cuda table scheduler
K = num_airs default (#911), and the device-only cliff recovery at R4 DEEP /
comp-tree / R3 barycentric (#935).

No conflicts. Both of the resolutions made when main met this campaign's tree
were already settled one branch down and arrive whole:

- `crypto/stark/src/prover.rs` — Round1/Round2 carry both main's `&mut` and the
  campaign's `H` parameter, so #935's cliff recovery runs through the H-generic
  signatures.
- `crypto/stark/src/prover.rs` — the device-only main-LDE recovery matches
  `MainLdeSlot` exhaustively: `Retained` downloads off the resident handle,
  `Dropped` (RecomputeLde) needs nothing because the fused task rebuilds from the
  host trace.

The flip's own collision surface stayed clear: the renamed alias layer
(`DefaultStarkHash`, `DefaultStarkTranscript`) and the `assert_keccak_backend`
guard over the cuda fork are untouched by main's gpu_lde edits, and the cuda
clippy pass — where this branch resolves to keccak — compiles clean.

Gates: stark release 287/0; crypto 72/0 on both round arms; `lfm::` 354 passed /
1 failed / 9 ignored, the same single pre-existing `fibonacci.elf` drift
exonerated in RESUME-PA-STAGE6.md §5.7, so zero delta; BLAKE3 host KAT green on
both round arms; second-source green; `make lint` clean across all five combos;
fmt clean.

The cross-version king gate against pre-merge refs stays failing by design — that
is the flip's inverted polarity (PA-PLAN §6), not a merge regression.
MauroToscano added a commit that referenced this pull request Aug 18, 2026
Carries origin/main (cf3b1e9) onto the MMCS integration branch: the bump guest
allocator default (#869), the VRAM-pressure/R2-race fix (#914), the cuda table
scheduler K = num_airs default (#911), and the device-only cliff recovery at
R4 DEEP / comp-tree / R3 barycentric (#935).

This is the branch where the two sides genuinely interleave. M-4p2 extracted the
round bodies so they take the data they use — `lde_trace`, `composition_parts`,
`rap_challenges` — instead of the whole `Round1`/`Round2`, and `multi_prove_batched`
reuses those same extractions. #935 works the other way: its recoveries write the
resident device data back into those very buffers, which is why upstream widened
the round signatures to `&mut Round1`/`&mut Round2`. Neither shape can simply win.

Resolution rule, applied to all nine hunks: **keep the extraction, move the
mutability onto the extracted parameter.** Each recovery then writes to exactly
the buffer its caller owns, and the batched path keeps sharing one implementation
with the monolithic one.

- `crypto/stark/src/prover.rs` `compute_composition_parts` — `lde_trace` becomes
  `&mut`; the R2 host-evaluator arm takes #935's recover-then-assert (replacing the
  old hard abort) against that parameter rather than `round_1_result.lde_trace`.
- `crypto/stark/src/prover.rs` `compute_composition_parts` — the `evaluate_dev`
  arm keeps the extracted `rap_challenges` and the extracted `lde_trace` in the
  `host_trace_empty` retain flag.
- `crypto/stark/src/prover.rs` `round_2_compute_composition_polynomial` — keeps the
  `CompositionParts` return, and #935's fold of the R2 device parts handle into the
  session (`set_gpu_composition_parts`) is added after the call, where
  `round_1_result` is in scope.
- `crypto/stark/src/prover.rs` `round_3_evaluate_polynomials_in_out_of_domain_element`
  — extracted `lde_trace` and `composition_parts` both become `&mut`; the R3 parts
  OOD arm takes #935's recovery against them.
- `crypto/stark/src/prover.rs` `compute_deep_composition_poly_evaluations` — same
  two parameters become `&mut`; the host DEEP loop's recovery writes through
  `composition_parts` instead of `round_2_result.lde_composition_poly_evaluations`.
- `crypto/stark/src/batched/prover.rs` — the three call sites and `deep_codeword`
  follow the widened signatures; the FRI combine closure captures `retained_parts`
  mutably. That closure is `FnOnce` and runs serially, so the capture adds no
  concurrency requirement.
- `crypto/stark/src/prover.rs` — the two `mut` bindings the split moved: the
  parts the R2 commit recovery writes now live in
  `round_2_compute_composition_polynomial` (so `computed.parts` is bound `mut`
  there), and `compute_composition_parts`'s own local is no longer mutated by
  anything, so it loses the `mut` and the `unused_mut` cfg_attr that went with it.
  Only the cuda lint pass sees either.

Both semantics are live afterwards: nothing is un-genericized, no recovery is
dropped, and the parameter each recovery writes to is the one the caller reads
next.

Gates: stark release 350/0 (RESUME-MMCS-INT.md's 349/0 plus main's new
`table_parallelism_stays_within_one_and_num_airs`); debug batched/mmcs 87/0,
exactly the recorded baseline; crypto 71+1/0 on both round arms; `make lint`
clean across all five combos; fmt clean.

`lfm::` reads 345 passed / 19 failed / 9 ignored against a recorded baseline of
349/15/9, and the merge is NOT the cause. Checked out 46798a5 — this branch's
own pre-merge tip — and ran the same suite there: 345/19/9, and the 19 failing
test names diff byte-identical against the merged tree's. The merge delta is
exactly zero; the recorded baseline is stale, drifted by the fixture/toolchain
trap already documented in the lfm fixture-drift notes. Independently, every
resolution in this merge is inside `#[cfg(feature = "cuda")]` or is a signature
mutability change, and that suite runs without cuda, so it could not have moved
those tests either way.

SEMANTIC-CONFLICT NOTE. The batched path consumes its parts on the host
immediately (`parts_builder.absorb`) and never reads the device parts handle, so
the recoveries are inert there today — `materialize_composition_parts_host`
returns true without touching anything when the evals are already populated, so
the widened signatures cost the batched path nothing and cannot trip its asserts.
The recovery is only reachable on the monolithic path. Flagged because that is a
judgement about reachability, not something a test currently pins.
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.

5 participants