Skip to content

perf(evm): cut dispatches and stack traffic in the stream interpreter - #12626

Draft
svlachakis wants to merge 17 commits into
masterfrom
perf/evm-stream-dispatch
Draft

perf(evm): cut dispatches and stack traffic in the stream interpreter#12626
svlachakis wants to merge 17 commits into
masterfrom
perf/evm-stream-dispatch

Conversation

@svlachakis

Copy link
Copy Markdown
Contributor

Two things the block analyzer can decide once, instead of the dispatch loop deciding per opcode.

Measured on the pool-heavy eth_call workload (44.6M opcodes per request), both clients started from the same snapshot on one runner inside a single run, with cross-client response parity clean (DIVERGENT: 0). The runner already had the runtime fix from #12625 in effect for both, so this is apples-to-apples against master:

mixed p99 isolated heavy-multicall p99
master 300 ms 483 ms
this branch 252 ms 376 ms

What changed

  • Glue pairs collapse into one entry: POP POP, PUSH1 PUSH1, SWAPn POP, AND ISZERO. A pair's single bounds check rejects exactly the depths at which one of its halves would have failed, and an exceptional halt burns the frame's gas either way, so which half failed is not observable.
  • A jump target's gas is carried by the block that falls into it, so the marker stops costing a dispatch. Jump arrivals pay it at the jump - the dynamic handlers already did, and the fused static jumps now do too, landing one entry past a solo marker.
  • Comparisons run over the stack representation directly. Big-endian byte order is numeric order, so LT and GT reduce to the most significant differing byte, and flipping the leading sign bit extends that to SLT and SGT. Neither operand is converted to limbs.
  • Executed-op counting moves to the block charge, using a per-block count the analyzer already knows. That also turns the per-op cancellation test into a threshold compare.
  • Straight-line ops with dynamic gas keep their block open: they self-charge and always fall through, so a block spanning them precharges exactly what it did before. Gas observers stay block-enders - SSTORE in particular, whose EIP-2200 sentry would see too little gas inside an open block.
  • Two constant pushes feeding an operator fold at analysis into one pooled push, computed with the very operation implementations the executor dispatches, so folding and execution cannot disagree on wrapping, division by zero or shift saturation.
  • An ISZERO feeding a fused conditional jump inverts into it.
  • Fused-constant cores take their operand by reference instead of copying 32 bytes per call.

Correctness

The existing differential fixture passed two defects during development that only a stronger check found: a four-gas overcharge on jump loops, and a dropped operation. So this adds a randomized gas differential over jump-heavy programs - it generates jump targets, static and dynamic conditional jumps, glue runs and mixed-depth DUP/SWAP, and requires the stream and the bytecode loop to charge identical gas - plus a regression that pins the jump-target gas carry across taken-jump counts.

Worth recording for anyone doing this kind of work here: a cross-client response parity bisect is what located both bugs. Latency and k6's checks cannot see them, because wrong results are computed faster than right ones and has_result passes either way.

Draft until the full test matrix is green on this branch.

Measured on a pool-heavy eth_call workload of 44.6M opcodes per request, against
the same commit's master build on one runner, with cross-client response parity
clean: mixed p99 312ms against 361, and the isolated heavy-multicall scenario
487ms against 697.

The changes, all of them things the block analyzer can decide once instead of the
loop deciding per dispatch:

- Glue pairs become one entry: POP+POP, PUSH1+PUSH1, SWAPn+POP and AND+ISZERO. A
  pair's single bounds check rejects exactly the depths at which one of its halves
  would have failed, and an exceptional halt burns the frame's gas either way.
- A jump target's gas is carried by the block that falls into it, so the marker
  stops costing a dispatch of its own. Jump arrivals pay it at the jump, which the
  dynamic handlers already did and the fused static jumps now do too.
- Comparisons run over the stack representation directly. Big-endian byte order is
  numeric order, so LT and GT reduce to the most significant differing byte, and
  flipping the leading sign bit extends that to SLT and SGT - neither operand is
  converted to limbs.
- Executed-op counting moves to the block charge, using a per-block count the
  analyzer already knows, which also turns the per-op cancellation test into a
  threshold compare.
- Straight-line ops with dynamic gas keep their block open, since they self-charge
  and always fall through; a block spanning them precharges exactly what it did
  before. Gas observers stay block-enders.
- Two constant pushes feeding an operator fold at analysis into one pooled push,
  computed with the very operation implementations the executor dispatches.
