Recover branching signed /2^k division and wider Thumb jump tables - #24
Merged
Conversation
Braun construction wires a phi by appending one arg to each predecessor's
terminator successor entry. Two halves of that were wrong whenever a predecessor
reaches a block by MORE THAN ONE edge:
• `appendSuccessorArg` used `successors.find(...)`, so it appended to the FIRST
matching edge only and left the others short;
• `preds` lists an entry per EDGE, so the loop ran k times for k edges and piled
k copies onto that same first edge.
Together they produced an arg list k× too long on one edge and empty on the rest —
`successor of 'switch_br' passes 18 args to a block with 6 params`. The verifier
catches it, so it is a loud decline rather than wrong output, but it makes the
shape unliftable. A `switch_br` reaches it routinely (two case values sharing a
body, `case 1: case 2:`, is ordinary C) and a `cond_br` whose two successors are
the same block reaches it too.
Fixed as one change because the halves have to agree: append to EVERY edge
targeting the block, and iterate DISTINCT predecessor blocks. The same distinction
fixes the join test above it — a block reached by several case values of one
switch has one predecessor supplying one value, not a join, so counting edges
manufactured a phi where none exists.
`preds` genuinely carries two readings and now says so in the module header and at
the parameter: an EDGE list for the args, distinct BLOCKS for the phis. (ir/core.ts
`predecessors` and structure.ts `predecessorBlocks` have the same duality;
structure.ts already dedups ad hoc at two join sites.)
Benchmark: zero rows move on its own — every liftable inhabitant is behind the
Thumb jump-table gaps in the next commit. Landing it separately keeps that
attribution honest. It is not dead code, though: revert this file on top of the
branch tip and two rows fail the verifier.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Porting m2c's `a7c5c2d` ("support label + offset in jtbl loads") turned out to be
the smallest of three gaps standing between asmlift and the six benchmark
functions that dispatch through a table. asmlift has had Thumb jump-table recovery
for a while; it recognised a spelling agbcc rarely emits.
POOL OFFSET (m2c's fix). The dispatch pointer is loaded like every other pool word
— `LABEL[+N]`, selecting word N/4 — because a literal pool is a POOL: agbcc packs
the table pointer in beside whatever else the function needed, and which slot it
lands in is an artifact of emission order:
ldr r1, .L21+0x4 ← .L21 = [gUnk_08078FC8, .L17]; the table is .L17
The recognizer accepted only a bare label whose pool held exactly ONE word. It now
reuses the shared POOL_LABEL grammar the const and gaddr resolvers already parse
operands with, so the three cannot disagree about what `.L21+0x4` addresses.
LONG-JUMP BOUNDS (not in m2c's commit; found while porting). The guard has two
spellings and the recognizer knew the near one:
direct cmp rX,#M ; bhi DEF → fall through to the dispatch
long jump cmp rX,#M ; bls DISP ; b DEF → branch TO it, long-branch the default
agbcc emits the second whenever the default is out of a conditional branch's reach
(Thumb-1 `B<cond>` carries a signed 8-bit halfword offset — ±256 bytes, about 128
instructions), which for a real switch it usually is: five of the six functions use
it, one uses the direct form. The dispatch may now be a branch target when named
exactly once, by the `bls` in its own bounds block, and the trailing lone `b DEF`
block supplies the default and is elided with it — that block is checked to be
unnamed by any branch, because leaving it in would make it a parameterless
predecessor of the default block, where wiring a phi fabricates an entry parameter.
ADJACENT LABELS. agbcc drops a long-jump helper label straight onto an existing one
(`.LCB80:` then `.L7:` on one instruction). Decode started a block at each, the
empty one was filtered out, and a branch naming it then resolved to nothing. Those
branches are now pointed at the block the label actually names — except that a
label naming DATA is emphatically not an alias, and that guard is the whole point:
every agbcc literal pool is a label on data, so aliasing blindly would silently
retarget `beq .Lpool` at whatever code followed the pool. Marker-free, plausible,
wrong. A data label neither aliases nor is aliased through.
Benchmark: declined 205 → 202. kleod:TransformSingleEntityToScreen and
kleod:CheckWorldCompletion move declined → scored; sa3:sub_802DFC8 moves declined →
NONCOMPILE, which is worth stating plainly. It now lifts and emits, and the
emission fails on `too many arguments to function 'VramMalloc'` — the call-arity
heuristic over-counting a callee with no prototype. That is loud HERE because the
benchmark supplies the project headers; in the CLI or playground with no
prototypes, C89 implicit declarations would accept the extra argument and compile.
So it is a genuine step from "asmlift knows it cannot" toward silence, and the fix
belongs in call-arity recovery.
The other five functions recover their switch and then decline further downstream
on gaps that have nothing to do with jump tables — union/overlapping struct fields,
a `sym+0x48` pool word, local stack frames. Their outcomes do not change; their
decline reasons become specific and attributable, which is the payoff this project
optimizes for.
Adds packages/core/test/thumb-switch.test.ts — there was no Thumb jump-table
fixture anywhere. It pins both bounds spellings, the pool addressing, the
shared-case-body arity invariant the previous commit enables, alias equivalence
(asserted against the un-aliased control, not a spelling), and six near-miss
dispatches that must decline. A mis-recovered table is not a wrong expression but a
wrong BLOCK — the output looks ordinary and runs the wrong code — so the refusals
are the point of the file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`x / 2^k` truncates toward zero but an arithmetic shift rounds toward minus infinity, so a compiler biases a negative dividend by `2^k - 1` first. asmlift already folded the branchless spelling (the SDIV_POW2_2 idiom pattern). This recovers the other one — a BRANCH, which is what IDO emits and what GCC emits for larger k: bgez a0, .L2 addiu at, a0, 1 .L2: sra v0, at, 1 A CFG diamond is not something the patterns-as-data layer can state — its match DAG is over the def-graph inside one block — so this earns a pre-recovery pass, for the same reason raise/shortcircuit.ts is one. That is also why it is not a port of m2c's `49b5d87` in form, only in intent: that commit adds a third and fourth INSTRUCTION-WINDOW pattern, and its own message notes GCC reorders the final `sra` and defeats the window. Matching the value graph is immune to that — verified: the fold still fires when the sunk shift sits in a later block. Both merge spellings are recovered: the shift SUNK after the join, and SPLIT into both arms — the latter is what a filled delay slot produces. The identity is arithmetic, but by this project's taxonomy that alone is not a licence to run ungated: the compiler-pinned patterns are gated precisely because they trade a spelling for another and are byte-safe only where measured, and this pass does trade a spelling. What carries it is raise/magicdiv.ts's argument, unchanged — the round-trip is SELF-VERIFYING. asmlift emits a plain `x / 2^k`, the target compiler regenerates its own lowering, and a wrong divisor recompiles to different bytes and shows up as a nonmatch, never a false match. Measured positive on ido7.1, agbcc and gcc2.7.2kmc; mwcc_242_81 and gcc2.7.2 have no inhabitant, so they are unmeasured rather than clean. Benchmark: **synthetic:div2:ido7.1 and synthetic:avg2:ido7.1 both flip nonmatch → MATCH; asmlift 361 → 363.** Both were rows where m2c matched and asmlift did not. synthetic:modpow2 (agbcc and gcc2.7.2kmc) changes text and STAYS matched — `a0 - (a0 / 16 << 4)` — which is the byte evidence that re-emitting `/ 2^k` reproduces the sequence on two further compilers. A real GBA function is an inhabitant already (kleod:TransformSingleEntityToScreen folds a k=8 diamond; it declines for unrelated reasons) and a second real one waits behind the `bltzl` branch-likely frontend gap (snowboardkids2:func_8005DF10_5EB10, ~10 instances). The pass deletes a block and retires a phi, so its safety story is its refusals, and divpow2.test.ts pins one per guard. Two of them are silent-miscompile classes found by adversarial review of the first draft: a merge phi that also escapes as a BLOCK ARGUMENT is a second consumer (args live in `successors[].args`, not `operands` — counting only operands folded `(y >> 2) + y` into `(x / 4) + (x / 4)`), and neither end of the diamond may be the ENTRY block (an entry that is also a loop header shows two predecessors while really being a three-way join; retiring a param there deletes one of the function's own arguments, and verify passes). Erratum for the bump review, which parked this lever as having no inhabitants: that scan asked whether `49b5d87`'s two new windows had inhabitants, and correctly answered no — neither flipped row matches those windows; both are shapes m2c has recognised since 2022. The LEVER had inhabitants all along. The review's own recommendation was to match the value DAG rather than instruction windows, which is what this is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The remaining two "worth porting" entries are both DISCARDS, verified rather than assumed, and the reasons are structural — so this pins the reasons rather than porting the guards. `7e607d6` (avoid negative struct/array indexing) has two halves. The literal-index half: m2c's index arrives as a BYTE offset it must divide by the element size, so a literal there is genuinely ambiguous — `p + 0x10` could be `p[4]` or a struct field and guessing wrong changes the address. raise/arrays.ts never guesses; it matches only when the shift amount and the access width AGREE (`1 << k === width`), which is what makes the shifted operand an index by construction, and is the relation docs/level-tower.md cites as what earned the pass. A constant index is then exactly as well-defined as a variable one. The negative half: a negative offset stays exact pointer arithmetic — `lw v0,-8(a0)` emits `a0[-2]`, and two accesses at -8 and +4 through one base do not synthesize a struct spanning them. Probed before writing: the negative literal offset, the folded `addiu a0,a0,-8` form, a negative residual on a strided access, and a constant index — all four emit exact, correct C today. `8ce8da5` (non-word loads when finding the subroutine-arg region) has nothing to port and nothing to pin: asmlift has NO outgoing-arg-region inference to blind. Call arity comes from the project's prototypes, falling back to argument-register liveness; stack-passed arguments beyond the register args are explicitly not modelled and decline. A test there would assert the absence of a subsystem. Test-only: no src change, so the benchmark cannot move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branching pow2-division recovery flips synthetic:div2:ido7.1 and synthetic:avg2:ido7.1 to MATCH, both rows where m2c matched and asmlift did not. The Thumb jump-table work moves declined 205 → 202: two rows become scored, one becomes noncompile (the call-arity gap noted in that commit). Seven further rows change text without moving — five are jump-table declines becoming specific and attributable, two are modpow2 keeping its byte-exact match through a respelling. 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 four commits the m2c pin bump (#22) classified as worth porting, following #23 which did the nice-to-haves. Each entry 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.asmlift 361 → 363 vs m2c 342 (743 rows). Declined 205 → 202.
a7c5c2dlabel+offset in jtbl loads7e607d6avoid negative struct/array indexing8ce8da5non-word loads in the arg region49b5d87more division-by-2^k patternsJump tables: the port was the smallest of three gaps
The bump doc said asmlift has no jump-table support. It does — it recognised a spelling agbcc rarely emits. m2c's fix was one of three things standing between us and the six benchmark functions that dispatch through a table:
LABEL[+N]; the recognizer accepted only a bare label whose pool held exactly one word. A literal pool is a pool — which slot the table pointer lands in is an artifact of emission order.cmp rX,#M ; bls DISP ; b DEFalongsidecmp rX,#M ; bhi DEF. agbcc emits this whenever the default is out of a conditional branch's ±256-byte reach, which for a real switch it usually is: five of the six functions use it, one uses the direct form. So the recognizer covered the rarer spelling.Plus a prerequisite
fix(ssa): block args belong to the edge, not the block. Aswitch_brsending two case values to one body (case 1: case 2:, ordinary C) piled k copies of each arg onto the first edge and left the rest empty. Landed separately because it moves zero rows on its own — but it is not dead code: revert that file on top of the tip and two rows fail the verifier.declined 205 → 202. Two rows become scored;
sa3:sub_802DFC8becomes noncompile, which is worth stating plainly — it now lifts and emits, and the emission trips the call-arity heuristic over-counting a callee with no prototype. That is loud here because the benchmark supplies project headers; in the playground with no prototypes, C89 implicit declarations would accept the extra argument and compile. The fix belongs in call-arity recovery. The other five functions recover their switch and decline further downstream on gaps unrelated to jump tables (unions, asym+0x48pool word, stack frames) — outcomes unchanged, decline reasons now specific and attributable.Division by 2^k: ported as intent, and it's the win
x / 2^ktruncates toward zero but an arithmetic shift rounds toward minus infinity, so a compiler biases a negative dividend by2^k - 1. asmlift already folded the branchless spelling; this recovers the branch, which is what IDO emits and what GCC emits for larger k.Deliberately not m2c's form.
49b5d87adds a third and fourth instruction-window pattern, and its own message notes GCC reorders the finalsraand defeats the window. This matches the value/CFG graph instead, which is immune to that — verified: the fold still fires when the sunk shift sits in a later block. A CFG diamond is unstateable in the patterns-as-data layer (its match DAG is over the def-graph inside one block), so it earns a pre-recovery pass, asraise/shortcircuit.tsdoes.synthetic:div2:ido7.1andsynthetic:avg2:ido7.1both flip nonmatch → MATCH — both rows where m2c matched and asmlift did not.synthetic:modpow2(agbcc and gcc2.7.2kmc) changes text and stays matched, which is round-trip byte evidence on two further compilers. A real GBA function already folds a k=8 diamond (it declines for unrelated reasons), and a second waits behind thebltzlfrontend gap.The pass is ungated on
magicdiv's self-verifying-round-trip argument, not on "the identity is arithmetic" — by this project's taxonomy that alone isn't a licence, since the pass does trade one spelling for another.The two discards
7e607d6— both halves are structural non-issues. m2c's index arrives as a byte offset it must divide by the element size, so a literal there is genuinely ambiguous;raise/arrays.tsmatches only when1 << k === width, which makes the shifted operand an index by construction. A constant index is then as well-defined as a variable one. And a negative offset stays exact pointer arithmetic (a0[-2]), with no struct synthesized across it. Probed four shapes before writing; the reasons are pinned as tests.8ce8da5— nothing to port and nothing to test: asmlift has no outgoing-arg-region inference to blind. Call arity comes from prototypes or argument-register liveness, and stack-passed args decline. A test would assert the absence of a subsystem.What review changed
Two commits shipped silent miscompiles, both caught and fixed:
beq .Lpoolsilently retargeted past the pool. Every agbcc literal pool is a label on data, so this was the common case.operandsas uses, missing block arguments — a merge value escaping along an edge folded(y >> 2) + yinto(x / 4) + (x / 4). A second reviewer hit the same bug independently from real MIPS asm, and also caught that the merge may be the entry block, where retiring a param silently deletes one of the function's own arguments whileverify()passes.Also backed out an over-correction: an
n < 2fail-closed guard broke a legal 1-case dispatch that demonstrably lifted correctly.Two errata, both mine: the bump doc's "zero inhabitants" for the pow2 lever was correctly scoped to m2c's new windows — the lever had inhabitants all along, and my first erratum mis-stated why. And "declined → noncompile is loud" was too strong, as described above.
Notes
thumb-switch.test.ts(there was no Thumb jump-table fixture anywhere) anddivpow2.test.ts. Both are refusal batteries: a mis-recovered table is not a wrong expression but a wrong block, and the division pass deletes a block and retires a phi, so in both cases the negative cases are the point.results.json.🤖 Generated with Claude Code