Skip to content

feat: proving-scoped zk-alloc arena allocator (benchmark feature)#412

Draft
MegaRedHand wants to merge 3 commits into
devnet5-bump-leanmultisigfrom
devnet5-zk-alloc
Draft

feat: proving-scoped zk-alloc arena allocator (benchmark feature)#412
MegaRedHand wants to merge 3 commits into
devnet5-bump-leanmultisigfrom
devnet5-zk-alloc

Conversation

@MegaRedHand
Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

Stacked on #408. leanVM 0520822 ships zk-alloc, a bump-arena allocator that speeds up proving — but it does nothing unless the binary installs it as the global allocator, and installing it naively corrupts a live node: begin_phase() resets every thread's slab, so long-lived buffers allocated by tokio/p2p/RocksDB/actor threads during a phase are silently overwritten by the next reset.

This PR adds an off-by-default zk-alloc feature that scopes the arena to proving only, so a node can run it long enough to benchmark.

What Changed

  • ScopedZkAlloc (crates/common/crypto/src/zk_alloc.rs): a dispatcher #[global_allocator] that routes allocations to leanVM's arena only on flagged proving threads; every other thread always uses System. dealloc defers to ZkAllocator's address-based check; realloc is reimplemented to avoid leaking arena memory onto non-proving threads.
  • Proving threads = the global rayon pool's workers, flagged at construction (ThreadPoolBuilder::start_handler + build_global, before leanVM's setup_prover builds the pool unflagged). Two failed designs are documented in the module: per-phase rayon::broadcast flagging deadlocks the pool, and a dedicated install() pool segfaults once its crossbeam internals are first allocated inside a phase.
  • Phase discipline: all proving serialized behind a global mutex (prevents begin_phase nesting between the spawn_blocking aggregation worker and actor-thread block building); proof bytes serialized after end_phase, while the lock is held, so outputs land in System.
  • Bin wiring: --features zk-alloc swaps jemalloc for ScopedZkAlloc and runs init_allocator() + init_arena_rayon_pool() at startup. Default build is unchanged.

Tradeoffs (benchmark build only)

  • jemalloc and /debug/pprof heap endpoints are unavailable (ZkAllocator::dealloc hardcodes System for non-arena frees).
  • zk_alloc::init() asserts runtime core count == build-host core count → build on the machine you run on (binary-mode devnets, not Docker).
  • Proving is fully serialized; concurrent aggregation/block-build proofs queue on the mutex.

Validation

  • Crypto aggregate/verify round-trip tests run with ScopedZkAlloc installed as the test-binary allocator (real arena phases): 5/5 pass.
  • 4-node binary-mode devnet soak, 1019 slots (~68 min): all nodes ended on the same head root, justified=1014 / finalized=1008, zero panics / zero ERROR lines, RSS stable at ~2.7–3.0 GiB/node. 1053 aggregations through the arena: p50 203 ms, p90 283 ms, p99 1.1 s (max 3.0 s = cold first proof incl. setup).

A jemalloc-baseline run of the same topology is the natural follow-up to quantify the speedup.

Side finding (upstream, not addressed here)

handle_tick schedules the next tick after on_tick returns, relative to its entry interval — any duty exceeding the 800 ms interval (block building proves inline, ~1.3 s) silently skips the next interval, so a proposing node produces no attestations that slot. Single-node devnets can never justify; multi-node devnets lose the proposer's attestations every slot. Deserves a separate issue.

…ure)

Add an off-by-default zk-alloc feature enabling leanVM's bump-arena allocator
for XMSS aggregation without destabilizing a long-running node.

Installing leanVM's ZkAllocator directly as the global allocator would corrupt
unrelated threads: begin_phase() resets every thread's slab, so any long-lived
buffer the tokio/p2p/storage/actor threads allocate during a phase is silently
overwritten on the next reset. Instead, ScopedZkAlloc is a dispatcher global
allocator that routes to the arena only on flagged proving threads; everything
else always uses the system allocator.

