diff --git a/docs/changes/2026-06-10-evm-sub-u64-wrap-lowering/README.md b/docs/changes/2026-06-10-evm-sub-u64-wrap-lowering/README.md new file mode 100644 index 000000000..b11d35ed9 --- /dev/null +++ b/docs/changes/2026-06-10-evm-sub-u64-wrap-lowering/README.md @@ -0,0 +1,143 @@ +# Change: Narrow wrap-form lowering for EVM SUB with u64-proven operand pairs + +- **Status**: Implemented +- **Date**: 2026-06-10 +- **Tier**: Light + +## Overview + +The result of EVM SUB wraps to the 2^256 scale on underflow, so the +**result** cannot be narrowed — but when range analysis has proven both +operands to be u64, the **computation** can: `(a - b) mod 2^256` is +identically `{wrapped difference a₀-b₀, borrow broadcast, borrow broadcast, +borrow broadcast}`, bit-exact for all inputs. This change lowers such SUB +sites from "8 `protectUnsafeValue` spills + a SUB/SBB chain" to "1 sub + +1 compare + 1 negate + borrow reuse". Paired EEST Cancun measurement: +site-weighted SUB fast-path hit rate **18.4% → 42.1% (+23.8pp)**, 925 sites +migrated, every other op's per-site classification unchanged; the correctness +suites pass (unittests 223/223, statetest 2723/2723 — see Verification), and +the end-to-end benchmarks are neutral. + +The differential coverage for this lowering path now ships separately with +the consolidated EVM differential suite change +(`docs/changes/2026-06-11-evm-differential-suite/`). That change carries the +6 fixtures for this SUB path — including the underflow all-ones-fill and +wrap-boundary adversarial cases — so this optimization change stays +code-only. + +## Motivation + +On EEST Cancun, SUB has the largest generic-4-limb-path site count of any +operator (3,893 sites, more than 4x ADD). At 50.2% of those sites both operands are +already statically proven u64 — loop counters, gas arithmetic, and length +differences. Yet SUB previously had only a constant-RHS fast path +(`handleSubU64Const`); dynamic u64 pairs all fell to the generic 4-limb +path. To protect the SBB carry chain from flag clobbering during x86 +lowering, the generic path first materializes all 8 operand limbs into +variables via `protectUnsafeValue`. This is a known source of spill +pressure. + +The value-range roadmap previously evaluated "SUB result narrowing" and +correctly deferred it: it requires the relational fact a≥b, which the +current lattice does not carry. This change takes a different route that +does not depend on that premise: **keep the result's U256 tag and narrow +only the computation form**. The underflow wrap is expressed exactly by the +borrow broadcast, so no no-underflow proof is needed. + +## Changes + +`src/compiler/evm_frontend/evm_mir_compiler.h` (the `handleBinaryArithmetic` +BO_SUB branch, inserted after the ADD narrow-form branch and before the +constant-folding paths): + +- Gate: `Operand::bothFitU64(LHSOp, RHSOp)` with both sides non-constant. + Constant cases still take the existing folding / identity / + `handleSubU64Const` paths, with ordering unchanged. +- Lowering: `Diff = sub(a₀, b₀)`; `Borrow = zext(a₀ <ᵤ b₀)`; + `Fill = 0 - Borrow` (in i64, 0-1 = all-ones, expressing the wrap fill of + the upper 192 bits exactly). Fill is materialized once into a temporary + variable and re-read per limb (the conservative multi-parent mode). There + is no SBB chain, so no flag-protection barriers are required. +- The result returns the **default U256 range** — no narrowing claim is + attached. This is the core soundness constraint of this change; the + analyzer's SUB transfer (`evm_analyzer.h:1645`, pushTop = U256) remains + symmetric. +- Adds a `SubFastRangeU64Count` counter and wires it into the arithmetic + fast-path summary predicate and log line. + +## Soundness + +- Wrap algebra: for a,b ∈ [0,2^64), a≥b → `{a₀-b₀,0,0,0}`; a(MemStats.AddFastRangeU64Count), static_cast(MemStats.AddFastConstU64Count), static_cast(MemStats.AddFullCount), + static_cast(MemStats.SubFastRangeU64Count), static_cast(MemStats.SubFastConstU64Count), static_cast(MemStats.SubFullCount), static_cast(MemStats.MulFastRangeU64Count), diff --git a/src/compiler/evm_frontend/evm_mir_compiler.h b/src/compiler/evm_frontend/evm_mir_compiler.h index fc02a1f24..520656dc1 100644 --- a/src/compiler/evm_frontend/evm_mir_compiler.h +++ b/src/compiler/evm_frontend/evm_mir_compiler.h @@ -419,6 +419,44 @@ class EVMMirBuilder final { } } + // Phase 1: Range-based u64 fast path for SUB. + // When both operands provably fit in u64, (a - b) mod 2^256 has only one + // meaningful limb of difference plus a borrow that sign-fills the upper + // 192 bits: limb0 = wrapping_sub(a0, b0); limbs[1..3] = 0 - borrow, where + // borrow = (a0 ( + false, OP_sub, MirI64Type, LHS[0], RHS[0]); + // Borrow = (LHS[0] < RHS[0]) ? 1 : 0 + MInstruction *BorrowCmp = createInstruction( + false, CmpInstruction::ICMP_ULT, MirI64Type, LHS[0], RHS[0]); + MInstruction *BorrowExt = zeroExtendToI64(BorrowCmp); + // Fill = 0 - borrow; all-ones on underflow, zero otherwise. + MInstruction *Fill = createInstruction( + false, OP_sub, MirI64Type, Zero, BorrowExt); + // Materialize Fill once and re-read it per upper limb (the + // conservative multi-use pattern, matching stackPop/stackGet). + Variable *FillVar = storeInstructionInTemp(Fill, MirI64Type); + U256Inst Result = {Diff, loadVariable(FillVar), loadVariable(FillVar), + loadVariable(FillVar)}; +#ifdef ZEN_ENABLE_MULTIPASS_JIT_LOGGING + ++MemStats.SubFastRangeU64Count; +#endif // ZEN_ENABLE_MULTIPASS_JIT_LOGGING + return Operand(Result, EVMType::UINT256); + } + } + // Phase 2: u64 fast path for ADD - share zero const for upper RHS limbs if constexpr (Operator == BinaryOperator::BO_ADD) { bool LHSIsU64 = LHSOp.isConstU64(); @@ -1325,6 +1363,7 @@ class EVMMirBuilder final { uint64_t AddFastRangeU64Count = 0; uint64_t AddFastConstU64Count = 0; uint64_t AddFullCount = 0; + uint64_t SubFastRangeU64Count = 0; uint64_t SubFastConstU64Count = 0; uint64_t SubFullCount = 0; uint64_t MulFastRangeU64Count = 0;