Skip to content

fix(blockchain): warm XMSS keys after the slot's signatures, in parallel - #621

Merged
MegaRedHand merged 7 commits into
build/leanvm-unified-aggregate-apifrom
fix/xmss-warm-after-signing
Sep 24, 2026
Merged

MegaRedHand merged 7 commits into
build/leanvm-unified-aggregate-apifrom
fix/xmss-warm-after-signing

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Why

A leanVM XMSS key caches a single bottom subtree (split_level = ceil(log2(range)) / 2, so 512 slots for a key range of about 2^18), and on_tick warmed both keys of every validator for slot + 1 at the end of every tick. At slot 512k - 1:

Interval What happened
0 prepare(512k) rebuilt 64 subtrees one after another (~1.7 s on the actor). The proposer's block reached gossip ~1.7 s late
1 That warm had evicted the subtree 512k - 1 signs with, so every attestation rebuilt it on the signing path (32 signatures over 1.9 s instead of 0.5 s), and the next warm rebuilt the new subtree again

Some nodes attested before importing the late block and some after, so the votes split across two sources. Block 512k then carried 192 of the 1024 votes it needed 683 of. On a 32-node, 1024-validator devnet with 8 s slots, this was the only deviation from head - finalized = 3 in 16 hours: every 512 slots, it lost 2 to 3 slots of finality.

What

  • Warm after signing: the next slot is warmed at the end of the interval-1 tick, right after this slot's attestations are signed and published. Each key rebuilds only when its cached subtree doesn't cover the slot, so there is no per-slot guard. If the interval-1 tick is skipped, the next slot is only slower, because sign rebuilds the subtree when it isn't cached. The next slot's block is signed at interval 4, after this warm.
  • Proposal key: only the upcoming proposer's proposal key is warmed. The others sign nothing before their own turn, and keeping all of them warm rebuilt one subtree per validator at every boundary.
  • Parallel: the rebuilds are spread over scoped threads of four keys each, joined inline. leanVM builds a subtree sequentially, so the keys are the only parallelism.

Measured

32 validators, 512-slot subtrees, crossing a boundary, on an 11-core machine:

Keys Time
Before (serial, both keys) 64 1.54 s
After (batched, attestation keys + one proposal key) 33 142 ms
After, every key already warm 33 0.2 ms

A leanVM key caches a single bottom subtree (512 slots on devnet-5's key
range), and the actor warmed both keys of every validator for slot + 1 at
the end of every tick. At slot 512k-1 that meant:

- interval 0: 64 serial subtree rebuilds (~1.7 s) held the actor, so the
  proposer's block reached gossip ~1.7 s late;
- interval 1: that warm had evicted the subtree slot 512k-1 signs with, so
  every attestation rebuilt it on the signing path, and the next warm
  rebuilt the new one again.

Votes split between the late block and its parent, the next block carried
192 of 1024, and devnet-5 lost 2-3 slots of finality every 512 slots.

Warm from interval 2 on, once the slot's attestations are signed, and once
per slot; the next slot's block is signed at interval 4, after it. Warm the
upcoming proposer's proposal key alone, since the others sign nothing
before their turn. Spread the rebuilds over scoped threads of four keys
each, joined inline: across a boundary the warm drops from 1.54 s (64 keys,
serial) to 142 ms (33 keys) on an 11-core machine, and costs 0.2 ms when
every key is already warm.
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

I'll review this PR focusing on the key manager optimization for XMSS signing cache warming. Let me analyze the changes carefully.

Overall Assessment

This is a well-structured optimization PR that reduces unnecessary XMSS subtree rebuilds by:

  1. Only warming attestation keys (not all proposal keys)
  2. Only warming the specific proposal key for the next slot's proposer
  3. Adding slot-based deduplication to prevent redundant warms
  4. Using scoped threads for parallel warming

Issues Found

1. Race Condition in warmed_slot Update (Medium Severity)

File: crates/blockchain/src/key_manager.rs, Lines 90-93

if self.warmed_slot == Some(slot) {
    return;
}
self.warmed_slot = Some(slot);

The warmed_slot is updated before the actual warming completes. If prepare_keys_for panics or the process crashes during warming, the slot is marked as warmed but keys are cold. On restart/retry, the early return would skip warming.