- An ISZERO feeding a fused conditional jump inverts into it.
- Fused-constant cores take their operand by reference rather than copying 32
  bytes on every call.

Correctness rests on more than the existing differential fixture, which turned out
to miss a four-gas overcharge and a permutation defect: there is now a randomized
gas differential over jump-heavy programs, and a regression that pins the
jump-target gas carry across taken-jump counts. A cross-client parity bisect over
these commits is what caught the two real bugs during development, both fixed
here.
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @svlachakis's task in 13m 25s —— View job


Review — perf(evm): cut dispatches and stack traffic in the stream interpreter

  • Read the full diff against origin/master
  • Review InstructionStream.cs (JUMPDEST elision, glue fusion, constant folding, BoundaryLinear)
  • Review VirtualMachine.Stream.cs (dispatch loop, gas charging, cancellation, jump gas)
  • Review EvmStack.cs + CompareCore
  • Review FusedConst cores
  • Review tests
  • Post findings

Verified by reading, not by running — dotnet build/dotnet test are not permitted in this job's tool allowlist, so nothing below is backed by an executed test. Everything is traced through the analyzer and the executor by hand.

Overall this is careful work and most of the reasoning in the PR body checks out. I verified the parts that are easy to get wrong and found them correct: CompareCore's operand order matches Math2ParamCore<OpLt> (top is a), big-endian byte order plus the index == 0 sign flip is a sound total order for SLT/SGT, SwapPop's single Head >= depth check really does subsume the pop, FusedPush1Push1Core's bound is right for MaxStackSize == 1025, the StaticJumpINot inversion (!TestJumpCondition) matches ISZERO; PUSH2; JUMPI, the new fused opcode bytes 0x1F/0x4C..0x4F are genuinely free in Instruction.cs, and the StaticJump + GasCostOf.JumpDest change lines up with SkipJumpDest in the dynamic handlers. Replacing the hardcoded _ => InstructionMCopy fallback with table dispatch also quietly removes a latent pre-Cancun mis-dispatch — good change.

Two findings block, in my reading.


Critical

None.

High

1. Elided JUMPDEST gas is dropped when its block is entered mid-way in metered modeInstructionStream.cs:202

The elision is exact for the two arrival modes the comment names, but not for the third: RunMeteredSegment, entered when a table handler lands on an InBlock entry (VirtualMachine.Stream.cs:449-452). There the block precharge never runs, and the metered loop hands back at the marker's pc — because pcToEntry[jumpdestPc] now points at the successor BlockFirst — so the marker is never dispatched and nobody charges its gas.

InstructionExtCodeSize's ISZERO peephole (EvmInstructions.CodeCopy.cs:262-290) reaches it: it consumes the following op and advances the pc past the BlockFirst the analyzer emitted for it.

PUSH20 <addr>
EXTCODESIZE      ; peephole eats the ISZERO, pc lands on the PUSH1
ISZERO           ; BlockFirst of block B
PUSH1 0x01       ; InBlock of block B   <-- landing => metered = true
JUMPDEST         ; elided; 1 gas went to block B, which is never precharged
PUSH1 0x02       ; BlockFirst of block C
STOP

1 gas short of the bytecode loop, on a run that succeeds. A wrong eth_estimateGas/eth_call figure today; a consensus bug if the stream is ever ungated. Fix this →

2. A loop that never charges a block can no longer be cancelledVirtualMachine.Stream.cs:85-96

Master probed IsCancelled on every in-block and static-jump dispatch. Moving the probe into the block-charge success branch drops it from every path that does not enter a block-charging entry, and the fused static jumps are such a path:

JUMPDEST         ; solo block, entry 0
PUSH2 0x0000
JUMP             ; fused StaticJump, operand fixed up to entry 1

Entry 1 jumps to itself; entry 0 is never revisited after the first iteration, so no block charge and no cancellation probe ever fire again. 14 gas per iteration means the RPC gas cap is the only bound, and the eth_call timeout is ignored — on the exact frames the stream is gated to in production. The three surviving mask probes (lines 379, 405, 546) are unreliable for a related reason: opCodeCount now advances in block-sized strides, so it can step over every multiple of 1024. Fix this →

Medium

3. The randomized differential asserts gas onlyStreamGasFuzzTests.cs:44-53