Proving threads are the global rayon pool's workers, flagged at construction
via ThreadPoolBuilder::start_handler + build_global (leanVM's prover is the
pool's only user in ethlambda). Two earlier designs failed and are documented
in src/zk_alloc.rs: per-phase rayon::broadcast flagging deadlocks the pool's
sleep accounting, and a dedicated install() pool crashes once its crossbeam
internals are first allocated inside a phase (UAF after the next slab reset).
The global pool works because setup_prover grows those internals in System
memory before any phase exists.

All proving is serialized behind a global mutex (begin_phase nesting between
the spawn_blocking aggregation worker and actor-thread block building would
otherwise panic), and proofs are serialized to bytes after end_phase, while
still holding the lock, so outputs land in System and survive the next reset.

Tradeoffs (benchmark build only): drops jemalloc and the /debug/pprof heap
endpoints (ZkAllocator::dealloc hardcodes System for non-arena frees), and
zk_alloc::init() asserts the runtime core count matches the build host, so
the binary must be built on the machine it runs on.

Validated: crypto round-trip tests with ScopedZkAlloc as the test-binary
allocator, and a 4-node binary-mode devnet soak of 1019 slots (all nodes on
the same head root, finalized=1008, zero panics; 1053 aggregations,
p50=203ms / p90=283ms through the arena).
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

🤖 Claude Code Review

I have enough context from the diff to write the review. Let me compile my findings.


PR Review: feat: proving-scoped zk-alloc arena allocator (benchmark feature)

Overview

This PR integrates leanVM's zk-alloc bump-arena allocator in a way that's safe for a long-lived node process by confining arena allocations to rayon prover threads. The approach is sound and well-documented; the PR description itself reads like a design document. The 68-minute soak test provides empirical confidence for the core safety claim.


Correctness & Safety

1. Panic safety: begin_phase() precedes EndOnDrop initialization

In ArenaSession::prove:

// zk_alloc.rs
pub(crate) fn prove<T, F>(&self, produce: F) -> T {
    begin_phase();
    struct EndOnDrop;
    impl Drop for EndOnDrop {
        fn drop(&mut self) { end_phase(); }
    }
    let _end = EndOnDrop;   // ← initialized AFTER begin_phase
    produce()
}

If begin_phase() panics after setting ARENA_ACTIVE, _end hasn't been initialized yet — end_phase() is never called, leaving the arena in an active state. The lock (_lock) will be dropped (and poisoned), but subsequent calls recover via .unwrap_or_else(|p| p.into_inner()) and immediately call begin_phase() again on an already-active arena, whose behavior is undefined.

Fix: initialize _end before calling begin_phase():

pub(crate) fn prove<T, F>(&self, produce: F) -> T {
    struct EndOnDrop;
    impl Drop for EndOnDrop {
        fn drop(&mut self) { end_phase(); }
    }
    let _end = EndOnDrop;   // ← guard is live before begin_phase
    begin_phase();
    produce()
}

2. realloc shrink path leaks memory for System allocations

unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
    if new_size <= layout.size() {
        return ptr;  // ← no-op for System allocations
    }
    ...
}

For pointers allocated via System, returning without calling System::realloc means the system allocator keeps the full old allocation live. This is valid per the GlobalAlloc contract, but it silently inflates RSS for any long-lived non-proving allocation that shrinks (e.g., Vec::shrink_to_fit). Acceptable for a benchmark build, but worth noting explicitly in the comment that System blocks are not shrunk.

3. dealloc always defers to ZkAllocator

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
    unsafe { ZkAllocator.dealloc(ptr, layout) };
}

This relies entirely on ZkAllocator::dealloc being address-based (arena pointers → no-op / arena free; System pointers → forward to free). If upstream ZkAllocator::dealloc ever uses a flag-based check (e.g., ARENA_ACTIVE) rather than address ranges, pointers allocated in System on a non-flagged thread but freed during a phase would be mishandled. The correctness claim cannot be verified from this diff alone — the PR should document which leanVM version guarantees the address-based contract, or assert it in a comment.

4. init_arena_rayon_pool() called redundantly inside prove_type1 / prove_type2

// lib.rs, zk-alloc path of prove_type1
zk_alloc::init_arena_rayon_pool();   // ← Once, so nearly free after startup
let session = zk_alloc::ArenaSession::begin();
ensure_prover_ready();

The call in main.rs at startup already runs this. The in-proof calls are defended as test-setup guards (where main.rs never runs). That's correct reasoning, but the comment currently only says "Must precede ensure_prover_ready" — it should also say "needed when called from test binaries that bypass main." As written, a future reader might remove it as dead code.


Design Notes

5. Proving serialization is global, not per-call-site

PROVING is a single process-wide mutex. Any concurrent prover call (from the actor thread building a block vs. the spawn_blocking aggregation worker) queues rather than runs in parallel. The PR description acknowledges this. For a benchmark build this is fine, but if parallelism is ever added upstream, the serialization point will need to become per-arena.

6. Rayon pool ordering is subtly fragile

The safety argument depends on init_arena_rayon_pool() running before any code that touches the global rayon pool. The Once guard handles idempotency correctly. But the warning path ("proving will not use the arena") is silent to the user at runtime — if a dependency is added that initializes rayon before main() runs (e.g., a LazyLock or ctor), proving silently degrades with no visible error. A runtime assertion (or at minimum a warn! rather than eprintln!) would make this more observable.

