perf(blockchain): sign a node's attestations in parallel - #655
MegaRedHand wants to merge 1 commit into
Conversation
Every validator a node runs votes at the same instant, but the node signed their attestations one after another. At ~22 ms per XMSS signature, a 32-validator node published its last vote 0.7 s after its first, so an aggregator's first proof usually covered 96 of its subnet's 128 votes and the rest needed a second raw proof and one more merge level. On devnet-5 (8 s slots) that pushed the first 2/3-wide aggregate to 6.49 s into the slot at the median, past the 6.4 s point where the next block is built, so most blocks carried their parent slot's votes below the threshold and justified every target a block late. The keys are independent and `sign` takes `&self`, so produce_attestations now signs every vote through KeyManager::sign_attestations, which spreads them over one scoped thread per core and returns the results in validator order; delivery and publication are unchanged. The batching helper the key warm already used is generalized to return results (map_batched), with the warm keeping its four-key minimum batch.
🤖 Kimi Code ReviewI'll review this PR focusing on the concurrent attestation signing implementation and its integration. Overall AssessmentThe PR introduces parallel XMSS attestation signing to reduce latency when multiple validators run on the same node. The core logic is sound, but there are several issues ranging from minor correctness concerns to potential performance pitfalls. Detailed Findings1. Potential for Excessive Thread Spawning (Performance)File: let max_threads = thread::available_parallelism().map_or(1, NonZeroUsize::get);
Suggestion: Add a reasonable upper bound or configuration parameter: const MAX_SIGNING_THREADS: usize = 16; // or configurable
let max_threads = thread::available_parallelism()
.map_or(1, NonZeroUsize::get)
.min(MAX_SIGNING_THREADS);2. Thread Name Allocation on Every Call (Performance/Memory)File: .name(thread_name.into())
Suggestion: Use a #[cfg(debug_assertions)]
let builder = thread::Builder::new().name("xmss-sign".into());
#[cfg(not(debug_assertions))]
let builder = thread::Builder::new();3.
|
| Priority | Item | Location | Action |
|---|---|---|---|
| Medium | Unbounded thread spawning | key_manager.rs:330 |
Cap max_threads |
| Low | Thread name allocation | key_manager.rs:349 |
Use static name or conditional |
| Low | Redundant resume_unwind |
key_manager.rs:363 |
Simplify to .unwrap() |
| High | Verify XmssKeyPair thread safety |
Not in diff | Code review/audit required |
| Low | Flaky test | key_manager.rs:521 |
Soften assertion |
| Low | Missing failure metrics | lib.rs:747 |
Add counter |
| Low | validator_ids allocation |
lib.rs:741 |
Consider optimization |
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Claude Code ReviewReview: parallel attestation signing (PR 655)The change is correct and well scoped. I found no blocking issues. What I checked
Minor notes
Overall, this looks good to merge. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Codex Code ReviewLooks good overall. I don’t see a consensus-safety regression in fork choice, attestation validation, justification/finalization, state transition, XMSS verification, or SSZ handling from this patch; it is narrowly scoped to local attestation signing and preserves output ordering.
Minor note:
Given the scope, this PR looks good to merge. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
devnet-5 before/afterThe same commit, stacked on #603 (
Both are measured from every node's logs, plus the central Prometheus for the finality gap. Votes
Aggregation tree (first aggregate anywhere in the fleet reaching N participants, p50, s into slot)
The 2/3 crossing p10/p90 went from 6.22/6.75 s to 6.10/6.50 s. The next block is built at 6.40 s, so what matters is how often the crossing beats it:
Finality (head minus checkpoint, slots, sampled every slot)
Notes
|
🗒️ Description / Motivation
A node signs the attestations of all its validators at interval 1, but it signed them one after another. One XMSS signature takes about 22 ms, so on devnet-5 (32 validators per node, 8 s slots) a node published its first vote at 1.61 s into the slot and its last at about 2.3 s.
Aggregators pay for that tail. On aggregator 0, the first raw proof of a slot usually covered 96 of its subnet's 128 votes; the last node's 32 arrived while it ran, so they needed a second raw proof (done at ~3.9 s) and one more merge level before the subnet was whole. Measured over 120 slots, across all 32 nodes' logs:
The next block is built at 6.40 s (interval 4), so in most slots the 2/3-wide aggregate did not exist yet: only 24 of 115 blocks carried their parent slot's votes at or above the threshold, and every target was justified one block late. Moving the base of the aggregation tree earlier moves every level above it.
The keys are independent and
ValidatorSecretKey::signtakes&self(each key's cache sits behind its own lock), so the signatures can run concurrently.What Changed
crates/blockchain/src/key_manager.rsKeyManager::sign_attestations(&self, validator_ids, data): signs with every listed attestation key, spread over at most one thread per core, and returns one result per id in input order.for_each_batched(the scoped-thread batcher the key warm uses) becomes a thin wrapper over a newmap_batched, which returns results in item order and takes the minimum batch size and thread name as arguments. The warm keeps its 4-key minimum batch; signing uses 1, since a signature costs far more than a thread spawn.sign_with_attestation_keytakes&selfinstead of&mut self.crates/blockchain/src/lib.rs:produce_attestationssigns all of the node's attestations first, then self-delivers and publishes each in validator order, as before.Correctness / Behavior Guarantees
ValidatorKeyNotFoundin its own slot of the result list.resume_unwind, the same thingthread::scopedid for the warm threads before. A thread that cannot be spawned runs its batch on the calling thread.--prover-arena) only servesArenaVecallocations in the proving crates, and thexmsscrate never uses it, so signing threads cannot claim arena slabs.Tests Added / Run
sign_attestations_signs_each_id_in_order: real (tiny) keys, ids in shuffled order plus an unknown id in the middle; each result verifies against that validator's public key, the unknown id reportsValidatorKeyNotFound.map_batched_returns_results_in_item_order: lengths 0-40, thread caps 0-64, minimum batches 0, 1 and 4.map_batched_with_unit_batches_uses_one_thread_per_item.for_each_batchedand warm tests pass unchanged.The same commit, stacked on #603, is rolling onto devnet-5 now as
devnet5-leanvm-b-603-psign; I will post the before/after of the table above once it has run for a while.Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — clean (atrelease-fast)make test(test-consensusplustest-node, atrelease-fast) — ranethlambda-blockchainunit tests only