The PR body makes this argument itself — a check that passes either way hides wrong results — and then the new fuzz compares GasSpent alone, while the riskiest changes here are value-semantics rewrites (CompareCore incl. the sign flip, AndIsZero, SwapPop, Push1Push1, the constant folder). Those only move gas if they happen to flip a branch. ReceiptCaptureTracer already captures StatusCode and Output; asserting all three costs nothing.

The generator also never emits AND, SHL/SHR, DIV/MOD, PUSH3..PUSH32, unconditional PUSH2 dest; JUMP, or any new IsLinearBoundary op except MSTORE — so AND ISZERO fusion, wide/cascading folds, shift saturation, division-by-zero folding, the StaticJump gas change, and the entire block-stays-open behaviour have zero randomized coverage. No generated program runs out of gas either, so the metered fallback is never crossed randomly (which is where finding 1 lives).

Low

  • InstructionStream.cs:263(byte)(push.Advance + size) can wrap now that folding lifts a PUSH32 entry's Advance to 255. Contained today (Advance only feeds a pc that nothing reads before it is overwritten), but it breaks the invariant silently. Mirror the > byte.MaxValue guard already in TryFoldConstantPair.
  • InstructionStreamTests.cs:483 — leftover Console.WriteLine("BISECT ..."), and ElisionBisect names the debugging session rather than the invariant.
  • InstructionStream.cs:406-413 — the "computed with the very operation implementations the executor dispatches" claim holds for ADDSGT but not for AND/OR/XOR/EQ/SHL/SHR, which are re-derived. They agree today (I checked the shift guard against ShiftCore); the comment promises an invariant the code does not enforce.
  • FusedPush1Push1Core's "One overflow check covers both pushes" — the leading check only catches Head == 1024; at Head == 1023 the first push still lands and the second returns StackOverflow. Harmless (exceptional halt discards the stack) but the comment describes something else.
  • StaticJumpINot does not bump opCodeCount for the PUSH2/JUMPI/JUMPDEST it absorbs, and a block that faults mid-run is now counted whole. Metrics-only (Metrics.IncrementOpCodes), and the PR documents the second half — noting for completeness.

Not blocking, verified sound: the block-stays-open reasoning for BoundaryLinear. A precharge covering ops after the memory/storage op only reorders when gas is deducted, never the total, and the excluded set (GAS, CALL family, CREATE, SSTORE) is the right one — SSTORE's EIP-2200 sentry is the case that would have bitten. The metered flag not being reset in that path is also fine: RunMeteredSegment only returns Metered: true with entryIndex >= ops.Length, which exits the loop.

Verdict: not mergeable as-is. Findings 1 and 2 are behavioural regressions against master on the exact workload the stream is gated to, and finding 3 is what would have caught the first one.
· branch perf/evm-stream-dispatch

Comment thread src/Nethermind/Nethermind.Evm/CodeAnalysis/InstructionStream.cs
Comment thread src/Nethermind/Nethermind.Evm/VirtualMachine.Stream.cs
Comment thread src/Nethermind/Nethermind.Evm.Test/CodeAnalysis/StreamGasFuzzTests.cs Outdated
Comment thread src/Nethermind/Nethermind.Evm/CodeAnalysis/InstructionStream.cs
Comment thread src/Nethermind/Nethermind.Evm.Test/CodeAnalysis/InstructionStreamTests.cs Outdated
Comment thread src/Nethermind/Nethermind.Evm/CodeAnalysis/InstructionStream.cs Outdated
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=1, pr=1

No significant regressions or improvements detected.

@github-actions

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-evm-stream-dispatch-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 988.43 1006.67 -1.81%
MEDIAN (ms) 931.6 920.0 +1.26%
P90 (ms) 1184.0 1373.9 -13.82%
P95 (ms) 1520.0 1740.4 -12.66%
P99 (ms) 3774.4 2169.0 +74.02%
MIN (ms) 678.9 659.1 +3.00%
MAX (ms) 3774.4 2169.0 +74.02%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1678.46 1727.24 -2.82%
MEDIAN (ms) 1187.88 1246.68 -4.72%
P90 (ms) 3395.45 3110.39 +9.16%
P95 (ms) 4011.78 4028.47 -0.41%
P99 (ms) 4393.84 5250.39 -16.31%
MIN (ms) 776.90 737.21 +5.38%
MAX (ms) 5190.62 8017.56 -35.26%

realblocks

