Port m2c's three nice-to-have commits (+1 match, three silent miscompiles fixed) - #23
Merged
Conversation
An `icmp_*` result is 0 or 1 by construction, so xoring the low bit or testing it
against zero is exactly negation. A compiler with no set-on-greater-equal spells a
materialised `a >= b` that way: MIPS `slt v0,a0,a1; xori v0,v0,1` (IDO and both
GCCs), and the branch form `… ; beqz v0` when the boolean is then tested. The
naive lift printed the double negatives `a0 < a1 ^ 1` and `a0 >= a1 == 0`, and hid
the comparison from every consumer that reasons about booleans — the short-circuit
recognizer matches an `icmp` feeder, not an `xor` or an `icmp_eq` of one.
xor(cmp, 1) → !cmp
icmp_eq(cmp, 0) → !cmp
icmp_ne(cmp, 0) → cmp
Ported as an IDEA from m2c `40cbae3` (which extended its MIPS/PPC `handle_xori`
fold to ARM); the implementation is asmlift's own — thirty data patterns in the
idiom layer, since a data-driven fold needs a fixed replacement opcode, so the
negation table is unrolled rather than expressed as a computed-opcode replacement.
`icmp_eq`/`icmp_ne` also join the engine's COMMUTATIVE set: `x == 0` and `0 == x`
are the same test and which one a frontend builds is an accident of decoding.
These are the first UNGATED patterns in the bundle, and the difference is
principled. The compiler-pinned folds trade one spelling for another and are only
byte-safe where measured; here the SHAPE IS ITS OWN GATE — the pattern can only
fire where the compiler emitted the flip, and wherever it did, the negated
comparison is what it was spelling. A compiler with a set-on-greater-equal never
produces the shape and so can never be harmed. (Four prose sites claiming every
default pattern is `{compilers}`-gated are corrected; the mwcc half of that claim
was already stale.)
The negation now comes from ONE table (`ir/opcodes.ts` NEGATED_ICMP, expanded from
five involutive pairs so `neg(neg(c)) === c` holds by construction). The MIPS
`slt …; beqz` branch fold and the short-circuit diamond negation each carried
their own copy; all three now read the shared one. A test asserts its completeness
against the registry's icmp family — the part construction cannot give, and an
eleventh comparison would otherwise degrade three consumers three different ways.
Benchmark: **synthetic:clampu8:ido7.1 flips nonmatch → MATCH, asmlift 360 → 361**
(m2c 342 unchanged). Its `slti at,a0,256` and `bnez at` sit in DIFFERENT blocks, so
the frontend's own compare-branch peephole cannot fuse them — that peephole is
block-local and register-keyed, and the SSA-level idiom has neither limit. This is
the argument for doing it at this layer, stated as a measurement. Round-trip
evidence: synthetic:inrange:gcc2.7.2kmc stays byte-exact MATCH while its source
goes from `(a0 < a1 ^ 1) & (a2 < a0 ^ 1)` to `a0 >= a1 & a2 >= a0`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while measuring the boolean-negation folds, on a live benchmark row. The `slt …; beqz` fold rewrites a branch into the (negated) comparison that defined the tested register — but the record was keyed by REGISTER, and a register is exactly what the next instruction may redefine: slt v0,a0,a1 xori v0,v0,0x1 ← v0 redefined: it now holds `a0 >= a1` beqz v0,.L The fold matched the DEAD `slt` and emitted the exactly inverted condition. synthetic:inrange returned 1 where the function returns 0 (inrange(5,1,0)) — silently wrong C with no marker and no decline, and the harness scored it `nonmatch 9/10`, indistinguishable from an honest near-miss. Keying on the SSA VALUE the compare produced removes the class rather than the instance: `condValue` resolves the branch register to its reaching value and looks THAT up, so any redefinition simply misses and the honest `icmp_eq(rX, 0)` is emitted (which the idiom layer then folds back to a plain comparison). Nothing is left for a future writer to remember — and that matters, because invalidating on write would NOT have been enough: `lui rD,%hi(SYM)` deliberately reassigns a register without going through the write path, and the fold also bypassed `read`'s own sp/`%hi`/`gp` used-as-data declines by never reading the register at all. Verified alongside the inversion: the `lui %hi` case now declines loud; `slt zero,…; beqz zero` no longer folds an unconditionally-taken branch; a spill/reload pair still folds; and `move v1,v0; beqz v1` now folds through the copy, which it did not before. The shape has three inhabitants in the corpus. Two snowboardkids2 rows carry it masked behind the `jal` decline, and one of those silently DROPPED a conjunct (`sltiu; and; beqz` emitted `if (a0 < 1)`) — so the class grows the moment MIPS calls land. Pinned with corpus/ido-inrange.asm, beside ido-maxab.asm which is the same fold in its clean form; the expectation is whole text because two mechanisms must hold together, the value keying and the idiom layer's negation folds. Benchmark: 743 rows, zero moved — that row was the stale fold's only liftable inhabitant, and it was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same stale-compare class as the MIPS fix, in the sibling frontend, and worse in kind. `pendingCmp` is seeded at `cmp` and was never invalidated — but on Thumb-1 nearly every data-processing instruction on LOW registers writes the condition flags, `s`-suffix or not (agbcc spells `adds r0,r0,r3` as `add r0,r0,r3`, and the assembler picks the flag-setting encoding). So an instruction between a `cmp` and its branch REPLACES the flags the branch tests: cmp r0, r1 add r2, r0, #1 ← sets flags; `beq` tests THIS beq .L1 emitted `if (a0 != a1)`, where the hardware branches on `r0 + 1 == 0`. No benchmark row exhibits it — compilers keep the pair adjacent, and across every agbcc row no conditional-branch block has ANY instruction between its compare and the branch. That measurement is the argument for closing the hazard, not for leaving it open: the corpus is not the input domain (the playground takes hand-written asm, where there is no oracle and a silent miscompile has no tell), and the remedy here is a loud DECLINE, which guesses at nothing. So: drop the pending compare on a flag-setting mnemonic with a low-register destination and let the terminator's existing "no reaching compare in its block" decline fire — the same loud answer that shape already gets when a label splits the pair, and pinned next to it. High-register forms (`mov rD,rH`, `add rD,rH`) do not set flags and stay transparent, which is what keeps agbcc's callee-saved shuffling from tripping it; loads, stores, push/pop and `bl` are unaffected, and the test pins those three non-tripping shapes too. Benchmark: 743 rows, zero moved — free, as the scan predicted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
m2c declared every global symbol it had accumulated during translation, and fixed it upstream (`7e8e106`) by wiring declaration emission into its pre-existing `Expression.use()` protocol. There is nothing to port: asmlift's declarations come from `collectSymbolRefs`, which derives the set from each candidate's FINAL tree at the point of consumption, and l3/symbol-refs.ts says in its header that there is deliberately no cached field for exactly this reason. A sweep across 11 tree shapes and all 121 real symbol-map rows found zero spurious declarations. But "by construction" is a claim about code that keeps being edited, and the collector has three arms of which only one was covered. Pinned here: the map now holds a symbol the function never touches, so the existing exact-equality assertion states the property directly; plus the two uncovered arms — a write-only scalar global (the `assign` arm, the one place the collector hand-rolls a name lookup rather than going through the shared expression vocabulary, and so the likeliest to be lost in a refactor) and `&gSym` as a call argument. Deleting either arm passed the entire suite before this. Recorded, not fixed: `collectStructs` is a SECOND declaration producer with exactly m2c's shape. It walks the L2 graph, is cached on the SFn and carried unpruned through every later L3 pass, while its sibling `locals` IS reference-pruned after dead-store elimination. No pass drops such a use today — IR-level DCE runs long before struct recognition, and l3/dce's `mustKeep` never drops a `field`/`index` — so the staleness is latent, and its doc comment now says so, along with the fix if a pass ever makes it reachable: derive at the consumption point, as symbol-refs already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… time m2c crashed on `ldr rX, [sp, rY]`: its stack-frame model assumed an sp-relative access always carries a literal addend, so a register-indexed one hit a fired `assert isinstance(addend, AsmLiteral)` (upstream `ef34aff`). There is nothing to port — asmlift refuses reading `sp` as DATA up front on every frontend, so the register-indexed form is not a case to know about but one more member of a class already refused. Probed every spelling on thumb, MIPS and PPC: all decline loud. What that guard is really defending is asmlift's OWN hazard, which is why these belong in this file: `sp` is never WRITTEN, so a frontend that let it be read as data would have Braun SSA materialize it as a fabricated PHANTOM PARAMETER — a function of the wrong arity returning the wrong argument. Pinned per frontend, because the class is only worth anything if it holds for every spelling: the register-indexed load and its store dual, the literal `[sp, #N]` slot m2c does handle, and `&local`. asmlift has no GENERAL stack-frame model but two deliberately narrow partial ones, and both are covered — MIPS models word `sp` SLOTS, so its contract is narrower (the model's own spill/reload shape keeps working, while sub-word access, the whole-function aliasing case its sub-word scan exists for, frame arithmetic and a never-stored slot all decline). PPC is the interesting one and was missing: it is asmlift's only frontend with a register-indexed addressing mode, which makes `lwzx rD,r1,rB` the exact structural analogue of m2c's case, and mwcc emits `lwzx` for every variable-index array access. It declines — but only because `addrX` routes both operands through `read`, not by a check of its own, so a refactor to `readVar` would silently reopen precisely that hole. Now pinned beside its sibling r1 guards. core/README is corrected in passing: it listed live spills as declined, which the MIPS word-slot model has always contradicted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The boolean-negation folds flip synthetic:clampu8:ido7.1 (`if (a0 < 256 == 0)` → `if (a0 >= 256)`, byte-exact). Four more rows change text without moving, all losing a double negative; every other row is byte-identical, and m2c's column is untouched. Also restores CLEAN provenance. The committed results have carried `dirty: true` since the m2c pin bump, which ran the benchmark from a tree that already held the pin edit — so apps/web/test/summary-consistency.test.ts has been red on main. Co-Authored-By: Claude Opus 5 (1M context) <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.
Works the three commits the m2c pin bump (#22) classified as nice-to-have. Each got an isolated commit, a benchmark measurement, and two adversarial review passes — one hunting for bugs and edge cases, one judging fit against
docs/level-tower.mdanddocs/asmlift-101.md.Headline: asmlift 360 → 361, m2c 342 unchanged (743 rows). Plus three silent miscompiles found and fixed, two of them pre-existing and unrelated to the ports.
40cbae3foldx ^ 1→!x7e8e106declare only referenced symbolsef34affdon't crash on[sp+reg]The port that paid
The bump doc said no row hinges on
x ^ 1, but that scan was ARM-only: seven MIPS rows carryxori …, 1, because MIPS has no set-on-greater-equal, so a materialiseda >= bis spelledslt; xori. Zero agbcc rows haveeor #1, so the ARM half of m2c's commit has no inhabitant here.Ported as an idea, not code — thirty data patterns in the idiom layer, covering the branch-form siblings m2c handles elsewhere:
These are the first ungated patterns in the bundle. The load-bearing argument isn't "semantic identity" (true, but that only preserves the IR) — it's that the shape is its own gate: the pattern can only fire where the compiler emitted the flip, and wherever it did, the negated comparison is what it was spelling. A compiler with set-on-greater-equal never produces the shape.
The zero-test half is what flipped a row.
synthetic:clampu8:ido7.1nonmatch → MATCH:if (a0 < 256 == 0)becameif (a0 >= 256). Itsslti/bnezpair sits in different blocks, so the frontend's own compare-branch peephole structurally cannot fuse it — that peephole is block-local and register-keyed, and the SSA-level idiom has neither limit. That's the layer choice validated by measurement rather than preference.Round-trip evidence:
synthetic:inrange:gcc2.7.2kmcstays byte-exact MATCH while its source goes from(a0 < a1 ^ 1) & (a2 < a0 ^ 1)toa0 >= a1 & a2 >= a0.Also consolidates three hand-written copies of the comparison-negation table into one
NEGATED_ICMP, with a test tying it to the opcode registry.Three silent miscompiles
MIPS stale compare-fold (found while measuring the port, on a live row). The
slt …; beqzfold kept its pending compare in a register-keyed map that nothing invalidated:It folded against the dead
sltand emitted the exactly inverted condition —inrange(5,1,0)returned 1 where the function returns 0, scorednonmatch 9/10, i.e. indistinguishable from an honest near-miss. Fixed by keying on the SSA value, which removes the class rather than the instance: invalidating on write would not have sufficed, sincelui rD,%hi(SYM)reassigns a register without going through the write path. Pinned with a corpus fixture.Thumb flags clobbered between a compare and its branch —
cmp r0,r1; add r2,r0,#1; beqemittedif (a0 != a1)where the hardware testsr0 + 1 == 0(on Thumb-1 nearly every low-register data-processing instruction sets flags,s-suffix or not). Now a loud decline. No corpus row exhibits it — but the corpus isn't the input domain, the playground takes hand-written asm where there is no oracle, and the remedy here is a decline, which guesses at nothing. Zero rows moved.Thumb push/pop transparency and the uppercase-
SPguard bypass — reproduced, not fixed here: pre-existing and outside these ports.push {r0}; pop {r1}silently loses the value, andldr r0, [SP, r1]bypasses every sp guard because the guard is a blocklist over an unvalidated register namespace. Top of the follow-up list.The two verifications
Both are "nothing to port", verified rather than assumed — and both first attempts were wrong, which review caught.
Declarations are reference-driven by construction:
collectSymbolRefsderives from each candidate's final tree at the point of consumption. A sweep across 11 tree shapes and all 121 real symbol-map rows found zero spurious declarations. My first test for it was vacuous (its dead load is reaped before structuring, so a cached-at-structure-time field would also be empty — it couldn't discriminate the designs it named). Replaced with the two collector arms nothing covered; deleting either arm passed the whole suite before this. Recorded but not fixed:collectStructsis a second declaration producer with exactly m2c's shape, latent today.sp-as-data is refused as a whole class, so the register-indexed form isn't a case to know about. The honest framing is that this is a capability gap, not parity — local stack frames are worth roughly a dozen matches, though only the sixth-largest decline class. PPC turned out to be the frontend that matters: it's asmlift's only one with a register-indexed addressing mode, making
lwzx rD,r1,rBthe exact structural analogue of m2c's case, and it declines only becauseaddrXroutes throughreadrather than by a check of its own. Now pinned.Notes
dirty: truesince the pin bump, sosummary-consistency.test.tshas been red onmain.popdoesn't replicate — those params come from high-register reads.🤖 Generated with Claude Code