7. Mutex poison recovery

let lock = PROVING.lock().unwrap_or_else(|poison| poison.into_inner());

After a prover panic, the recovered lock is immediately used to start another phase. If end_phase() was NOT called (per the bug in point 1), calling begin_phase() again on an active arena is undefined behavior. Fixing point 1 removes this exposure.


Code Quality

  • The RAII EndOnDrop pattern is the right idiom; the bug is only in ordering (covered above).
  • The two failed designs are documented clearly in the module doc — valuable for long-term maintainability.
  • Feature-gating via #[cfg(feature = "zk-alloc")] throughout lib.rs is clean; no dead code or stub impls in the default build.
  • PROVING is pub in zk_alloc.rs but not re-exported from the crate's pub use — effectively crate-private. Fine.
  • The prove_type1 / prove_type2 duplication is a consequence of Rust not having HKT; can't collapse these without boxing. Acceptable.

Summary

One real correctness issue (Item 1 — begin_phase() before the RAII guard) and one observability gap (Item 6 — silent pool-already-initialized fallback). Everything else is either acknowledged tradeoff or minor documentation. The broader design — thread-local flag + global rayon pool flagged at construction + serialized sessions — is the right solution to the stated problem. The devnet soak validates the "no corruption" invariant for the key failure mode.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

🤖 Codex Code Review

  1. zk-alloc currently has a hidden global-init precondition that the library path does not enforce. crates/common/crypto/src/lib.rs:35 re-exports ScopedZkAlloc and the proving helpers start using it in prove_type1 / prove_type2, but init_allocator() is only called from the binary in bin/ethlambda/src/main.rs:165. That makes the feature easy to misuse from tests or any future non-binary caller of ethlambda-crypto, and the test-only allocator at crates/common/crypto/src/lib.rs:43 has the same gap. I’d move init_allocator() behind a Once inside the library-side zk-alloc entry path so the feature is self-contained.

  2. The Rayon-pool setup can silently fail while the binary still reports success. init_arena_rayon_pool() swallows build_global() failure with inspect_err(...).ok(), but main logs Using zk-alloc arena allocator unconditionally right after. If anything initialized Rayon first, this build silently falls back to the system allocator on prover workers, which makes benchmark results misleading and obscures whether the safety assumptions here are actually active. This should return a Result/status and log a warning or abort the benchmark mode on fallback.

No consensus/fork-choice/state-transition logic was modified here, and I didn’t see a direct correctness regression in those areas from this diff. I could not run cargo check end-to-end in this environment because dependency resolution is blocked offline, so the review is from static inspection only.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 3, 2026

Greptile Summary

