perf(core): replace D*J dyn-jump edges with implicit predecessor count - #14
perf(core): replace D*J dyn-jump edges with implicit predecessor count#14abmcar wants to merge 17 commits into
Conversation
Remove the all-or-nothing HasDynamicJump bailout in buildGasChunksSPP so that contracts with a mix of resolvable and unresolvable jumps still get CFG-based SPP shifting on the resolved portion. Factor the edge construction into a reusable buildCFGEdges() that can be driven either with over-approximation or with call-site-resolved targets. Add resolveCallSiteTargets() which detects the Solidity internal-function return pattern (SWAPn -> JUMP) and walks predecessors to find the enclosing function entry JUMPDEST, then collects valid return addresses from all matching call sites (PUSH ret -> PUSH func -> JUMP). The reverse-reachability walk is bounded by MAX_REVERSE_REACHABILITY_DEPTH to cap compile-time cost. Introduce decodePushAsJumpDest() as a shared PUSH-as-JUMPDEST decode helper and add Prev2Pc / Prev2Opcode tracking on GasBlock so the 3-instruction call-site window can be inspected without rescanning bytecode. Tighten the SPP shifting guard so that a successor whose last opcode is a isGasChunkTerminator bails out of shifting, preventing gas cost from being moved across chunk boundaries. GasChunkCost continues to write Blocks[Id].Cost (the original unshifted per-block cost) exactly as PR DTVMStack#371 established: the interpreter gas chunk fast path depends on unshifted costs, and exporting SPP-shifted metering to the JIT is left as a follow-up on a separate JIT-only output path. Test plan: - format.sh check: clean - evmone-unittests multipass: 223/223 pass - evmone-unittests interpreter: 215/215 pass - evmone-statetest --fork Cancun: 2723/2723 pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Measured via evmone-bench against upstream/main@a14a9de on the external/total/(main|micro) benchmark set: geomean -10.13% across 27 benchmarks, with memory_grow_mload/mstore -19% to -24%, signextend -19% to -20%, and snailtracer -7.53%. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add EVMBytecodeCache::GasChunkCostSPP as a second parallel cost array that holds the SPP metering-shifted per-chunk cost computed by buildGasChunksSPP. The interpreter continues reading the unshifted GasChunkCost (preserving PR DTVMStack#371's interpreter-safety invariant) while the multipass JIT prefers the shifted values when emitting gas checks. Plumb the pointer through EVMFrontendContext::setGasChunkInfo, snapshot it in EVMMirBuilder, and swap the three chunk-cost read sites: - meterOpcode — primary per-chunk-start charge - meterOpcodeRange (slow path) — JUMPDEST-skip cumulative sum - JUMPDEST-run suffix-sum precompute inside the jump table builder Swapping all three sites is safe because SPP's lemma614 shift can only transfer gas from a single-predecessor successor into its parent. Every JUMPDEST is a jump target (multi-predecessor), so SPP can never shift gas *into* a JUMPDEST-run member from outside the run; the only intra-run shift it can perform is from the trailing body chunk up into the last JUMPDEST, which is still charged along every entry path into the run. Total gas along any realizable execution path is preserved. Test plan: - format.sh check: clean - evmone-unittests multipass: 223/223 pass - evmone-unittests interpreter: 215/215 pass - evmone-statetest --fork Cancun: 2723/2723 pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 3 wires SPP-shifted gas costs into the multipass JIT but leaves the expensive CFG / call-site / metering pipeline running for every module — including interpreter-only ones that never read the shifted values. On CI that manifests as a ~7% interpreter-mode regression on snailtracer and up to +14% on smaller benchmarks where compile time dominates the total run. Gate the pipeline: - Add `bool EnableSPP` to `buildBytecodeCache`. When false, the function walks the basic gas-block scan, writes unshifted `Blocks[Id].Cost` into `GasChunkEnd` / `GasChunkCost`, and leaves `GasChunkCostSPP` empty. - Track `EVMModule::CacheNeedsSPP`. It is set to `true` immediately before `performEVMJITCompile` runs (the only current SPP consumer). Pure interpreter-mode modules and JIT-fallback modules leave it `false`, so the lazy `initBytecodeCache` picks the cheap path. - `evm_compiler.cpp` passes `nullptr` when the cache's `GasChunkCostSPP` vector is empty, so any JIT compile without SPP (defensive path) falls back to the unshifted table via the existing `GasChunkCostSPP ? ... : GasChunkCost` pattern in `meterOpcode` / `meterOpcodeRange` / the JUMPDEST-run suffix-sum builder. Test plan: - format.sh check: clean - evmone-unittests multipass: 223/223 pass (9.4s vs 13.7s previously) - evmone-unittests interpreter: 215/215 pass (0.4s vs 3.7s previously) - evmone-statetest --fork Cancun: 2723/2723 pass (67s vs 103s previously) - Local bench vs upstream/main (CI flags): geomean -14.29% (n=27) The test-suite runtime drop is the dominant signal that gating works — interpreter mode no longer runs the SPP pipeline, so every module load in the test suite gets back the ~90% of cache-build time the pipeline used to consume. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous two-pass CFG build used call-site-resolved targets to replace over-approximate edges for dynamic jumps. This created an under-approximate CFG when resolution was incomplete, allowing lemma614Update to shift gas along non-existent edges and produce unsafe metering on missed paths. Fix: always over-approximate dynamic jumps in buildCFGEdges (edges to all JUMPDESTs). Remove the second-pass CFG rebuild. Export resolved targets through ResolvedJumpTargets for downstream consumers (MIR direct-branch optimization with runtime guard) instead of using them for CFG refinement. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reflect Phase 5 (soundness fix): always over-approximate CFG for dynamic jumps, export ResolvedJumpTargets for downstream MIR use, document reverse BFS cross-function risk as benign. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
resolveCallSiteTargets() and its ResolvedJumpTargets export had no downstream consumer — the resolved targets were computed but never read. Remove the function, its helper isSwapOpcode(), the cache field, and the output parameter to eliminate dead work (O(N×200) BFS per JIT-compiled contract). The call-site enumeration algorithm can be restored from git history when a consumer (e.g. MIR direct-branch optimization) is implemented. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous run had +70% SHL/SHR/SAR synth regressions due to noisy neighbor on shared GitHub Actions runner — same baseline, same code, different run produced 11.8ms vs 20.2ms for SHL/b0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…al design Address codex review on PR DTVMStack#446: - Rewrite the gas-check-placement change doc to describe the final design only: mixed-precision CFG (over-approximate dynamic jumps), separate GasChunkCostSPP array for the JIT, and interpreter-mode gating. Drop the call-site / ResolvedJumpTargets narrative — that exploration was reverted by c26bf7c and lives in git history, not the change doc. - Update src/evm/evm_cache.md so GasChunkCost is documented as the unshifted interpreter cost and the new GasChunkCostSPP field is documented as the SPP-shifted JIT cost. Match the field semantics in src/evm/evm_cache.cpp:1161-1165 and src/evm/evm_cache.h:22-27.
Add docs/design/evm-gas-mechanism.md with mermaid diagrams covering: - shared EVMBytecodeCache layout and the GasChunkCost vs GasChunkCostSPP split, - interpreter chunk fast path (pre-charge at chunk start) with per-opcode fallback, - JIT meterOpcode/meterGas dMIR emission and shared OOG block, - SPP cost shifting (Lemma 6.14), per-path total preservation, mixed-precision CFG with over-approximated dynamic jumps, - pipeline gating via EVMModule::CacheNeedsSPP. Addresses zoowii's review request on PR DTVMStack#446 to document the latest interpreter and JIT gas mechanism.
Two reviewers flagged factual issues in docs/design/evm-gas-mechanism.md: - JIT meterOpcode flowchart was wrong: when the chunk cache is populated and PC is mid-chunk, the function returns without emitting any MIR (evm_mir_compiler.cpp:537), it does NOT fall through to the per-opcode metric. The per-opcode fallback only fires when the cache pointers are absent. Diagram now shows both branches separately. - Chunk-terminator wording was inverted: SSTORE/CALL*/CREATE*/GAS end their own chunk (the terminator's static cost is included, evm_cache.cpp:329), they are not "before the boundary". Updated the chunk definition and the interpreter key-properties bullet. - Memory expansion is not a chunk boundary; it is charged inside handlers via expandMemoryAndChargeGas. Removed the misleading "dynamic memory growth" entry. - Gas-register sync sites are not limited to CALL/CREATE/return; syncGasToMemory is also called from balance/code/keccak/memory handlers. Listed concrete line numbers. - Interpreter mermaid: chunk-start condition failure does not raise OOG directly; it falls into the slow per-opcode path. - Failure-mode table updated to match. Also drift-fixed line numbers for meterOpcode (524), meterOpcodeRange (544), JumpDestRun precompute (1297-1335), and EVMModule initBytecodeCache (133-136). Counted parallel arrays correctly (5). Added precondition note to the SPP example. Replaced \\n with <br/> and quoted diamond labels so GitHub renders the diagrams.
Records the 6 fix items (F1-F6) identified by the 2026-05-07 self-review of PR DTVMStack#446 with concrete file:line citations, sequencing, and quality gates. The plan was iterated through 3 review rounds (Opus subagent + concrete GraphQL verification of GitHub thread state) before this final form. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…approx invariant GasBlock::Prev2Pc and Prev2Opcode were added to support a future 3-instruction call-site window lookup, but the call-site enumeration that would have consumed them was removed in commit c26bf7c (Phase 5 CFG soundness fix). Whole-repo grep confirms zero readers; only the writers in buildGasBlocks remain. Removing both fields shrinks GasBlock by ~9 bytes (one uint32_t + one uint8_t + alignment) and removes dead bookkeeping from every cache build. Also extends the buildCFGEdges function comment to make the soundness pairing with lemma614Update explicit: the over-approximated dynamic-jump edges to all JUMPDESTs work because lemma614Update's effectivePredCount > 1 guard refuses to shift gas across multi-predecessor edges, so the over-approximation is absorbed without breaking per-path totals. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CacheNeedsSPP flag controls whether the bytecode cache builds with the SPP metering pipeline. It must be set before any getBytecodeCache() call: once the cache is lazily built, the EnableSPP decision is fixed for the module's lifetime. Future lazy / on-demand JIT paths must flip this flag before triggering the cache build, otherwise the JIT will silently fall back to the unshifted GasChunkCost array. Documentation only — no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… and F6 dropped The change-doc Metrics section cited a 27-bench local evmone-bench run (3 reps) whose numbers had drifted significantly from the CI Performance Regression Check baseline. The "≤ +6% small regressions" framing mis-categorized the actual jump-heavy regressions on weierstrudel / jump_around / snailtracer, which sit in the +10–23% range on the CI multipass table. Rewrite the section using the CI bot's authoritative numbers and drop the unverified geomean claim. Also update the review-fix plan to record: - F1, F4, F5 implemented in commits 81efba3 and 691069a; - F2, F3 applied to the PR body; - F6 (open an upstream issue for addEdge O(deg²)) dropped — the concern was theoretical, no commit on this branch touches addEdge, no measured evidence of compile-time pain. Concern kept inline as a future-tuning reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR DTVMStack#446 over-approximates dynamic jumps by inserting an explicit edge from every dynamic-jump block to every JUMPDEST, then splitting each as a critical edge. On contracts with D dynamic jumps and J JUMPDESTs that costs O(D*J^2), so compile time grows quadratically with J and dominates JIT-prep on jump-heavy bytecode. This change carries a per-JUMPDEST scalar ImplicitDynamicPredCount that counts how many dynamic-jump blocks could reach it at runtime, and folds it into effectivePredCount so the lemma 6.14 update behaves identically without materialising the edges. To keep dyn-only JUMPDESTs visible to dominator and loop analyses (Solidity function returns that are unreachable in the static-only CFG), we seed reachability from every JUMPDEST after the static reachable set is built. Compile time on loop_full_of_jumpdests drops from 7.3s to 3.3s. Geomean runtime is -6.15% across the 27 paper benches with zero benches above the +/-25% CI gate. See docs/changes/2026-05-11-spp-cfg-implicit-dyn-pred/README.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b943a7a to
972615a
Compare
|
Superseded — optimization commit squashed into PR DTVMStack#446 (after rebase onto upstream/main). See DTVMStack#446. |
|
✅ Performance Check Passed (interpreter) Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions |
|
✅ Performance Check Passed (interpreter) Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions |
|
✅ Performance Check Passed (multipass) Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions |
⚡ Performance Regression Check Results✅ Performance Check Passed (interpreter)Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions ✅ Performance Check Passed (multipass)Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions |
|
✅ Performance Check Passed (multipass) Performance Benchmark Results (threshold: 25%)
Summary: 194 benchmarks, 0 regressions |
Summary
PR DTVMStack#446 over-approximates dynamic jumps by inserting an explicit edge
from every dynamic-jump block to every JUMPDEST, then splitting each as
a critical edge. On a contract with
Ddynamic jumps andJJUMPDESTs,this is
O(D × J²)and dominates JIT-prep time on jump-heavy bytecode.This PR replaces the materialised edges with a per-JUMPDEST scalar
ImplicitDynamicPredCountthat gets folded intoeffectivePredCountduring the lemma 6.14 update. The soundness check (
effectivePredCount > 1disables shifting) behaves identically — we just never build the D×J edges.
To keep dyn-only JUMPDESTs (Solidity function returns, unreachable in
the static-only CFG) visible to the dominator / loop analyses, we seed
the reachability search from every JUMPDEST after the static reachable
set is built.
Results
loop_full_of_jumpdests: 7.3s → 3.3sjump_around/empty: -53.1%signextend/zero: -24.6%blake2b_huff/8415nulls: -14.7%Detailed analysis:
docs/changes/2026-05-11-spp-cfg-implicit-dyn-pred/README.md.Test plan
tools/format.sh checkcleancmake --build build --target dtvmapiclean (no new warnings)evmone-unittestsmultipass: 223/223 pass🤖 Generated with Claude Code