fix(thumb2): i64.popcnt epilogue clobber + i64.div_s INT64_MIN/-1 overflow trap (#632, #633) - #634
Merged
Merged
Conversation
…rflow trap (#632, #633) #632 — the I64Popcnt expansion's own scratch restore (POP {R3,R4,R5}) clobbered the freshly computed count whenever the allocator-assigned rd landed inside the restore set (ADDS rd,R4,R5 one instruction before the pop). Structural fix on both the Thumb-2 and A32 arms: the total is carried ACROSS the restore in R12 (encoder scratch, never allocatable per #212, never in any restore set) and moved into rd only after the pop — no choice of rd can collide. The entry marshal also routes rnlo through R12 so an operand pair living at (R3,R4) can no longer read a clobbered R4, and the new MOV/MOV.W forms are total over rd/rnhi = R8 where the old 3-bit T1 fields silently corrupted the encoding. Expansion-family audit (pop-restore clobber class): I64Popcnt was the only affected op on either ISA. I64Div{S,U}/I64Rem{S,U} and I64Rotl/Rotr stage their result in R0:R1 before their scratch pop and route it through the #610 fixed-ABI exit (which skips restored registers); I64Clz/I64Ctz and i32 Popcnt push no scratch. #633 — the i64 signed-division expansion emitted only the divide-by-zero guard: INT64_MIN/-1 negated the dividend onto itself and silently returned INT64_MIN instead of trapping (WASM Core 4.3.2 idiv_s). Mirror the i32 path's overflow guard on the #610/#613 fixed-ABI wrapper path (dividend R0:R1, divisor R2:R3): dividend==INT64_MIN && divisor==-1 -> UDF #0, on both the Thumb-2 and A32 I64DivS arms. I64RemS deliberately stays guard-free — rem_s(INT64_MIN,-1) is defined as 0 and must not trap (pinned by the fix-guard twin vectors and unit tests). Oracles (red -> green): - scripts/repro/i64_popcnt_632_differential.py — unicorn-vs-wasmtime, symtab-based: 6/11 vectors MISMATCH (0 for every input) on main, 11/11 OK post-fix. - scripts/repro/i64_divs_overflow_633_differential.py — INT64_MIN/-1 returned 0 instead of TRAP on main (2 MISMATCH), 16/16 OK post-fix including rem_s(INT64_MIN,-1)=0 no-trap and div-by-zero still-traps. - estimator_encoder_agreement (the #511 pin): I64Popcnt 172->180, I64DivS 172->194, register-independent. - frozen_codegen_bytes: all anchors untouched (no i64 popcnt / div_s-overflow shapes in the frozen fixtures). Closes #632 Closes #633 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
avrabe
added a commit
that referenced
this pull request
Jul 8, 2026
avrabe
added a commit
that referenced
this pull request
Jul 8, 2026
…lision, two-guard obligation split (VCR-PERF-002) (#636) Upgrades exactly the op class phase 2 deliberately left untracked: the fact_spec symbolic walk now tracks i32/i64 div/rem — never deleting them (they can trap; a div result carries no erasable producer slice) but discharging up to TWO INDEPENDENT per-site guard obligations through the ordeal-backed certificate-checked BvSolver BEFORE emission: divide-by-zero guard (div_u/div_s/rem_u/rem_s, i32+i64): UNSAT(P ∧ divisor == 0) INT_MIN/-1 overflow guard (div_s ONLY; rem_s(INT_MIN,-1)==0 never traps): UNSAT(P ∧ dividend == INT_MIN ∧ divisor == -1) THE TWO-GUARD DISTINCTION (#633/#634 synergy): a divisor-nonzero fact (kind 3) discharges the first but NOT the second — divisor ≠ 0 does not exclude -1, so the overflow guard is RETAINED (loud decline) unless the premises independently prove it (divisor ∈ [1,N] proves both; either fact kind works). Sat/Unknown/no-premise ⇒ loud decline, general lowering. Mechanics: discharged obligations become per-site marks (FactSpecResult::elide_div_zero/elide_div_ovf, remapped through any clamp-elision rewrite), threaded via CompileConfig::fact_div_zero_elide/ fact_div_ovf_elide to the DIRECT selector only — the optimized path's IR passes renumber instructions, so marked functions route to select_with_stack (#507/#509 honest-degradation pattern; never fires without SYNTH_FACT_SPEC + facts + a discharged obligation). i32 guards are selector-emitted and skipped; i64 guards live in the ArmOp::I64Div*/Rem* encoder expansions, which gain per-guard elision flags (Thumb-2 + A32), with estimator sizes tracking the flags (#511 agreement oracle extended with the 5 elided variants). Oracles: 9 new fact_spec unit gates (two-guard matrix, i64 width discipline via current_func_params_i64, mark remapping); encoder splice pins (elision removes EXACTLY the 8 B/12 B zero guard; overflow retained under zero-only elision); fact_spec_div_494.rs byte evidence (guard UDFs present without facts, absent with facts+flag; qs64 keeps exactly 1 UDF = the retained INT64_MIN/-1 guard; Sat-decline byte-identity; debug-only SYNTH_FACT_SPEC_FORCE_ADMIT red lever screams); fact_spec_div_494_differential.py (1584-case in-bounds sweep specialized ≡ wasmtime ≡ unspecialized; qs64(INT64_MIN,-1) traps in BOTH wasmtime and the specialized build; --expect-decline byte-identity; --force-admit RED leg: wasmtime traps at divisor=0, the forced unsound build returns 0) — all CI-gated by the extended fact-spec-oracle job. Fixture bytes (cortex-m4): qu 16→12, qs 36→12 (both guards proven dead), ru 20→16, qs64 214→206 (zero guard spliced, 22 B overflow guard retained); .text 446→406. Frozen anchors bit-identical (no fixture carries facts); flag default OFF. Closes nothing yet — #494 phase 3 (gale measurement) remains. Refs #494, #242, #633, #634. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
avrabe
added a commit
that referenced
this pull request
Jul 10, 2026
….0xFFF (#681) (#690) encode_thumb32_add_imm packed the RAW immediate into the T3 ADD.W i:imm3:imm8 field, which is a ThumbExpandImm MODIFIED immediate — correct only for imm <= 0xFF. ThumbExpandImm(0x200) = 0 and ThumbExpandImm(0x400) = 0x8000_0000, so every dynamic-address load/store with a static offset in [256, 4095] silently computed a WRONG address. In --safety-bounds software the guard (correct T4 ADDW) checked the intended address while the access used the mis-encoded one — a bounds-check bypass. #253/#255 ThumbExpandImm class, reached via the #382 paths. Fix: imm <= 0xFFF delegates to encode_thumb32_add, which already picks T3 (<= 0xFF, raw == expanded, bit-identical) vs ADDW T4 plain imm12 (0x100..=0xFFF) per #253. The > 0xFFF MOVW/MOVT path is unchanged, so byte sizes are unchanged (estimator agreement #511 stays green). Class audit (no wildcard survives, #634 style): - ArmOp::Rsb (Thumb T2): field is ThumbExpandImm-coded with NO plain imm12 form — now gated on try_thumb_expand_imm, Err on non-representable. All emitters use imm 32 (byte-identical). - ArmOp::Rsb (A32): imm was silently masked & 0xFF (#378 class) — now Err for imm > 0xFF. - encode_thumb32_and_imm_raw: raw-packed ThumbExpandImm field — now gated; only caller (POPCNT, #0x3F) byte-identical. - encode_thumb32_sub/adds/subs/cmp already correct (T4 / expand-gated). Oracles: - test_encode_add_imm_thumb_expand_681: clang -target thumbv7m pinned bit-for-bit (0xFF/0x100/0x104/0x200/0x3FC/0x400/0xFFF + rd/rn perm). - test_encode_add_imm_large_350's 0x123 assertion upgraded from length-only (which let the mis-encoding pass CI) to exact bytes. - scripts/repro/addw_offset_681_differential.py: unicorn-vs-wasmtime, dynamic base + static offsets, i32/i8/i16/i64 load+store, bounds none+software incl. the bypass pin and OOB trap-to-trap. RED on pre-fix main (36 mismatches: clobber returns 4660 not 111; offset 1024 faults 2 GiB past base), GREEN post-fix (49/49). CI-wired in the trap-semantics oracle job. - Frozen anchors 10/10 byte-identical; estimator agreement green; workspace tests, fmt, clippy -D warnings clean. Closes #681 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #632, fixes #633 — two shipped thumb-2 i64 miscompiles filed by gale, fixed structurally on both the Thumb-2 (cortex-m) and A32 (cortex-r5) paths.
#632 — i64.popcnt result clobbered by the expansion's own scratch-restore pop
Root cause (confirmed): it is neither the allocator choosing a "wrong" register nor the function epilogue — the
I64Popcntexpansion itself saves scratch withPUSH {R3,R4,R5}, computespopcnt(lo)→R4,popcnt(hi)→R5, materializes the total withADDS rd, R4, R5, and then restores scratch withPOP {R3,R4,R5}.rdis allocator-assigned (any of R0–R8); whenever pressure lands it inside the expansion's own restore set {R3,R4,R5} (the issue's case: rd=R5), the restore destroys the result one instruction after it is produced.Structural fix: the count is carried ACROSS the restore in R12 — encoder scratch, never allocatable (#212), never in any restore set — and moved into
rdonly after the pop (ADD.W R12,R4,R5; POP {R3,R4,R5}; MOV rd,R12). No choice ofrdcan collide, by construction. Two latent hazards in the same arm fixed alongside: the entry marshal now routesrnlothrough R12 (an operand pair living at (R3,R4) previously read a clobbered R4), and the new MOV/MOV.W forms are total overrd/rnhi= R8 (the oldADDST1 /MOVST1 3-bit fields silently corrupted the encoding for R8 — #178/#180 class).Expansion-family audit (pop-restore clobber class), per the #615-style sweep:
I64PopcntADDS rd,R4,R5beforePOP {R3,R4,R5}I64DivS/DivU/RemS/RemUPOP {R4-R11}/{R4-R8}/{R4-R7}; #610 fixed-ABI exit skips restored regsI64Rotl/RotrI64Clz/I64CtzPopcntI64Popcntwas the only member of the class, on both ISAs; both are fixed here.#633 — i64.div_s(INT64_MIN, -1) silently returned INT64_MIN instead of trapping
Root cause (confirmed): the I64DivS expansion emitted only the divide-by-zero guard. It negated the dividend (INT64_MIN wraps to itself), negated the divisor (−1→1), ran the unsigned core, and returned
0x8000000000000000— no trap, violating WASM Core §4.3.2idiv_s. The i32 path has the guard; the i64 path did not.Fix: mirror the i32 overflow guard on the #610/#613 fixed-ABI wrapper path, right after the zero-divisor guard, where the dividend is in R0:R1 and the divisor in R2:R3:
divisor == -1 && dividend == INT64_MIN → UDF #0(22 bytes Thumb-2, register-independent; conditional-execution twin on A32). Emitted for div_s only.Fix-guard twin:
i64.rem_s(INT64_MIN, -1)keeps returning 0 without trapping — pinned by differential vectors (rem_s_m1(0, 0x80000000) = 0 OK,rem_s(IMIN, -1) = 0 OK) and by unit tests asserting I64RemS contains exactly one UDF (the zero-divisor trap) on both ISAs.Oracles — red → green
scripts/repro/i64_popcnt_632_differential.py(unicorn-vs-wasmtime, symtab-based,-t cortex-m3 --relocatable), on origin/main:post-fix: all 11 vectors OK,
ORACLE: PASS(issue table (7,0)→3, (1,1)→2, (−1,0)→32, (−1,−1)→64 all exact).scripts/repro/i64_divs_overflow_633_differential.py, on origin/main:post-fix: all 16 vectors OK,
ORACLE: PASS— including100/-1 = -100,INT64_MIN/1and/2(defined, no trap, both halves checked),rem_s(INT64_MIN,-1) = 0no-trap, and div/rem-by-zero still trapping on both sides.Gates
test_632_i64_popcnt_result_survives_scratch_restore(all rd incl. {R3,R4,R5}, R8),test_632_i64_popcnt_marshal_pair_at_r3_r4,test_632_a32_i64_popcnt_result_survives_scratch_restore,test_633_i64_divs_overflow_guard_emitted,test_633_i64_rems_has_no_overflow_guard,test_633_a32_i64_divs_overflow_guardestimator_encoder_agreement(test(vcr-oracle): estimator↔encoder agreement oracle for the optimized path (#498, #242) #511 pin): green —I64Popcnt172→180,I64DivS172→194, register-independent;I64RemSunchanged at 170cargo test -p synth-cli --test frozen_codegen_bytes: all anchors untouched (no i64 popcnt / div_s-overflow shapes in the frozen fixtures)cargo test --workspace: 105 suites, 0 failurescargo fmt --checkclean,cargo clippy --workspace --all-targets -- -D warningsclean🤖 Generated with Claude Code