This PR introduces an off-by-default zk-alloc feature that scopes leanVM's bump-arena allocator exclusively to the global rayon pool's proving workers, preventing the arena from touching tokio/p2p/storage threads and corrupting a live node. All proving calls are serialized behind a PROVING mutex to prevent begin_phase() nesting.

  • ScopedZkAlloc dispatches alloc/alloc_zeroed through the thread-local USE_ARENA flag; dealloc always routes through ZkAllocator (address-based, forwarding non-arena pointers to System); realloc is reimplemented to avoid delegating to ZkAllocator::realloc, which gates on ARENA_ACTIVE rather than the thread-local flag.
  • prove_type1/prove_type2 wrap every prover call in an ArenaSession that acquires the PROVING lock and calls end_phase() via EndOnDrop on return, with proof serialization happening after end_phase() but while the lock is held to prevent concurrent begin_phase() resets.
  • Startup wiring in main.rs installs ScopedZkAlloc, calls init_allocator() (leanVM's core-count assertion), and calls init_arena_rayon_pool() to flag rayon workers before any other rayon user can build the global pool unflagged.

Confidence Score: 3/5

Safe to merge as a benchmark-only feature (off by default), but the arena phase management in zk_alloc.rs has two fragile spots worth addressing before widening use.

The allocator dispatch logic and proving serialization are well-thought-out and backed by a 68-minute devnet soak. Two concerns sit in ArenaSession::prove: proof serialization reads arena memory after end_phase() has been called, relying on an undocumented leanVM invariant that slab resets only happen at begin_phase(); and if begin_phase() itself panics, EndOnDrop is never constructed, leaving ARENA_ACTIVE potentially stuck active. Neither is a current defect given the tested leanVM version and the benchmark-only scope, but both create silent failure modes if leanVM internals change or an unexpected panic occurs in begin_phase().

crates/common/crypto/src/zk_alloc.rs — specifically the ArenaSession::prove method drop ordering and the gap between begin_phase() and EndOnDrop construction.

Important Files Changed

Filename Overview
crates/common/crypto/src/zk_alloc.rs New file: implements ScopedZkAlloc dispatcher allocator and ArenaSession. Core fragility: end_phase() is called inside prove() via EndOnDrop before the returned proof value is serialized by callers; correctness depends on end_phase() not invalidating arena memory. Also: if begin_phase() itself panics, EndOnDrop is never created and end_phase() is never called, leaving ARENA_ACTIVE potentially set.
crates/common/crypto/src/lib.rs Refactors all prover call sites into prove_type1/prove_type2 helpers that manage the ArenaSession lifecycle; non-zk-alloc paths are unchanged. The compress_*_to_byte_list calls occur after end_phase() but while the PROVING lock is held.
bin/ethlambda/src/main.rs Adds zk-alloc startup block: installs ScopedZkAlloc as global allocator, calls init_allocator() and init_arena_rayon_pool() early in main. Feature-gating for jemalloc/malloc_conf is correct.
bin/ethlambda/Cargo.toml Adds optional ethlambda-crypto dependency and zk-alloc feature gate; clean and correct.
crates/common/crypto/Cargo.toml Adds optional rayon dependency behind zk-alloc feature; minimal and correct.

Sequence Diagram

sequenceDiagram
    participant Main as main.rs
    participant CryptoLib as crypto/lib.rs
    participant ZkAlloc as zk_alloc.rs
    participant Rayon as Global Rayon Pool
    participant LeanVM as lean_multisig

    Main->>ZkAlloc: init_allocator()
    Main->>ZkAlloc: init_arena_rayon_pool()
    ZkAlloc->>Rayon: "build_global() + start_handler sets USE_ARENA=true"

    Note over Main,LeanVM: Per proof call

    Main->>CryptoLib: aggregate_signatures / merge / split
    CryptoLib->>ZkAlloc: ArenaSession::begin() lock PROVING mutex
    CryptoLib->>LeanVM: ensure_prover_ready() outside phase System memory
    CryptoLib->>ZkAlloc: session.prove(closure)
    ZkAlloc->>LeanVM: "begin_phase() ARENA_ACTIVE=true"
    ZkAlloc->>LeanVM: "produce() rayon workers USE_ARENA=true arena allocs"
    LeanVM-->>ZkAlloc: LMType1/LMType2 arena memory
    Note over ZkAlloc: EndOnDrop drop calls end_phase() HERE
    ZkAlloc-->>CryptoLib: LMType1 arena ptr phase already ended
    CryptoLib->>LeanVM: compress reads arena memory post end_phase
    LeanVM-->>CryptoLib: ByteList512KiB System memory
    Note over CryptoLib,ZkAlloc: session drops PROVING mutex released
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/common/crypto/src/zk_alloc.rs:175-187
**Post-`end_phase()` arena read is an undocumented leanVM invariant**

`EndOnDrop` calls `end_phase()` when `prove()` returns — before `compress_type1_to_byte_list` / `compress_type2_to_byte_list` runs in the callers. Those functions read the `LMType1`/`LMType2` value, which may contain pointers into arena memory. Safety depends entirely on `end_phase()` not freeing or invalidating arena memory (only `begin_phase()` resetting the slab does). This invariant is internal to `lean_multisig` and not part of any published API contract. A future leanVM release that flushes or frees slabs in `end_phase()` would silently turn proof serialization into a use-after-free.

A structurally safer alternative is to move `begin_phase()`/`end_phase()` into `ArenaSession`'s constructor and `Drop` impl respectively, so the phase lifetime matches the session lifetime. Compression then runs before the session (and the phase) ends, making the arena-memory liveness invariant explicit in the code structure rather than an assumption about upstream behaviour.

### Issue 2 of 2
crates/common/crypto/src/zk_alloc.rs:174-187
**`begin_phase()` panic leaves `end_phase()` uncalled**

`_end = EndOnDrop` is only created *after* `begin_phase()` returns. If `begin_phase()` itself panics (e.g. upstream assertion on nested phases), `EndOnDrop` is never constructed, so `end_phase()` is never called. The panic unwinds through `prove_type1`, drops `session`, and poisons the `PROVING` mutex. The next `ArenaSession::begin()` recovers the poison and proceeds — but if `begin_phase()` had already set `ARENA_ACTIVE = true` before panicking, the arena switch is now permanently stuck active. Any allocation on a flagged rayon worker from that point on will silently land in the (unreset) arena.

Moving the `EndOnDrop` guard to before `begin_phase()` is called — or initialising `_end` first and then calling `begin_phase()` inside its constructor — would close this window.

Reviews (1): Last reviewed commit: "feat(crypto): proving-scoped zk-alloc ar..." | Re-trigger Greptile

Comment on lines +175 to +187
begin_phase();
// Guarantees `end_phase` runs even if the prover panics, so the global
// arena switch is never left stuck active. leanVM's `end_phase` also
// flushes the global pool's injector (its job blocks may live in arena).
struct EndOnDrop;
impl Drop for EndOnDrop {
fn drop(&mut self) {
end_phase();
}
}
let _end = EndOnDrop;
produce()
}
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.

