Skip to content

Port m2c's three nice-to-have commits (+1 match, three silent miscompiles fixed) - #23

Merged
macabeus merged 6 commits into
mainfrom
port/m2c-nice-to-haves
Aug 5, 2026
Merged

Port m2c's three nice-to-have commits (+1 match, three silent miscompiles fixed)#23
macabeus merged 6 commits into
mainfrom
port/m2c-nice-to-haves

Conversation

@macabeus

@macabeus macabeus commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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.md and docs/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.

m2c commit outcome
40cbae3 fold x ^ 1!x ported and extended → +1 match
7e8e106 declare only referenced symbols nothing to port (verified) — regression tests
ef34aff don't crash on [sp+reg] nothing to port (verified) — regression tests

The port that paid

The bump doc said no row hinges on x ^ 1, but that scan was ARM-only: seven MIPS rows carry xori …, 1, because MIPS has no set-on-greater-equal, so a materialised a >= b is spelled slt; xori. Zero agbcc rows have eor #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:

xor(cmp, 1)     → !cmp
icmp_eq(cmp, 0) → !cmp
icmp_ne(cmp, 0) →  cmp

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.1 nonmatch → MATCH: if (a0 < 256 == 0) became if (a0 >= 256). Its slti/bnez pair 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.2kmc stays byte-exact MATCH while its source goes from (a0 < a1 ^ 1) & (a2 < a0 ^ 1) to a0 >= 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 …; beqz fold kept its pending compare in a register-keyed map that nothing invalidated:

slt   v0,a0,a1
xori  v0,v0,0x1     ← v0 redefined: it now holds `a0 >= a1`
beqz  v0,.L

It folded against the dead slt and emitted the exactly inverted condition — inrange(5,1,0) returned 1 where the function returns 0, scored nonmatch 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, since lui 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 branchcmp r0,r1; add r2,r0,#1; beq emitted if (a0 != a1) where the hardware tests r0 + 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-SP guard bypass — reproduced, not fixed here: pre-existing and outside these ports. push {r0}; pop {r1} silently loses the value, and ldr 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: collectSymbolRefs derives 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: collectStructs is 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,rB the exact structural analogue of m2c's case, and it declines only because addrX routes through read rather than by a check of its own. Now pinned.

Notes

  • Suite green: 930 tests. This also restores clean benchmark provenance — the published results have carried dirty: true since the pin bump, so summary-consistency.test.ts has been red on main.
  • One agent claim I checked and did not carry forward: inflated arity on two real rows attributed to the dropped pop doesn't replicate — those params come from high-register reads.

🤖 Generated with Claude Code

macabeus and others added 6 commits August 5, 2026 15:14
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>
@macabeus
macabeus merged commit dcf947e into main Aug 5, 2026
2 checks passed
@macabeus
macabeus deleted the port/m2c-nice-to-haves branch August 5, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant