perf(evm): cut dispatches and stack traffic in the stream interpreter - #12626
perf(evm): cut dispatches and stack traffic in the stream interpreter#12626svlachakis wants to merge 17 commits into
Conversation
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 finished @svlachakis's task in 13m 25s —— View job Review —
|
EVM Opcode Benchmark DiffAggregated runs: base=1, pr=1 No significant regressions or improvements detected. |
EXPB Benchmark ComparisonRun: View workflow run superblocksScenario: Client Processing (SSE)
K6 TTFB
realblocksScenario: Client Processing (SSE)
K6 TTFB
fusakaScenario: Client Processing (SSE)
K6 TTFB
|
…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.
e59acce to
5b6a26d
Compare
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.
5b6a26d to
c7f0027
Compare
8821f2a to
0e73b87
Compare
45fd7ab to
ba57182
Compare
…n probe wrap-safe
07d8203 to
da6a8ca
Compare
Two things the block analyzer can decide once, instead of the dispatch loop deciding per opcode.
Measured on the pool-heavy
eth_callworkload (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:What changed
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.LTandGTreduce to the most significant differing byte, and flipping the leading sign bit extends that toSLTandSGT. Neither operand is converted to limbs.SSTOREin particular, whose EIP-2200 sentry would see too little gas inside an open block.ISZEROfeeding a fused conditional jump inverts into it.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_resultpasses either way.Draft until the full test matrix is green on this branch.