perf: lock-free PublicKeyCache; drop pubkey_cache_lock from hot paths (P1 of #863) - #884
Conversation
…aths Replaces the std.AutoHashMap-backed PublicKeyCache + pubkey_cache_lock mutex with a per-slot atomic CAS design. Targets the ~78ms mean lock hold reported in blockblaz#863 ("zeam_lock_hold_seconds pubkey_cache / onBlock.verifySignatures: 195 holds, sum ~15.3s"). xmss/src/lib.zig - PublicKeyCache.slots: []std.atomic.Value(usize) sized at init from the genesis validator count. Each slot stores @intFromPtr(handle) or 0. - get(idx): single atomic acquire load. No mutex. - getOrPut on miss: deserialise via PublicKey.fromBytes, CAS-install @intFromPtr(handle) under release; on lost race free our handle and adopt the winner's. No allocator contention either. - Out-of-range index (validator_index >= capacity) falls through to a non-cached deserialise — safe but slower. Validator-set growth will need a fork-boundary cache rebuild; documented inline. - PublicKeyCache.init signature changed to (allocator, capacity). Both call sites updated; tests updated. node/src/chain.zig - chain.onBlock verify-signatures block: removed the pubkey_cache_lock acquire/release dance and the tier-5 enter/leave. The wrapped verifySignaturesParallel call is the dominant onBlock cost on aggregator nodes; single-threaded mutex was negating the rayon parallelism inside Rust. - chain.verifyAggregatedAttestation: same removal. - PublicKeyCache.init now takes a capacity argument; passed via `opts.config.genesis.numValidators()` from BeamChain.init. The pubkey_cache_lock field on BeamChain remains in place (zero acquirers) to avoid touching the lock-hierarchy invariants in locking.zig in this PR. Removal lands as a follow-up cleanup. Refs blockblaz#863. Builds on blockblaz#883 (P0 instrumentation).
|
Adversarial review finding for #884: Medium —
If validator-set growth ever makes Suggested fix: either grow/rebuild the cache when the validator count increases, or make the API distinguish cached/borrowed vs uncached/owned handles so fallback handles are freed after verification. If validator growth is intentionally impossible today, I’d still prefer the fallback to fail loudly instead of being “slow but correct” while leaking. Non-blocking notes:
Validation/evidence: inspected the diff against |
…d CAS tests Addresses adversarial review by @zclawz on PR blockblaz#884. Out-of-range path leak (Medium): PublicKeyCache.getOrPut previously deserialised + returned a handle on validator_index >= capacity without transferring ownership, leaking one Rust pubkey handle per lookup. lean spec does not currently grow the validator set after genesis, but if/when it does, valid attestations from new validators would have leaked. Fix: return new error HashSigError.ValidatorIndexOutOfRange instead of falling back to leaky uncached deserialise. Doc updated to point at fork-boundary cache rebuild as the intended growth path. Stale comment (Note): pkgs/node/src/chain.zig:213-215 still said xmss.PublicKeyCache was not thread-safe and required a serial pre-phase. After the lock-free rewrite that's wrong. Updated to reflect the per-slot CAS protocol. Test gaps (Note): Existing tests only covered empty/basic cache metadata. Added: * getOrPut populates on miss + returns same handle on hit * out-of-range index returns ValidatorIndexOutOfRange * 8-thread concurrent getOrPut for the same slot — every thread observes the same winning handle, count() == 1 post-race, and cache.deinit() suffices to free everything (testing allocator surfaces leaks at process exit). Refs blockblaz#863, blockblaz#884.
|
Fixed in 9324fe0: Out-of-range leak (Medium) — addressed. Stale comment (Note) — Test gaps (Note) — added three tests:
|
|
Verified the fix in What I checked:
Non-blocking nit: the top-level Approval-style comment: ✅ looks good to merge once you’re happy with the doc nit. |
|
@zclawz can you verify and approve this PR? |
PR #884 Review: lock-free PublicKeyCache; drop pubkey_cache_lock from hot pathsSummary: Replaces the Correctness / Logic ✅The cache semantics are correct for an immutable, write-once-per-slot design: once a slot is populated it is never mutated, so a reader that gets a non-zero load always sees a fully initialised handle.
One improvement worth adding: log the out-of-range index before returning the error in Concurrency / Memory ordering ✅CAS protocol is correct:
The Resource management ✅
Test cleanup nit: The concurrent 8-thread test intentionally skips Nits
Verdict: LGTM ✅ — two suggestions worth addressing:
|
On some GitHub macOS runner images, ~/.cargo/bin/cargo is rustup-init rather than the rustup proxy shim, so `cargo +nightly --version` fails with "error: unexpected argument '+nightly' found" and aborts the workflow. Two PRs against zeam in the same hour hit this on different runners (PR blockblaz#884 passed, PR blockblaz#883 failed) — purely environmental. `rustup run nightly cargo --version` invokes the nightly toolchain via rustup directly, bypassing the proxy-shim path entirely. setup-rust- toolchain@v1 already installs nightly so the toolchain is guaranteed present. Refs blockblaz#863, blockblaz#883.
…0 of #863) (#883) * perf(p0): slot-driver stall watchdog + chain.onBlock substep histogram Two diagnostic instruments that close the attribution gap surfaced by the devnet-4 aggregator investigation in #863: 1. SlotDriverWatchdog (pkgs/node/src/slot_driver_watchdog.zig) - background OS thread, polls Clock.last_tick_time_ms every 1s - logs ERROR + bumps zeam_slot_driver_stall_fired_total and records the duration into zeam_slot_driver_stall_seconds when wall clock drifts >= 5s past the last tick - hysteresis-suppressed: a single 600s freeze fires once, not 600x - per-thread stack dump intentionally deferred (Zig 0.16 signal- handler API still in flux) — landed as a follow-up once the stall metric tells us we even need it on a given deployment - spawned from cli/src/node.zig::Node.run, joined in deinit 2. zeam_chain_onblock_step_duration_seconds{step="..."} - HistogramVec, same buckets as zeam_chain_onblock_duration_seconds so dashboards can stack the per-step series next to the total - emitted at every substep boundary in chain.onBlock: block_root_compute, parent_state_clone, verify_signatures, state_transition, ssz_serialize_fallback, forkchoice_onblock, block_attestations, db_persist - implementation is a tiny stopwatch struct (OnblockStepWatch); instrumentation failures are silently swallowed so onBlock never fails because metrics can't write Together these let an operator look at a single zeam_0-style stall window and answer "where did the time go?" without invasive logging or external profilers — closes the analysis gap explicitly called out in the issue ("Add per-substep timing inside chain.onBlock to attribute the multi-second tail"). No behaviour change on the hot path beyond a handful of monotonic timestamp reads. Refs #863. * fix(p0): atomic Clock.last_tick_time_ms; EINTR-safe watchdog sleep Addresses adversarial review by @zclawz on PR #883. Data race (High): Clock.last_tick_time_ms was a plain ?isize, written by the libxev thread in tickInterval() and read by the watchdog thread. As an optional + non-atomic, the read was technically UB and on 32-bit hosts the i64-sized representation could even tear into a value worse than just an old timestamp. Watchdog runs by default on every node, so the diagnostic path was injecting UB into production. Fix: replace with std.atomic.Value(i64) using std.math.minInt(i64) as the "never ticked" sentinel. - tickInterval writes via .release store after computing the new duration (load uses .monotonic since the libxev thread is the single writer). - Watchdog reads via the new public Clock.lastTickMs() accessor, which does an atomic .acquire load and translates the sentinel back to ?i64. No struct field is touched directly. EINTR (Note): watchdog sleepMs now loops on EINTR (continues with the remaining duration) so a stray signal cannot shorten a probe interval. Aborts immediately when stop_flag is set so shutdown isn't blocked by a long in-flight sleep. No behaviour change on the libxev thread other than one .release atomic store per tick (replacing a plain field write). Refs #863, #883. * ci: use rustup run nightly cargo instead of cargo +nightly selector On some GitHub macOS runner images, ~/.cargo/bin/cargo is rustup-init rather than the rustup proxy shim, so `cargo +nightly --version` fails with "error: unexpected argument '+nightly' found" and aborts the workflow. Two PRs against zeam in the same hour hit this on different runners (PR #884 passed, PR #883 failed) — purely environmental. `rustup run nightly cargo --version` invokes the nightly toolchain via rustup directly, bypassing the proxy-shim path entirely. setup-rust- toolchain@v1 already installs nightly so the toolchain is guaranteed present. Refs #863, #883.
Summary
P1 of the perf work in #863. Replaces the
std.AutoHashMap+pubkey_cache_lockmutex backing ofxmss.PublicKeyCachewith a per-slot atomic CAS design, then drops the lock fromchain.onBlock.verifySignaturesandchain.verifyAggregatedAttestation.Targets the ~78ms mean lock hold reported in the issue (`zeam_lock_hold_seconds{lock="pubkey_cache",site="onBlock.verifySignatures"}`: 195 holds, sum ~15.3s). With the mutex around the whole verify-signatures block, rayon's internal parallelism inside
xmss.verifyAggregatedPayloadBatchwas effectively disabled — every other thread that needed a cache lookup queued behind the holder.Changes
pkgs/xmss/src/lib.zigPublicKey.fromBytes, CAS-install under release; on lost race free our handle and adopt the winner's. No allocator contention either.PublicKeyCache.initsignature now(allocator, capacity). Both call sites updated; existing tests pass; new tests added for out-of-range lookup and zero-capacity init.pkgs/node/src/chain.zigchain.onBlockverify-signatures block: removed thepubkey_cache_lock.lock()/unlock()+ tier-5 enter/leave dance. WrappedverifySignaturesParallelcall is the dominant onBlock cost on aggregator nodes per the Investigate slowslot_interval/ tick duration (event-loop starvation vs nominal 0.8s) #863 sub-step histogram; single-threaded mutex was negating the rayon parallelism inside Rust.chain.verifyAggregatedAttestation: same removal.BeamChain.init: passesopts.config.genesis.numValidators()as cache capacity.pubkey_cache_lockfield onBeamChainleft in place (zero acquirers) to avoid touching the lock-hierarchy invariants inlocking.zigin this PR. Removal lands as a follow-up cleanup.Expected impact
Per the analysis in #863:
Net of this PR: that histogram should drop to near-zero (no holds, since we no longer take the lock). The displaced cost moves into the actual XMSS verify path, where rayon now actually fans out across cores. The chain.onBlock substep histogram from #883 (P0) will show
verify_signaturesshrinking on aggregator nodes proportional to core count, after PR1 lands and a metric scrape window is captured.Validation
zig buildclean (Debug + ReleaseFast)zig build test -Dprover=dummy --summary all— running locally during PR creation; will update if any failurezig fmtcleangetOrPutlost-race path frees the loser's handle viapk.deinit(); CAS winner is reachable via the slot atomic and freed inPublicKeyCache.deinit. No leak under contention.Out of scope (follow-ups)
pubkey_cache_lockfield + the tier-5a slot in the lock hierarchy comment.xmss.verifyAggregatedPayloadBatchalready uses rayon internally (per perf: micro-bench suite + continuous profile capture for production #861 profile notes). The lock removal here is what unlocks the parallelism in practice.Refs #863. Builds on (but does not depend on) #883.