Scenario: nethermind-flat-realblocks-perf-evm-stream-dispatch-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 22.66 22.77 -0.48%
MEDIAN (ms) 19.6 19.6 +0.00%
P90 (ms) 37.0 36.9 +0.27%
P95 (ms) 42.8 42.5 +0.71%
P99 (ms) 91.4 90.2 +1.33%
MIN (ms) 0.3 0.2 +50.00%
MAX (ms) 187.9 188.2 -0.16%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 27.54 27.84 -1.08%
MEDIAN (ms) 23.06 23.22 -0.69%
P90 (ms) 40.87 41.12 -0.61%
P95 (ms) 47.70 48.75 -2.15%
P99 (ms) 95.39 94.15 +1.32%
MIN (ms) 1.76 0.88 +100.00%
MAX (ms) 1192.25 1296.25 -8.02%

fusaka

Scenario: nethermind-flat-fusaka-perf-evm-stream-dispatch-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 40.56 40.45 +0.27%
MEDIAN (ms) 35.1 35.1 +0.00%
P90 (ms) 69.2 68.7 +0.73%
P95 (ms) 83.0 83.0 +0.00%
P99 (ms) 125.0 129.8 -3.70%
MIN (ms) 4.9 5.1 -3.92%
MAX (ms) 276.9 250.9 +10.36%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 50.07 52.27 -4.21%
MEDIAN (ms) 42.73 43.48 -1.72%
P90 (ms) 83.26 82.83 +0.52%
P95 (ms) 97.21 97.52 -0.32%
P99 (ms) 171.44 162.04 +5.80%
MIN (ms) 6.42 7.26 -11.57%
MAX (ms) 634.32 1327.94 -52.23%

…s cancelable

Review findings on the stream interpreter:
- a table handler that consumes its successor can land on an elided JUMPDEST
  whose gas lives in the bypassed block charge: the landing now pays the
  marker, and the metered walk steps it raw instead of handing back past it
- fused static jumps can loop without crossing a block charge: taken jumps now
  count their ops and probe cancellation, and the boundary probes use a
  threshold that a striding op count cannot step over
- the fuzz differential now asserts status and output besides gas, generates
  the folded/fused/peephole alphabet, and starves each program at a
  seed-derived budget to cross the metered fallback
- guard the fused-push advance against byte wrap; align three comments with
  the code they describe
…ck charge

A linear boundary does not end its block, so the in-block entries after it
are paid by the block's precharge. An arrival that entered the block past
that charge - a peephole handler landing mid-block - crossed the linear
boundary back into gas-free dispatch, running the suffix uncharged. The
metered walk now steps linear boundaries raw like any interior op, and a
landing on one routes through the general epilogue, whose landing recompute
sends the uncharged suffix back to the metered walk.

Found by the widened fuzz alphabet on its first CI run; the failing seeds
all combine the consumed-successor landing with an in-block linear boundary,
a shape no hand-written case had. Two deterministic regressions pin it.
The shift amount is an analysis-time constant, and generated code shifts
almost exclusively by whole bytes - address and selector packing,
fixed-point scaling. Big-endian stack order makes SHL a move toward index
zero and SHR a move away from it, so the aligned case needs no limb
conversion and no shift arithmetic; sub-byte amounts keep the mirrored
ShiftCore path. The randomized differential already generates both shift
directions with random and saturating amounts.
@svlachakis
svlachakis force-pushed the perf/evm-stream-dispatch branch from e59acce to 5b6a26d Compare August 1, 2026 08:19
EXTCODESIZE's handler peeks at the next raw opcode and, for ISZERO or for
GT/EQ with zero on top, computes it itself, charges its gas and steps past it.
In the stream that opcode is the first entry of the block the boundary opened,
so the arrival lands inside a block whose charge never ran - which is where
master returns a different eth_call result from its own bytecode loop on
mainnet state, and what the two fixes in this branch address. The randomized
differential cannot reach the shape: the GT and EQ forms need a zero on the
stack at exactly that point.
@svlachakis
svlachakis force-pushed the perf/evm-stream-dispatch branch from 8821f2a to 0e73b87 Compare August 1, 2026 09:22
@svlachakis
svlachakis force-pushed the perf/evm-stream-dispatch branch from 45fd7ab to ba57182 Compare August 1, 2026 09:33
@svlachakis
svlachakis force-pushed the perf/evm-stream-dispatch branch from 07d8203 to da6a8ca Compare August 1, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants