Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions docs/changes/2026-05-12-g4-safe-valuerange/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Change: G4-safe ValueRange propagation for OR/XOR/SHR_U + range-driven compare

- **Status**: Proposed
- **Date**: 2026-05-12
- **Tier**: Light

## Overview

Extend `EVMMirBuilder::ValueRange` propagation to three monotone-safe sites:

1. `handleBitwiseOp<BO_OR>` / `handleBitwiseOp<BO_XOR>`: result range = `max(LHS.range, RHS.range)` (both Phase-1 u64-const fast path and Phase-5 general path).
2. `handleShift<BO_SHR_U>` (unsigned right shift): result range = `ValueOp.range` (right shift cannot widen).
3. `handleCompareEqU64` / `handleCompareLtRhsU64` / `handleCompareGtRhsU64`: when the wide operand carries `ValueRange::U64`, skip the upper-limbs OR-fold and emit a direct single-limb compare.

Explicitly **out of scope** for this PR per `2026-05-12-verified-opportunities.md` §1.G4 verdict: SUB (wraps to full U256), SHL (widens), SAR (sign-fills), ADDMOD/MULMOD (need richer range model), EXP, signed compare.

This work is **orthogonal** to the `perf/value-range-cfg-join` branch, which performs CFG-level join/meet range analysis and plumbs analyzer-derived ranges into block-entry operands. G4-safe adds per-opcode producer threading; cfg-join feeds those producers across control flow. The two compose without conflict (G4-safe touches `handleBitwiseOp`, `handleShift`, `handleCompare*RhsU64`; cfg-join touches `createStackEntryOperand` and adds an `EVMValueRange` external type).

## Motivation

`ValueRange` was introduced in PR #458 (`fca0b1a`) as a wholesale producer-side annotation: ADD/MUL/DIV/MOD/AND/BYTE/u64-compare populate it; the only non-arithmetic consumer is AND. Every other bitwise/shift/compare operator **drops the range to U256** even when the inputs are demonstrably narrower:

- `(narrow_mul a, b) | c` → range falls back to U256, blocking any downstream narrow-path lowering.
- `(narrow_mul a, b) >> k` → same loss.
- `(narrow_mul a, b) < C64` → constant fast path triggers, but the helper still emits a 3-upper-limb OR-fold check (`LHS[1] | LHS[2] | LHS[3]`) that we *know* is zero.

The 2026-05-12 verification doc isolated this as the smallest, lowest-risk subset of the G4 work: monotone propagation only, no wraparound or sign-fill semantics. The win is small per opcode but **load-bearing** for any future narrow consumer (compare-via-range, narrow lowering paths for additional opcodes).

## Impact

### Modules touched

- `src/compiler/evm_frontend/evm_mir_compiler.h` — three return sites in `handleBitwiseOp` (Phase 1 OR/XOR fast path, Phase 5 general path) and one in `handleShift`. Optionally a small `static maxRange` helper near the `ValueRange` enum.
- `src/compiler/evm_frontend/evm_mir_compiler.cpp` — three helper bodies: `handleCompareEqU64` (around line 2978), `handleCompareLtRhsU64` (around line 3011), `handleCompareGtRhsU64` (around line 3046).

### Contracts preserved

- EVM observable semantics: unchanged. All optimizations rely on `getRange()` annotations established at producer sites; values whose runtime bits do not match their annotation would be a pre-existing invariant violation.
- `protectUnsafeValue` calls remain on every generated I64 result that flows into stack-residence — the consumer side only changes WHICH limbs are computed, not their barrier annotation.
- Result encoding for narrow-path compares unchanged: `Result[0] = cmp`, `Result[1..3] = Zero`, `Range = U64`.

### Risks

- **Low**: monotone non-widening transformations only. OR/XOR of two operands with range `U128` yields a U128 value (limbs[2..3] of both operands are 0 → OR/XOR of zeros is zero). Logical right shift of a U64 value yields a U64 value (limbs[1..3] of input are 0 → shifting out is still zero in limbs[1..3]; shift cannot inject bits from above-limb).
- **Compare consumer change**: requires the producer-side `ValueRange::U64` invariant to hold at runtime. PR #458 establishes this invariant from ADD/MUL/DIV/MOD/AND; this PR extends it to OR/XOR/SHR_U.

### Invariant chain (correctness of the compare-side fast path)

The compare-side fast path trusts a **value-level** invariant: when an operand carries `ValueRange::U64`, its upper limbs evaluate to zero at runtime — even if the MIR for those limbs is not a literal `Zero` constant. This is the **Range contract**.