Fix: Move self.warmed_slot = Some(slot) to after the thread::scope block completes successfully.

pub fn prepare_keys_for(&mut self, slot: u32, proposer: Option<u64>) {
    if self.warmed_slot == Some(slot) {
        return;
    }
    
    let keys = self.keys_to_warm(proposer);
    let start = Instant::now();
    std::thread::scope(|scope| {
        // ... spawn threads ...
    });
    
    // Only record success after warming completes
    self.warmed_slot = Some(slot);
    trace!(...);
}

2. Missing use std::time::Instant (Compilation Error)

File: crates/blockchain/src/key_manager.rs, Line 88

Instant::now() is used but I don't see use std::time::Instant in the visible diff. Verify this import exists or add it.

3. Thread Spawn Failure Handling

File: crates/blockchain/src/key_manager.rs, Lines 95-102

scope.spawn() can panic if the OS fails to create a thread (resource exhaustion). The std::thread::scope will propagate this panic, unwinding and dropping other spawned threads mid-warm. This could leave the warmed_slot incorrectly set (if fix from Point 1 isn't applied) or cause inconsistent state.

Consider: Is graceful degradation acceptable? With the current structure, a partial warm + panic + retry (with fix #1) would re-warm from scratch, which is safe but wastes work.

4. keys_to_warm Returns References with Tied Lifetime

File: crates/blockchain/src/key_manager.rs, Lines 112-121

fn keys_to_warm(&self, proposer: Option<u64>) -> Vec<(u64, KeyRole, &ValidatorSecretKey)>

This returns borrowed references tied to &self. The thread::scope closure captures keys by move, but the references inside point to self.keys which remains borrowed for the scope's duration. This is correct but fragile—any refactor moving warming outside &mut self methods would break.

The current usage is safe because prepare_keys_for takes &mut self, ensuring exclusive access during the scope. Document this invariant.

5. Test tiny_key_manager Seed Collision Risk

File: crates/blockchain/src/key_manager.rs, Lines 234-245

let key = |seed: u8| ValidatorSecretKey::generate_from_seed([seed; 32], 0..=1).unwrap();
// ...
attestation_key: key(2 * id as u8),
proposal_key: key(2 * id as u8 + 1),

For count > 128, 2 * id as u8 overflows. The test uses 2 * KEYS_PER_WARM_THREAD + 1 (9 with default), so this is fine in practice, but:

fn tiny_key_manager(count: u64) -> KeyManager {

The parameter type u64 suggests large counts are intended to be supported. Add a debug assertion or use checked_mul:

let att_seed = (2 * id).try_into().expect("seed overflow in test");

6. Comment/Doc Inconsistency in lib.rs

File: crates/blockchain/src/lib.rs, Lines 189-193

// No proposal key: the current slot's block was due at the previous
// slot's interval 4, before we started.

This comment explains why proposer: None is correct at startup, but the reasoning is subtle and assumes specific slot/interval timing. Consider expanding: if the node starts mid-slot, could it be before interval 4 and need to propose? Actually no—genesis/current slot calculation uses wall clock, and the first slot's block would only be proposed if we're in interval 4 of slot N-1, which is the "previous slot" relative to current_slot. The comment is correct but could be clearer.

7. Interval Logic Assumption

File: crates/blockchain/src/lib.rs, Lines 506-511

let attestations_signed = matches!(
    interval,
    SlotInterval::Aggregation | SlotInterval::SafeTargetUpdate | SlotInterval::EndOfSlot
);

This hardcodes that attestation signing completes before interval 1 ends. Verify this matches the actual duty timing in SlotInterval definition. If SlotInterval::Attestation (interval 1) is when attestations are produced/signed, then warming at Aggregation (interval 2) is correct. But if there's any path where attestations are signed later, the cache could be prematurely evicted.

Confirm: Is SlotInterval ordering Attestation(1) < Aggregation(2) < SafeTargetUpdate(3) < EndOfSlot(4)? The comment says "interval-2 warm lands before interval 4 signs the next slot's block"—this suggests block signing is at interval 4, which seems late (should be at slot start?). Double-check this matches spec.

Positive Observations

  1. Good use of std::thread::scope — avoids Arc overhead for short-lived parallelism
  2. Proper KEYS_PER_WARM_THREAD batching — amortizes thread spawn cost without excessive threads
  3. Correct deduplication — warmed_slot prevents redundant work across interval ticks
  4. Clean separation — keys_to_warm is testable and logically isolated
  5. Comprehensive tests — covers empty proposer, foreign proposer, batch spanning, and signing after warm

Minor Suggestions

  • Line 97: Consider rayon::scope if already in dependency tree for better thread pool reuse, though std::thread::scope is fine for this use case.
  • Line 100: The move closure captures batch by copy (it's a slice reference). Explicit move is correct but slightly misleading since &[(u64, KeyRole, &ValidatorSecretKey)] is Copy. No change needed.

Summary

Severity Count Key Issues
Medium 1 warmed_slot updated before success (Point 1)
Low 2 Missing import check (Point 2), test seed overflow (Point 5)
Info 4 Thread panic handling, lifetime fragility, doc clarity, interval assumption

The core logic is sound. Point 1 should be fixed before merge as it creates a correctness issue on failure paths.


Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings:

  1. Medium: the “overrun fallback” comment doesn’t hold for proposer warming, because the interval-4 path warms next_slot only after propose_block(next_slot, ...) has already signed the block. If the actor misses interval 2 and 3 and first regains control at interval 4, the proposer key stays cold on the critical path for the very slot we are about to publish. That turns the XMSS subtree rebuild back into proposal-path latency under load, which is exactly the case this change is trying to protect. See crates/blockchain/src/lib.rs and crates/blockchain/src/lib.rs.

  2. Medium: prepare_keys_for now creates one scoped OS thread per 4 warmed keys, every warmed slot. With a large local validator set this scales poorly: at the current registry limit of 4096 validators, warming attestation keys alone can spawn about 1024 threads in one tick. That is a lot of scheduler pressure in a latency-sensitive consensus process, and it can easily compete with the actor, Tokio workers, and aggregation/proving work. A bounded pool or reused blocking executor would be safer than thread-per-batch fanout. See crates/blockchain/src/key_manager.rs and crates/blockchain/src/key_manager.rs.

Consensus-wise, I didn’t see changes to fork choice, justification/finalization, attestation validation, state transition, or SSZ handling in this PR.

Testing gap: I couldn’t run the Rust tests in this environment because cargo/rustup tried to write under read-only /home/runner/.rustup.


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

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

I'll skip running the build (would need separate approval) since the diff is small and I've traced the types/borrows by hand. Here's the review.

Review: fix(blockchain): warm XMSS keys after the slot's signatures, in parallel

Overall this is a solid, well-reasoned fix. The root-cause analysis (subtree eviction racing with interval-1 signing) is correct, the new gating (Aggregation | SafeTargetUpdate | EndOfSlot) correctly starts only after interval 1's attestation signing has completed, and the once-per-slot memoization plus per-thread batching is a sound way to cut redundant work. The new unit tests cover the proposer-key selection logic and the once-per-slot guard reasonably well.

A few points worth a look:

1. thread::scope::spawn panics on OS thread-creation failure (crates/blockchain/src/key_manager.rs:100)
std::thread::Scope::spawn (unlike Builder::spawn) panics if the OS refuses to create a thread (e.g. thread-limit/cgroup exhaustion under load). Previously this path only did sequential in-process work with no thread creation, so it could never fail this way. A panic here propagates out of on_tick/handle_tick, which would very likely kill the whole BlockChainServer actor task (no more block production/attestation for that node until restart). Given this runs unconditionally once per slot, it's worth at least considering Builder::spawn (which returns io::Result) with a fallback to sequential warming on failure, rather than an unrecoverable panic in the consensus-critical actor.

2. Silent no-op for a stale proposer argument (crates/blockchain/src/key_manager.rs:90-94)
The warmed_slot guard now keys purely on slot, so a repeat call for the same slot with a different proposer value is silently ignored. Correctness today relies on get_our_proposer being deterministic for a given slot (validator count and key manager membership are fixed after genesis in this "lean" spec, with no runtime deposit/registration path I could find), so this isn't currently exploitable — but it's a latent trap if validator registration is ever added later. Worth a one-line comment on warmed_slot/prepare_keys_for noting that the memoization assumes get_our_proposer(slot) is stable across calls within a slot, so a future reader doesn't reintroduce the bug by adding dynamic validator sets without revisiting this cache.

3. Redundant get_our_proposer call at EndOfSlot (crates/blockchain/src/lib.rs:485-487 vs 512-513)
At SlotInterval::EndOfSlot, next_proposer is computed once (filtered by sync_status.duties_allowed()) for the actual proposal, and then get_our_proposer(next_slot) is computed again, unfiltered, for the warm call a few lines later. Functionally harmless (cheap linear scan, and warming proposal keys during a sync-gated period is wasted but not wrong), but it's duplicate work and slightly inconsistent that one call site filters on duties_allowed() and the other doesn't. Consider passing through the already-computed value, or explicitly noting why the warm path intentionally ignores the sync gate.

4. Fixed KEYS_PER_WARM_THREAD = 4 (crates/blockchain/src/key_manager.rs:56)
Reasonable given the measured 32-validator/11-core case, but it's not derived from available_parallelism(), so a node running a much larger validator set than measured could oversubscribe threads (or a smaller/busier host could undersubscribe). Not a bug, just a scalability knob to keep an eye on if validator-per-node counts grow.

Nice touches:

  • The warmed_slot guard and the attestations_signed interval gate are both well-commented and match the actual tick pipeline precisely (interval 1 signs, interval 2+ warms, interval 4 signs the next block) — I traced the sequencing across slot boundaries and didn't find an off-by-one.
  • Restricting warm-up to only the upcoming proposer's proposal key (rather than all validators' proposal keys) is correctly justified and tested (keys_to_warm_takes_every_attestation_key_and_only_the_proposers_proposal_key).
  • ValidatorSecretKey::prepare/sign both take &self, confirming leanVM's internal mutability makes the parallel warm safe — no two threads ever touch the same key concurrently since each key appears at most once across the batches.

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

KeyManager remembered the last slot it warmed so the interval-3 and
interval-4 repeats could skip the keys. The keys already know: leanVM's
`prepare` compares the requested slot's subtree with the one it caches
and rebuilds only on a miss. The slot memo duplicated that state one
level up, and could disagree with it, since a `sign` on a different
subtree swaps the cache without the memo seeing it.

A repeat call now pays a lock per key plus the helper threads' spawns,
about 0.2 ms, and a key past its range warns on each interval-2..4 call
instead of once per slot.
Intervals 3 and 4 re-asked in case the actor overran interval 2. That
fallback bought little: when the interval-2 warm is missed, `sign`
rebuilds the subtree itself on a miss, so the cost is latency on the next
slot's duties, the same as before warming existed. Warming at one place
also puts it next to the duty it follows, and brings the warning for a
key past its range back to once per slot.
The attestations are signed on the actor at interval 1, before
`produce_attestations` returns, so the end of that tick is the first point
where nothing is left to sign at this slot. Warming there instead of at
interval 2 leaves interval 2's tick to aggregation, and gives the next
slot's proposer an extra interval of margin before it signs at interval 4.

The warm runs after the early-aggregation timer is armed, so a slow warm
across a subtree boundary cannot push that timer back.
Nothing in ethlambda-blockchain uses it. The benchmark in bin/ethlambda
still does, so the workspace entry stays.
…ctor

`Scope::spawn` panics when the OS refuses a thread, so a warm that only
shifts latency could take the BlockChain actor down with it. Spawn with
`Builder::spawn_scoped` instead, and warm a batch on the calling thread
when its thread cannot be created.

The fan-out also spawned one thread per four keys: a node with 1000
validators started ~250 threads every slot, and a lone validator one
thread that added no parallelism. Cap the threads at the core count,
keep each batch at four keys or more, and let the calling thread take
the first batch, so a single batch spawns nothing.

With the fan-out in its own function, its test checks that every key is
visited exactly once, which the old smoke test could not fail on.
Alongside: `KeyRole` is public and replaces the binary's two copies of
the same enum, the per-key trace names its validator and role, the log
fields put the slot first, and the test key seeds no longer overflow
past 127 validators.
The first tick fires as soon as the actor starts and runs the current
interval's duty. The startup warm always targeted the current slot's
attestation keys, so a node started from interval 2 on warmed a slot it
no longer attests at, and never warmed the proposal key that signs the
next slot's block at this slot's interval 4. A restarted key has an
empty cache, so that build rebuilt a whole subtree.

Target the current slot up to interval 1, and the next slot plus its
proposer's key from interval 2 on.

`get_our_proposer` now takes the validator count: it cloned the whole
head state on every call, and the tick already reads it.
@MegaRedHand
MegaRedHand merged commit 13d20b5 into build/leanvm-unified-aggregate-api Sep 24, 2026
3 of 4 checks passed
@MegaRedHand
MegaRedHand deleted the fix/xmss-warm-after-signing branch September 24, 2026 21:49
MegaRedHand added a commit that referenced this pull request Sep 24, 2026
## 🗒️ Description / Motivation

Follow-up to #621. After that PR, crossing a 512-slot subtree boundary
still blocks the blockchain actor for ~142 ms at interval 1, while
`prepare_keys_for` rebuilds the next subtree of every attestation key
and joins the rebuilds inline. This moves that warm off the actor, so
the tick returns immediately.

It is safe to overlap with signing because of how leanVM caches the
subtree (`xmss::XmssSecretKey`): the cache is
`Mutex<Option<BottomSubtree>>`, and both `prepare` and `sign` go through
`cached_bottom_subtree`. That takes the lock, rebuilds only on a miss,
and holds the lock while rebuilding. A signature that races the warm of
the same key therefore waits for that rebuild and reuses it. It never
rebuilds twice, so the worst case is the current inline cost.

## What Changed

- **`crates/blockchain/src/key_manager.rs`**
- `KeyManager` stores `Arc<ValidatorKeyPair>`, so a background thread
can own clones of the keys. `KeyManager::new` keeps its signature.
- New `prepare_keys_in_background(slot, proposer)`: spawns an
`xmss-warm` thread running the same batched warm, and keeps its
`JoinHandle`.
- The warm body (`available_parallelism` + `for_each_batched` from #621)
moved into a free function `warm_keys`, shared by the blocking
`prepare_keys_for` and the background path. The background thread takes
`for_each_batched`'s inline first batch, and any further batches still
go to its capped scoped threads.
- **`crates/blockchain/src/lib.rs`**
  - The interval-1 tick calls `prepare_keys_in_background`.
- The startup warm in `BlockChain::spawn` stays blocking: it runs before
the first tick, so there is nothing to overlap with.

## Correctness / Behavior Guarantees

- **Eviction ordering is unchanged.** The warm is spawned after
`produce_attestations`, which signs synchronously in the same tick, so
nothing signs with the old subtree after the warm starts.
- **Interval-4 proposal signing:** if it lands before the warm finishes,
it waits on the key's lock. That is the same cost as today, never
double.
- **A warm still running at the next call is skipped**, with a warning.
A finished warm that panicked is logged when it is joined. A failed
thread spawn warns instead of panicking. In every case the only cost is
latency, since `sign` rebuilds a missing subtree itself.
- **No runtime dependency:** it uses `std::thread`, not
`spawn_blocking`, so `KeyManager` stays usable outside tokio (the
benchmark uses it).

## Tests Added / Run

- New `prepare_keys_in_background_warms_without_blocking_signing`: signs
while the warm may still be running, joins the worker, and checks that a
later call spawns a fresh warm.
- `make fmt` and `make lint` are clean. All `key_manager` tests pass.
- `cargo test --workspace --profile release-fast --no-fail-fast`: 516
passed. The 205 failures all come from the base branch (#606), where the
spec fixtures don't decode: 204 are `ValidatorPubkey length != 32` and 1
is a 1208-vs-2536-byte signature length mismatch. #621's CI fails the
same way.

## Related Issues / PRs

- Stacked on #621 (itself on #606)

## ✅ Verification Checklist

- [x] Ran `make fmt` — clean
- [x] Ran `make lint` (clippy with `-D warnings`) — clean
- [ ] Ran `make test` (`cargo test --workspace --profile release-fast`)
— all passing (spec fixtures fail on the base branch, see above)

---------

Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com>
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