Conversation
The `[METRIC] BLOCK` line covers validate/exec/merkle/store inside `add_block`. On a chain of near-empty blocks that accounts for a little over half of what the consensus client measures for engine_newPayload; the rest ; decoding the payload into a block and the pre-execution checks ; was not measured anywhere. Time the V5 handler end to end and split it into payload decoding, the checks around it, and execution, so the unaccounted part is visible.
The handler instrumentation shows decoding and the pre-execution checks cost ~0.03 ms together, while the consensus client measures the whole round trip at several times the handler total. The remainder sits before the handler runs: JSON parsing of the payload body, JWT verification and dispatch. Time those three at the authenticated RPC entry point and log them for engine_newPayload* only, so the gap between what we measure and what the consensus client measures is attributable.
…d one The block pipeline spawned one OS thread for execution and another for merkleization, then had the calling thread do nothing but wait for both. Creating those threads is not free: on a chain of near-empty blocks the merkleizer's start delay, measured from the start of the phase to its first instruction, averaged 0.372 ms, 29% of a 1.27 ms block, and it was created after the execution thread. Spawn the merkleizer first and run execution on the calling thread. That removes one thread creation per block and lets the merkleizer start before execution begins rather than after. `catch_unwind` preserves the previous behaviour of reporting a panic in execution as an error.
…nsactions The warmer thread is spawned for every block. On a block with no transactions it has nothing to warm beyond the few entries the system calls and withdrawals touch, which execution reads anyway, so the thread creation costs more than the cold reads it avoids. Warming is best-effort and only populates caches, so skipping it cannot change the block's result. The trie-node prefetcher already opts out below its own size threshold.
Running execution on the calling thread removed one OS thread per block but left the merkleizer's own start delay untouched: measured on slot-paced empty blocks it stayed at 0.31 ms, a quarter of a 1.2 ms block, because that thread is still created per block. Give the pipeline a two-thread pool, built on first use like the merkleization pool, and spawn the merkleizer onto it. `in_place_scope` runs execution on the calling thread and only returns once the merkleizer has finished, so both closures keep their borrows. The pool is separate from the merkleization pool because the merkleizer opens a scope on that one.
One block in flight uses one pool thread, but block processing can overlap (a payload arriving while the syncer runs). Four threads keep those from serialising behind each other; the spare ones are idle otherwise.
|
🤖 Kimi Code ReviewI'll review this PR which introduces a persistent thread pool for block pipeline execution and adds performance metrics to engine API handlers. crates/blockchain/blockchain.rsThread Pool Design and SafetyLine 282-288, 576-586: The
Critical:
|
| File | Line | Issue | Severity |
|---|---|---|---|
blockchain.rs |
1193-1200 | Dead code: unreachable if let Some(rx) in None branch |
Medium |
blockchain.rs |
1254, 1259 | AssertUnwindSafe may be unsound; audit captured types |
Critical |
blockchain.rs |
1251-1281 | Verify catch_unwind + channel semantics match previous panic behavior |
High |
rpc.rs |
1069 | starts_with is imprecise; use exact method matching |
Low |
rpc.rs |
1012-1035 | Consider gating timing overhead to metrics feature | Low |
payload.rs |
472 | Use saturating_sub on Duration before max(0.0) |
Low |
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewFindings:
I did not spot a consensus/EVM/RLP correctness regression in the execution-path refactor itself. I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Lines of code reportTotal lines added: Detailed view |
🤖 Claude Code ReviewReview: perf(l1): remove per-block thread creation from the block pipelineOverall, this is a well-reasoned, well-instrumented performance change. The reasoning about Correctness / concurrency analysis (no bugs found)
Minor observations (non-blocking)
No security, gas-accounting, consensus-rule, or RLP-encoding concerns — this PR touches only pipeline scheduling/threading and adds observability, without altering execution, validation, or state-root computation logic (the diff in the execution/merkle closures is a pure move-into-closure refactor with no logic changes, confirmed line-by-line against the pre-PR version). Automated review by Claude (Anthropic) · sonnet · custom prompt |
Moving the merkleizer to a persistent pool removed the thread creation but not the handoff: its measured start delay stayed at 0.30 ms of a 1.17 ms empty block, because the pool worker still has to be woken. A block with no transactions streams no per-tx updates, so the merkleizer spends the whole of execution parked on an empty channel and does all of its work afterwards regardless. Running execution and merkleization in order on the calling thread therefore gives up no overlap and skips the wakeup entirely. The streaming channel is unbounded, so execution still completes without waiting for a reader. Blocks with transactions keep the concurrent path, where the overlap is real: on mainnet blocks it runs above 90%.
This reverts commit 1bc7b86. Measured on the same node and the same kind of block, it is a regression: 1.244 ms against 1.166 ms for the concurrent path, over 85 and 123 slot-paced empty blocks. The premise was wrong. Merkleization does not sit idle waiting for streamed updates on a transactionless block: the BAL-synthesized path starts on the parent state as soon as it is handed the prepared updates, so roughly 0.11 ms of its work genuinely overlaps execution. Serialising gave that up, and the measured total moved by 0.08 ms, which is that overlap net of the pool wakeup it saved. The full merkle cost is visible in the two runs: 0.775 ms serial against 0.668 ms remaining after execution on the concurrent path. Keeping the persistent pool.
The warmer and trie-prefetch threads are the only users of the scope, and both are rayon-only, so building without that feature warns on an unused binding. The annotation rode along with the inline-merkleization commit and went away with its revert.
Benchmark Block Execution Results Comparison Against Main
|
Motivation
On a chain of near-empty blocks, ethrex spends more time on the fixed cost of processing a block than on the block itself. Measured on Plataberget (the public Glamsterdam testnet, where most blocks currently carry no transactions), a block took 1.23 ms on average, and the largest single component was not work: 0.32 ms of it was the merkleizer's start delay, the gap between the start of the exec/merkle phase and that thread's first instruction. The pipeline created one OS thread for execution and another for merkleization on every block, and the calling thread then did nothing but wait for both.
That fixed cost is what a consensus client measures for
engine_newPayloadwhen blocks are empty, and it is the difference between clients on a quiet chain.Description
The first two commits add measurement, because the earlier reading pointed at the wrong place. A first look suggested a large cost outside block execution, in the engine handler. Instrumenting it showed the opposite:
So the engine plumbing is not the cost; block execution is. The apparent gap came from averaging over duplicate
newPayloadcalls, which the consensus client re-sends and which short-circuit in about 0.2 ms.Two new INFO lines make this visible going forward:
[METRIC] NEWPAYLOADsplits the V5 handler into decode, checks and execute, and[METRIC] ENGINE_RPCsplits the authenticated RPC entry point into JSON parsing, auth and dispatch. They are emitted forengine_newPayload*only.The remaining commits then remove the per-block thread creation:
in_place_scoperuns execution on the calling thread and returns only once the merkleizer has finished, so both closures keep their borrows, andcatch_unwindon each preserves the previous behaviour of reporting a panic as an error rather than unwinding.Results
Measured on a node following Plataberget, where blocks currently carry no transactions. Only slot-paced blocks are counted: after a restart a node executes a catch-up backlog whose blocks cost about half as much, and mixing them in hides the effect. Baseline is 609 blocks, the change is 123 blocks, from the node's own
[METRIC] BLOCKline.Block processing is about 13% cheaper on the mean and 6% on the median, execution about 24% cheaper, and the merkleizer's start delay about 22% cheaper.
The start delay does not disappear, because handing the task to a pool worker that may be parked still costs around 0.29 ms.
Merkleizing inline for transactionless blocks, to skip that wakeup entirely, was tried and reverted on the branch: it measured 1.244 ms against 1.166 ms over 85 and 123 blocks, a regression. The premise was wrong. Merkleization does not idle waiting for streamed updates on such a block; the BAL-synthesized path starts on the parent state as soon as it has the prepared updates, so about 0.11 ms of its work genuinely overlaps execution. The full merkle cost is visible across the two runs: 0.775 ms when serialised, against 0.668 ms remaining after execution on the concurrent path. The commit and its revert are both kept so the measurement is on the record.
The consensus client's own measurement of the same node moved from 2.233 ms to 2.027 ms across the two windows. That is the right direction, but the other two clients on the same network drifted by a comparable amount over the same period without any change, so the per-phase numbers above are the reliable signal and that one is not.
How to Test
cargo test -p ethrex-blockchain cargo clippy -p ethrex-blockchain --no-deps -- -D warningsOn a node following a chain of empty blocks, the
[METRIC] BLOCKline'sstart_delayfield should fall andps -Lshould show persistentblock-pipeline-*threads with no per-blockblock_executor_*threads. Compare only slot-paced blocks: after a restart a node executes a catch-up backlog whose blocks are roughly half the cost of steady-state ones.Checklist
STORE_SCHEMA_VERSION(crates/storage/lib.rs) if the PR includes breaking changes to theStorerequiring a re-sync. (Not needed: no schema change.)