P2 Post-end_phase() arena read is an undocumented leanVM invariant

EndOnDrop calls end_phase() when prove() returns — before compress_type1_to_byte_list / compress_type2_to_byte_list runs in the callers. Those functions read the LMType1/LMType2 value, which may contain pointers into arena memory. Safety depends entirely on end_phase() not freeing or invalidating arena memory (only begin_phase() resetting the slab does). This invariant is internal to lean_multisig and not part of any published API contract. A future leanVM release that flushes or frees slabs in end_phase() would silently turn proof serialization into a use-after-free.

A structurally safer alternative is to move begin_phase()/end_phase() into ArenaSession's constructor and Drop impl respectively, so the phase lifetime matches the session lifetime. Compression then runs before the session (and the phase) ends, making the arena-memory liveness invariant explicit in the code structure rather than an assumption about upstream behaviour.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/crypto/src/zk_alloc.rs
Line: 175-187

Comment:
**Post-`end_phase()` arena read is an undocumented leanVM invariant**

`EndOnDrop` calls `end_phase()` when `prove()` returns — before `compress_type1_to_byte_list` / `compress_type2_to_byte_list` runs in the callers. Those functions read the `LMType1`/`LMType2` value, which may contain pointers into arena memory. Safety depends entirely on `end_phase()` not freeing or invalidating arena memory (only `begin_phase()` resetting the slab does). This invariant is internal to `lean_multisig` and not part of any published API contract. A future leanVM release that flushes or frees slabs in `end_phase()` would silently turn proof serialization into a use-after-free.

A structurally safer alternative is to move `begin_phase()`/`end_phase()` into `ArenaSession`'s constructor and `Drop` impl respectively, so the phase lifetime matches the session lifetime. Compression then runs before the session (and the phase) ends, making the arena-memory liveness invariant explicit in the code structure rather than an assumption about upstream behaviour.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +174 to +187
{
begin_phase();
// Guarantees `end_phase` runs even if the prover panics, so the global
// arena switch is never left stuck active. leanVM's `end_phase` also
// flushes the global pool's injector (its job blocks may live in arena).
struct EndOnDrop;
impl Drop for EndOnDrop {
fn drop(&mut self) {
end_phase();
}
}
let _end = EndOnDrop;
produce()
}
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.

P2 begin_phase() panic leaves end_phase() uncalled

_end = EndOnDrop is only created after begin_phase() returns. If begin_phase() itself panics (e.g. upstream assertion on nested phases), EndOnDrop is never constructed, so end_phase() is never called. The panic unwinds through prove_type1, drops session, and poisons the PROVING mutex. The next ArenaSession::begin() recovers the poison and proceeds — but if begin_phase() had already set ARENA_ACTIVE = true before panicking, the arena switch is now permanently stuck active. Any allocation on a flagged rayon worker from that point on will silently land in the (unreset) arena.

Moving the EndOnDrop guard to before begin_phase() is called — or initialising _end first and then calling begin_phase() inside its constructor — would close this window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/crypto/src/zk_alloc.rs
Line: 174-187

Comment:
**`begin_phase()` panic leaves `end_phase()` uncalled**

`_end = EndOnDrop` is only created *after* `begin_phase()` returns. If `begin_phase()` itself panics (e.g. upstream assertion on nested phases), `EndOnDrop` is never constructed, so `end_phase()` is never called. The panic unwinds through `prove_type1`, drops `session`, and poisons the `PROVING` mutex. The next `ArenaSession::begin()` recovers the poison and proceeds — but if `begin_phase()` had already set `ARENA_ACTIVE = true` before panicking, the arena switch is now permanently stuck active. Any allocation on a flagged rayon worker from that point on will silently land in the (unreset) arena.

Moving the `EndOnDrop` guard to before `begin_phase()` is called — or initialising `_end` first and then calling `begin_phase()` inside its constructor — would close this window.

How can I resolve this? If you propose a fix, please make it concise.

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.

1 participant