Two independent producer paths uphold the contract:

1. **Direct materialization** (pre-existing, since PR #458). Every producer that explicitly assigns `Range = U64` also writes literal MIR `Zero` to `limbs[1..3]`. Verified producer sites: `evm_mir_compiler.h:565` (general compare result), `evm_mir_compiler.h:629` (AND u64-const fast path), `evm_mir_compiler.cpp:2024` (DIV u64÷u64), `evm_mir_compiler.cpp:2194` (MOD u64÷u64), the three existing `handleCompare*U64` helpers, and BYTE.
2. **Analyzer-derived narrowing** (PR #493, `EVMRangeAnalyzer`). The dataflow analyzer retrofits `Range = U64` onto stack-popped operands whose backing variables hold any MIR that *evaluates to* a u64-fitting value. The analyzer guarantees value-level zero in `limbs[1..3]`, not literal-zero MIR.

The new compare-side fast path **does not weaken** the Range contract: it only reads `getRange()` and elides reading `LHS[1..3]`. It never creates a U64-tagged operand with non-zero upper-limb values.

**SHR_U caveat (value-level, not MIR-level)**: `handleLogicalRightShift` emits `Select(IsLargeShift, Zero, <ushr-of-zero>)` for upper limbs when the input has `ValueRange::U64`. The runtime value is zero, but the MIR is not necessarily a literal `Zero`. Consumers must gate on `Range` rather than MIR-pattern-match upper limbs — the compare-side fast path here does exactly that.

The same trust model already applies to the AND `NarrowRange` path at `evm_mir_compiler.h:633-655`.

## Implementation

### Step 1 — Helper: derive merged range for binary ops

Add an inline helper in `evm_mir_compiler.h` (near the `ValueRange` enum), or inline at use site:

```cpp
static ValueRange maxRange(const Operand &A, const Operand &B) {
return A.getRange() > B.getRange() ? A.getRange() : B.getRange();
}
```

`ValueRange` is `uint8_t` with `U64=0 < U128=1 < U256=2`, so `>` gives the wider tier.

### Step 2 — OR/XOR: thread range through two return sites

In `handleBitwiseOp` (`evm_mir_compiler.h:570-693`):

- Phase 1 u64-const fast path return (currently line 678):
```cpp
return Operand(Result, EVMType::UINT256, OtherOp.getRange());
```
Rationale: the u64-const side has range U64, so `max(U64, OtherOp.range) = OtherOp.range`. Limb[0] is recomputed; limbs[1..3] pass through unchanged from `OtherOp`. Their value-range claim is preserved.

- Phase 5 general path return (currently line 692):
```cpp
return Operand(Result, EVMType::UINT256, maxRange(LHSOp, RHSOp));
```

### Step 3 — Unsigned SHR: thread range from ValueOp

In `handleShift<BO_SHR_U>` (`evm_mir_compiler.h:704-756`):

Change the single shared `return Operand(Result, EVMType::UINT256);` (line 755) to be operator-aware:

```cpp
if constexpr (Operator == BinaryOperator::BO_SHR_U) {
return Operand(Result, EVMType::UINT256, ValueOp.getRange());
}
return Operand(Result, EVMType::UINT256);
```

Rationale: for SHL the range *can* widen and we keep U256 default; for SAR sign-fill can populate upper limbs and we keep U256 default. For SHR_U, an N-bit value shifted right yields an at-most-N-bit value, so the range is preserved.

### Step 4 — Compare consumer: skip upper-limbs OR-fold when range is U64

Modify three helpers in `evm_mir_compiler.cpp`:

- `handleCompareEqU64`: when `FullOp.getRange() == ValueRange::U64`, the 3-upper-limb OR-fold (`Upper = LHS[1] | LHS[2] | LHS[3]`) is provably zero. Skip it; emit `FinalResult = ICMP_EQ(LHS[0], U64Val)` directly. Save 2 OR + 1 CMP + 1 AND per call.
- `handleCompareLtRhsU64`: when `LHSOp.getRange() == ValueRange::U64`, `HasUpper` is provably false. Skip the 3-upper-limb OR-fold and the `SelectInstruction`; emit `FinalResult = ICMP_ULT(LHS[0], RhsVal)` directly. Save 2 OR + 1 CMP + 1 SELECT.
- `handleCompareGtRhsU64`: when `LHSOp.getRange() == ValueRange::U64`, mirror of LT: emit `FinalResult = ICMP_UGT(LHS[0], RhsVal)` directly.

Preserve the `protectUnsafeValue` wrap and the `Result[1..3] = Zero` tail in all three. Return range remains `ValueRange::U64` (comparison results are 0 or 1).

### Step 5 — Build + verify

- Configure CMake with the worktree-bootstrap default flags (includes `-DZEN_ENABLE_JIT_PRECOMPILE_FALLBACK=ON` per memory `feedback_jit_fallback_required_flag.md`; otherwise peephole O(n²) `setInsertBlock` hangs).
- Run `tools/format.sh check`.
- Local tests per `.claude/rules/dtvm-local-test.md`:
- `evmone-unittests` multipass with `EVMOneMultipassUnitTestsRunList.txt`
- `evmone-statetest -k fork_Cancun` multipass with `enable_gas_metering=true`
- For perf measurement: `/bench-compare` on 27-bench vs upstream/main baseline (per `.claude/rules/dtvm-perf-worktree-lab.md`).

### Step 6 — Document the result back into the analysis doc

Update `docs/research/directions/u256-strength-reduction/analysis/2026-05-12-verified-opportunities.md`:

- §0 landed-status table: add a row for "G4-safe ValueRange OR/XOR/SHR_U/compare" with commit reference.
- §2 Tier 1 entry #1 and §3 Sprint 1: annotate as `Implemented` with link to this change doc and bench numbers.

## Checklist

- [ ] Step 1 helper or inline merge in place
- [ ] Step 2: OR/XOR Phase 1 + Phase 5 returns thread range
- [ ] Step 3: SHR_U return threads `ValueOp.getRange()`
- [ ] Step 4: three compare helpers gain U64-range early branch
- [ ] `tools/format.sh check` passes
- [ ] Build with CI-faithful flags green
- [ ] `evmone-unittests` (multipass) green
- [ ] `evmone-statetest -k fork_Cancun` (multipass) green
- [ ] `/bench-compare` shows no regression vs upstream/main on 27-bench
- [ ] `2026-05-12-verified-opportunities.md` updated with shipped status
- [ ] Module specs in `docs/modules/` updated (if affected — likely not for this change)
- [ ] No new producer site emits `ValueRange::U64` without setting `Result[1..3] = Zero` at the MIR level (self-audit of the diff before commit)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Round 1 — Codex Review of G4-safe Implementation

## Verdict
PASS

## Verified
- Check 1: ✅ `handleBitwiseOp` returns `Operand(Result, EVMType::UINT256, OtherOp.getRange())` inside the `BO_OR || BO_XOR` `if constexpr`; function return type is `Operand`. `src/compiler/evm_frontend/evm_mir_compiler.h:575`, `src/compiler/evm_frontend/evm_mir_compiler.h:664-686`
- Check 2: ✅ Phase 5 excludes AND via `if constexpr (BO_OR || BO_XOR)` and uses `Operand::maxRange`; AND still returns earlier for u64-const and U128/NarrowRange, while unconstrained U256 AND falls through to default `Operand(Result, EVMType::UINT256)`. `src/compiler/evm_frontend/evm_mir_compiler.h:615-661`, `src/compiler/evm_frontend/evm_mir_compiler.h:690-707`
- Check 3: ✅ `maxRange` is a static helper on `Operand`; call site correctly uses `Operand::maxRange`. `src/compiler/evm_frontend/evm_mir_compiler.h:266-269`, `src/compiler/evm_frontend/evm_mir_compiler.h:703-705`
- Check 4: ✅ only `BO_SHR_U` returns `ValueOp.getRange()`; SHL and SHR_S share the conservative default return. `src/compiler/evm_frontend/evm_mir_compiler.h:762-776`
- Check 5: ✅ `handleCompareEqU64` uses `FinalResult = LowEq` for `Range == U64`; else branch preserves upper-limb OR-fold, `ICMP_EQ` vs Zero, and AND with `LowEq`; both feed one protected result and zero-fill tail. `src/compiler/evm_frontend/evm_mir_compiler.cpp:2990-3014`
- Check 6: ✅ LT and GT use the same shape: `LowLt`/`LowGt` fast path when range is U64; else branch preserves HasUpper select semantics. `src/compiler/evm_frontend/evm_mir_compiler.cpp:3032-3056`, `src/compiler/evm_frontend/evm_mir_compiler.cpp:3074-3100`
- Check 7: ✅ `git diff --name-only HEAD -- src/` lists only `evm_mir_compiler.cpp` and `evm_mir_compiler.h`; no other `src/` file is modified.
- Check 8: ✅ no fast-path branch returns early; all three compare helpers converge to `Result[0] = protectUnsafeValue(...)` and `Result[1..3] = Zero`. `src/compiler/evm_frontend/evm_mir_compiler.cpp:3009-3014`, `src/compiler/evm_frontend/evm_mir_compiler.cpp:3051-3056`, `src/compiler/evm_frontend/evm_mir_compiler.cpp:3095-3100`

## Refuted / concerns
- Check 9: ⚠️ `tools/format.sh check` was run and failed with exit 123 on unrelated existing files, e.g. `src/singlepass/x64/assembler.h` and `src/host/wasi/sandboxed-system-primitives/src/posix.c`. The two touched files pass `clang-format --dry-run -style=file -Werror`.
- No semantic scope creep found: SUB, SHL, SAR, ADDMOD/MULMOD, EXP, signed compare, and unrelated files are untouched by this diff.

## Suggested commit subject
perf(evm): propagate safe ValueRange through OR/XOR and SHR_U

## Trust-chain analysis
For check #10, the fast paths are intentionally unsound if `Range == U64` lies. EQ would be wrong when upper limbs are nonzero and the low limb equals the constant: it returns `LowEq` true instead of old `LowEq && UpperZero`. LT would be wrong when upper limbs are nonzero and `low < rhs`: it returns true even though the full U256 is greater than any u64. GT would be wrong when upper limbs are nonzero and `low <= rhs`: it returns false even though the full U256 is greater than any u64. This matches the spec trust chain: correctness depends on `ValueRange::U64` implying runtime-zero upper limbs.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Impl Review — Round 1 (Opus)

**Subject**: G4-safe ValueRange propagation implementation
**Reviewer persona**: DTVM senior compiler reviewer (adversarial, contract-focused)
**Diff scope**: `src/compiler/evm_frontend/evm_mir_compiler.{h,cpp}`

## Verdict: **PASS**

Implementation faithfully realizes Steps 1–4 of the spec. No correctness regressions
identified, no unstated extra changes, format/build/tests clean.

## Spec ↔ Impl mapping

| Spec step | Site | Status |
|-----------|------|--------|
| 1. `maxRange` helper | `evm_mir_compiler.h:267-269` | OK — `static`, U64<U128<U256 ordering valid (Range uint8_t, U64=0,U128=1,U256=2) |
| 2a. OR/XOR Phase-1 fast path | `evm_mir_compiler.h:686` | OK — guarded by `if constexpr (BO_OR \|\| BO_XOR)` block at `h:665-688` (BO_AND fast path is a separate earlier block at `h:616-635`) |
| 2b. OR/XOR Phase-5 general path | `evm_mir_compiler.h:703-706` | OK — `if constexpr` correctly excludes AND |
| 3. SHR_U range thread | `evm_mir_compiler.h:773-775` | OK — SHL/SHR_S fall through to default at `h:776` |
| 4a. handleCompareEqU64 early branch | `evm_mir_compiler.cpp:2990-3007` | OK — `Result[1..3]=Zero` tail preserved at `cpp:3011-3013`, range tag preserved at `cpp:3014` |
| 4b. handleCompareLtRhsU64 | `evm_mir_compiler.cpp:3032-3049` | OK — same shape, tail at `cpp:3053-3055` |
| 4c. handleCompareGtRhsU64 | `evm_mir_compiler.cpp:3074-3093` | OK — `One` correctly hoisted to else branch only (`cpp:3080`); fast path uses only `LowGt`, no dead `One` |

## Critical issues

**None.**

Detailed verification of the six review concerns:

1. **Phase-1 OR/XOR fast path scope** (`h:686`) — sits inside `if constexpr (BO_OR || BO_XOR)` at `h:665-666`; AND has its own block at `h:616-662`. Confirmed isolated.
2. **Phase-5 AND fall-through hazard** — AND has two earlier `return` sites: `h:634` (U64 fast path) and `h:660` (U128-Narrow). For two-U256-operand AND it does fall through to `h:690-707`, but the new `if constexpr` at `h:703-704` only fires for OR/XOR — AND correctly reaches the fallback `return Operand(Result, EVMType::UINT256);` at `h:707`. **Monotone-min invariant for AND preserved.** Safe.
3. **`maxRange` ordering** — `Range` is `uint8_t` with `U64=0 < U128=1 < U256=2` (`evm_mir_compiler.h:278` for default; enum confirmed in spec README §Step 1). `A.getRange() > B.getRange() ? A : B` gives the wider (more conservative) tier. Correct.
4. **Phase-1 OR/XOR limb pass-through soundness** — limbs[1..3] of `Result` come from `Other[1..3]` (`h:680-682`), i.e. the same `MInstruction *` pointers held by `OtherOp`. Returning `OtherOp.getRange()` is therefore strictly correct (no widening, no narrowing). Comment at `h:683-685` documents this.
5. **SHR_U gating on `ValueOp`** — `ValueOp` is the value being shifted (`h:718-719` signature `handleShift(Operand ShiftOp, Operand ValueOp)`); right-shifting an N-bit value yields at-most-N bits. Correct.
6. **Compare consumer invariants** — all three helpers:
- keep `Result[1..3] = Zero` initialization in BOTH branches (it's after the if/else, so unconditional);
- keep `Result[0] = protectUnsafeValue(FinalResult, MirI64Type)` wrap;
- keep `return Operand(Result, EVMType::UINT256, ValueRange::U64)`.

`GtRhsU64` correctly only allocates `One` in the else branch — no dead use, no leak (instructions are arena-allocated regardless).

## Style / nit issues

- En-dash characters (`—`) in comments at `cpp:3034`, `cpp:3076`, `h:701`. ASCII-only is not codified in `.claude/rules/cpp-code-style.md`, and existing files contain similar Unicode. **Not blocking.**
- All comments in English (per `cpp-code-style.md`). No new doc-blocks added. LLVM naming preserved. Trailing newline preserved (file unchanged at tail).
- `tools/format.sh check` → exit 0 (no diff).

## Build / test artifacts

- `build/lib/libdtvmapi.so` present (confirmed `ls`).
- User-reported: evmone-unittests multipass 223/223, evmone-statetest `-k fork_Cancun` multipass 2723/2723. Consistent with diff scope (no semantic change for non-narrow producers; range-tag-only at most call sites).

## Suggested commit subject

```
perf(compiler): thread ValueRange through OR/XOR/SHR_U and skip OR-fold in U64 compares
```

(89 chars; lowercase type/scope; imperative; under 120-char cap per `commitlint.config.js`.)

Optional body:

```
Phase 1: extend monotone ValueRange propagation to OR/XOR (max of operand
ranges) and unsigned right shift (input range — N-bit input cannot widen
under SHR_U).

Phase 2: in handleCompareEqU64 / handleCompareLtRhsU64 / handleCompareGtRhsU64,
when the wide operand carries ValueRange::U64, the upper-limb OR-fold zero-test
is provably redundant: every existing U64 producer materializes literal MIR
Zero in limbs[1..3]. Elide the OR-fold and the zero-AND / Select; emit the
single-limb compare directly.

Out of scope: SUB/SHL/SAR (widening or sign-fill), ADDMOD/MULMOD, EXP, signed
compare. Composes with perf/value-range-cfg-join.
```

## Verified facts table

| Claim | Source |
|-------|--------|
| `maxRange` helper added | `src/compiler/evm_frontend/evm_mir_compiler.h:266-269` |
| Phase-1 OR/XOR returns `OtherOp.getRange()` | `evm_mir_compiler.h:686` |
| Phase-1 guarded by OR/XOR `if constexpr` | `evm_mir_compiler.h:665-666` |
| Phase-5 OR/XOR returns `maxRange(LHSOp, RHSOp)` | `evm_mir_compiler.h:703-706` |
| Phase-5 AND falls through to default `Operand(..., UINT256)` | `evm_mir_compiler.h:707` |
| SHR_U threads `ValueOp.getRange()`; SHL/SAR keep default | `evm_mir_compiler.h:773-775` then `:776` |
| `handleCompareEqU64` early branch + tail preserved | `evm_mir_compiler.cpp:2990-3014` |
| `handleCompareLtRhsU64` early branch + tail preserved | `evm_mir_compiler.cpp:3032-3056` |
| `handleCompareGtRhsU64` `One` hoisted to else only | `evm_mir_compiler.cpp:3074-3093` |
| All three return `ValueRange::U64` | `cpp:3014`, `cpp:3056`, `cpp:3100` (tail unchanged) |
| `tools/format.sh check` exit 0 | command output |
| `build/lib/libdtvmapi.so` present | `ls` |
Loading
Loading