@asmlift/core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +22 -16
  2. package/package.json +1 -1
  3. package/src/backend/c.ts +1 -0
  4. package/src/backend/cfamily.ts +270 -171
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +243 -39
  8. package/src/declare.ts +41 -4
  9. package/src/frontend/mips.ts +11 -0
  10. package/src/frontend/ppc.ts +43 -7
  11. package/src/frontend/ssa.ts +404 -29
  12. package/src/frontend/thumb.ts +2176 -686
  13. package/src/ir/alias.ts +78 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +345 -2
  16. package/src/ir/opcodes.ts +176 -21
  17. package/src/ir/parse.ts +19 -2
  18. package/src/ir/print.ts +27 -2
  19. package/src/ir/simplify.ts +190 -3
  20. package/src/ir/struct-names.ts +42 -0
  21. package/src/ir/verify.ts +43 -49
  22. package/src/l3/address.ts +62 -0
  23. package/src/l3/advance.ts +373 -0
  24. package/src/l3/argbase.ts +6 -5
  25. package/src/l3/ast.ts +510 -59
  26. package/src/l3/basecse.ts +686 -78
  27. package/src/l3/coalesce.ts +432 -46
  28. package/src/l3/dce.ts +31 -9
  29. package/src/l3/gates.ts +96 -1
  30. package/src/l3/hoist.ts +293 -14
  31. package/src/l3/homesplit.ts +285 -0
  32. package/src/l3/initfirst.ts +301 -0
  33. package/src/l3/inlinebase.ts +193 -0
  34. package/src/l3/mentions.ts +176 -0
  35. package/src/l3/mulfirst.ts +42 -0
  36. package/src/l3/nearbase.ts +152 -0
  37. package/src/l3/offmember.ts +371 -0
  38. package/src/l3/parkfirst.ts +96 -0
  39. package/src/l3/pollguard.ts +154 -0
  40. package/src/l3/ptrfield.ts +227 -0
  41. package/src/l3/regspell.ts +114 -89
  42. package/src/l3/reindex.ts +722 -80
  43. package/src/l3/scopebase.ts +649 -220
  44. package/src/l3/sinkinit.ts +40 -0
  45. package/src/l3/slotorder.ts +123 -0
  46. package/src/l3/storage.ts +48 -0
  47. package/src/l3/symbol-refs.ts +41 -8
  48. package/src/l3/tailmerge.ts +16 -1
  49. package/src/l3/typing.ts +198 -9
  50. package/src/l3/unmerge.ts +687 -0
  51. package/src/l3/unreduce.ts +971 -0
  52. package/src/l3/volatileptr.ts +207 -0
  53. package/src/l3/volatileval.ts +130 -0
  54. package/src/l3/volstore.ts +229 -0
  55. package/src/l3/zerosub.ts +62 -0
  56. package/src/pattern/engine.ts +239 -16
  57. package/src/pipeline.ts +173 -60
  58. package/src/proto.ts +112 -14
  59. package/src/raise/arrays.ts +6 -1
  60. package/src/raise/const.ts +203 -3
  61. package/src/raise/divpow2.ts +4 -4
  62. package/src/raise/extscale.ts +342 -0
  63. package/src/raise/globalshape.ts +1058 -0
  64. package/src/raise/gvn.ts +33 -18
  65. package/src/raise/latch.ts +126 -0
  66. package/src/raise/magicdiv.ts +2 -2
  67. package/src/raise/memberarrays.ts +594 -0
  68. package/src/raise/narrow.ts +124 -0
  69. package/src/raise/narrowlocal.ts +572 -0
  70. package/src/raise/paramwidth.ts +201 -0
  71. package/src/raise/pre-recovery.ts +169 -21
  72. package/src/raise/recover.ts +56 -23
  73. package/src/raise/retsink.ts +585 -19
  74. package/src/raise/shortcircuit.ts +1050 -89
  75. package/src/raise/struct-arrays.ts +19 -2
  76. package/src/raise/structs.ts +34 -4
  77. package/src/raise/tailsink.ts +126 -0
  78. package/src/rank-declare.ts +256 -0
  79. package/src/rank-variations.ts +760 -0
  80. package/src/rank.ts +2122 -326
  81. package/src/structure/analysis.ts +1398 -150
  82. package/src/structure/bitfields.ts +432 -0
  83. package/src/structure/globalaccess.ts +300 -0
  84. package/src/structure/hazards.ts +411 -20
  85. package/src/structure/loops.ts +2 -49
  86. package/src/structure/namecoalesce.ts +454 -0
  87. package/src/structure/structure.ts +3979 -612
  88. package/src/structure/switch-recover.ts +710 -145
  89. package/src/symbols.ts +188 -6
  90. package/src/target.ts +495 -32
  91. package/src/trace.ts +112 -33
  92. package/src/variation-definitions.ts +1540 -0
  93. package/src/variation-gates.ts +89 -0
  94. package/src/variation-tokens.ts +355 -0
@@ -0,0 +1,572 @@
1
+ // A NARROW DECLARED LOCAL, extended once at every read.
2
+ //
3
+ // agbcc has no sub-word register, so an `s16`/`u8` LOCAL lives in a full register and every read of
4
+ // it re-extends. Where the local is a loop's counter that extension is not cosmetic, because it
5
+ // decides WHICH LOOP the compiler emits:
6
+ //
7
+ // `gcc/thumb.h:344` PROMOTE_MODE forces `UNSIGNEDP = 1` on every sub-word mode, so a narrow
8
+ // counter's write-back is an LSHIFTRT; `gcc/loop.c` `basic_induction_var` follows SIGN_EXTEND
9
+ // (`:5876`) and ASHIFTRT (`:5880`) and lets LSHIFTRT fall through to `default: return 0`
10
+ // (`:5902`) — the comment at `:5756-5762` says the ZERO_EXTEND exclusion is deliberate. With no
11
+ // basic induction variable there is no strength reduction, so the narrow counter SURVIVES into
12
+ // the emitted loop as a real index where a wide one becomes a pointer walk. Compiled both ways
13
+ // with this benchmark's own agbcc, same declared `s32` local, same range, same body, only the
14
+ // write-back's RTL shape differing:
15
+ //
16
+ // i = ((i + 1) << 16) >> 16; ASHIFTRT add r1,r1,#0x2 / add r2,r2,#0x2 index ELIMINATED
17
+ // i = (s32)(u16)(i + 1); LSHIFTRT lsl r0,r2,#0x1 / add r1,r0,r4 index SURVIVES
18
+ //
19
+ // So the width of a carrier is a fact about the emitted code, and it is recovered here rather than
20
+ // enumerated for the same reason `paramwidth` recovers its own: an extension states a width and a
21
+ // signedness outright. What it does NOT always state is that the source DECLARED them — see
22
+ // `edge-extends` below, where two spellings survive and this file picks one.
23
+ //
24
+ // SOUNDNESS HAS TWO HALVES, AND THE CARRIER'S OWN READERS ARE ONLY THE FIRST. Typing the carrier
25
+ // narrow makes the C TRUNCATE at every incoming edge — `s16 v` assigned a wide expression keeps its
26
+ // low 16 bits.
27
+ //
28
+ // HALF ONE, THE CARRIER. The truncation is unobservable through the carrier exactly when every
29
+ // reader of the carrier already reads only those bits, i.e. when the carrier's one and only
30
+ // reader is an extension of that width: `ext(trunc_w(x), w) == ext(x, w)` for every `x`. A second
31
+ // reader of the raw carrier, or the carrier forwarded on a branch into another block's parameter,
32
+ // would observe the bits the declaration drops — both refuse.
33
+ //
34
+ // HALF TWO, THE INCOMING ARGUMENTS, and it is NOT implied by half one. The C names the edge
35
+ // values with the carrier's variable: `structure.ts`'s `backArgName` hands a loop header's name
36
+ // to the back-edge argument, so every OTHER reader of that argument reads it through the narrow
37
+ // declaration too. Half one says nothing about them. The counterexample is a 9-instruction loop
38
+ // whose header carrier has exactly one reader (its own `sext16`) and whose back-edge value
39
+ // `adds r1, r2, #1` is UNTRUNCATED and also read by the `cmp` at 32 bits: narrowed, the recovered
40
+ // C is `s16 v; do { *a0 = v; v = v + 1; } while (v < 32768);`, and this benchmark's own agbcc
41
+ // compiles it to `b .L3` with `warning: comparison is always true due to limited range of data
42
+ // type` — an infinite loop out of assembly that terminates. So `edge-reader` requires every value
43
+ // arriving on an in-edge to be observed NOWHERE except through an extension of exactly this
44
+ // carrier's width and signedness, which is the same truncation the declaration performs.
45
+ //
46
+ // AND ONE HALF THAT IS EVIDENCE, NOT SOUNDNESS — `edge-extends`, WHERE THE TRUNCATION IS. Both
47
+ // spellings compute the same numbers, so nothing here is a correctness argument; what decides it is
48
+ // which shape agbcc leaves in the asm. PROMOTE_MODE's write-back truncation is a `zext`, and where
49
+ // it LANDS is the whole rule. In a loop it lands on the back edge, so the carrier's in-edge value
50
+ // is itself an extension. Across a plain merge gcc SINKS the common truncation past the join, where
51
+ // it stops being an in-edge fact and becomes the carrier's own reader — so an in-edge test alone
52
+ // reads a real narrow local as a cast. All four spellings compiled with this benchmark's own agbcc,
53
+ // each round-tripped through decompile() and scored against its own object:
54
+ //
55
+ // SOURCE CARRIER'S READER REFUSED ADMITTED
56
+ // s16 v; … *out = v; zext16 -> sext16 6 0 MATCH
57
+ // s32 v; … *out = (s16)v; sext16 0 MATCH 6
58
+ // u16 v; … *out = v; zext16 4 0 MATCH
59
+ // s32 v; … *out = (u16)v; zext16 0 MATCH 4
60
+ //
61
+ // Rows one and two are DECIDABLE by the carrier's own readers: a `zext_w` read by a `sext_w` is the
62
+ // write-back truncation followed by the declaration's own sign extension, and no cast on a wide
63
+ // local writes that pair. Rows three and four are the same IR in this pass's READER vocabulary —
64
+ // same single extension, same raw in-edges, opposite answers — so nothing the carrier reads decides
65
+ // them.
66
+ //
67
+ // THE BRANCH SHAPE IS EVIDENCE THIS PASS CAN READ, and it decides rows three and four. Compiled
68
+ // with this benchmark's own agbcc, `u16 v` leaves a DIAMOND of 14 instructions and `s32 v` +
69
+ // `(u16)v` a HOISTED join of 12:
70
+ //
71
+ // u16 v; if (c) v = a+b; else v = a-b; *out = v; cmp / beq / adds / b / lsl+lsr / str
72
+ // s32 v; if (c) v = a+b; else v = a-b; *out = (u16)v; subs / cmp / beq / adds / lsl+lsr / str
73
+ // ^^^^ the else arm ABOVE the compare
74
+ //
75
+ // `gcc/jump.c:443-445` rewrites `if (...) x = a; else x = b;` to `x = b; if (...) x = a;`, and
76
+ // `gcc/thumb.h:344` PROMOTE_MODE expands a narrow-DECLARED assignment into the arithmetic PLUS its
77
+ // `ashift`/`lshiftrt` truncation pair — five insns in the `.jump` dump, past the ONE SET that
78
+ // transform's guard wants. So a join gcc could have hoisted and did not is positive evidence FOR a
79
+ // declaration. `mergeArms` and `armIsOneSet` hold the guard, conjunct by conjunct. The shape is
80
+ // REQUIRED rather than `hoistedJoin` REFUSED, because the negation of a hoist is not evidence of a
81
+ // declaration: phrased that way the rule would newly admit every one-predecessor, three-armed and
82
+ // irreducible join on nothing at all.
83
+ //
84
+ // AND WHAT IT READS IS THE FINAL ASM, WHERE `jump_optimize` READ THE RTL — the one gap in the
85
+ // mechanism, and it runs BOTH ways. `if (c) v = a << b; else v = a >> b;` leaves arms of TWO insns
86
+ // (`adds` / `asrs`) and gcc hoisted anyway, reload having added the copies afterwards — an
87
+ // over-refusal, which is free. The other way, `s32 v; if (c) v = (u16)a; else v = (u16)b;
88
+ // out[0] = (u8)v;` is refused the hoist (its arms were `lsl`/`lsr` at jump time) and then has its
89
+ // common `lsr` SUNK past the join, leaving one-insn arms `armIsOneSet` calls hoistable. No fixture
90
+ // can close that: the mutation is the compiler's. Six authored cast spellings were aimed at the
91
+ // hole and all six hoisted, so it costs no measured row — but widening the arm test widens into
92
+ // it.
93
+ //
94
+ // ITS PRICE IS MEASURED OVER THE SET IT REFUSES, never over the set it admits — a gate priced on
95
+ // its own accepts cannot show a cost. Over 2288 per-function sa3 sources (1742 lift, 546 decline at
96
+ // the frontend) the table is IDENTICAL with the join conjunct and without it:
97
+ //
98
+ // entry-param 1228 · reader-is-extension 2114 · param-typed 33 · raw-reader 13 · forwarded 7 ·
99
+ // edge-reader 28 · edge-extends 40 (zext 30 / sext 10) · ACCEPT 60 (sext 51 / zext 9)
100
+ // flipped carriers: 0 of the 40 refusals: diamond 28, diamond AND arms hoistable 0
101
+ //
102
+ // Over the benchmark's base rows, decompiled twice — the conjunct as shipped and cleared — exactly
103
+ // ONE row's emitted SOURCE BYTES change (`synthetic:mergeu16:agbcc`; gapCountChanged 0 over all of
104
+ // them). Measured over the 930 rows the artifact carried at the time. So over every corpus anything
105
+ // here has measured, this rule's only inhabitant is the row that motivated it, and the `merge*`
106
+ // rows in apps/benchmark/dataset/synthetic.ts are its whole score: four cells of the 2x2, plus
107
+ // `mergeldcast` (both arms one load) and `mergepool` (an arm whose immediate needs a pool load),
108
+ // which cost 6 and 1 under an arm test that read only the op count. sa3 can add nothing — every one
109
+ // of those functions references external symbols and its `ctx.c` is per translation unit, so no
110
+ // per-function score exists there.
111
+ //
112
+ // THE ADMIT SIDE'S ORACLE IS narrowlocal-fuzz.test.ts, whose generator appends this join shape to
113
+ // half its functions for this clause alone — 7421 acyclic and 7522 loop-bearing carriers admitted,
114
+ // every one structured, interpreted and compared against the un-narrowed spelling. A widening that
115
+ // changes what a function COMPUTES shows up there; one that only changes the SPELLING shows up in
116
+ // the rows.
117
+ //
118
+ // NO LOOP GATE, deliberately, AND THAT SENTENCE IS ABOUT THE SOUND RULES ONLY. The extension states
119
+ // the width whether or not the block is a loop header — the loop is where the width is worth
120
+ // something, not where it becomes true. The join clause gets no such licence: a rotated loop header
121
+ // IS a two-armed merge and `gcc/jump.c` never considers a back edge. It is refused by `mergeArms`'
122
+ // head test rather than by a loop predicate, because a per-FUNCTION loop question cannot decide a
123
+ // per-SITE one.
124
+ //
125
+ // TARGET-GATED IN ONE CONJUNCT AND NOWHERE ELSE, and the split is the point. The sound rules above
126
+ // are claims about C and hold for every compiler; "gcc 2.x's `jump_optimize` would have collapsed
127
+ // this diamond and did not" names one compiler's optimizer, and reads that compiler fact BACKWARDS.
128
+ // `docs/level-tower.md` legislates the case: such a fact belongs in
129
+ // `TargetDescription.compilerBehaviors`, and owes an explicit refusal from every pass that moves
130
+ // the thing it reads. `NarrowLocalOptions.hoistsSingleSetArm` carries it (absent ⇒ the clause never
131
+ // admits), and `runPreRecovery` reads the shape ahead of the pass that manufactures it —
132
+ // `mergeShapes`.
133
+ import { type Block, type Fn, type Op, type Value, defOpMap, predecessors, replaceAllUsesWith } from '../ir/core';
134
+ import { CAST_WIDTHS, REEVAL_UNSAFE_OPS } from '../ir/opcodes';
135
+ import { T } from '../ir/types';
136
+ import { type Gate, firstRejection } from '../l3/gates';
137
+
138
+ /** THE ONE PER-COMPILER FACT THIS PASS READS, threaded rather than defaulted — see the header for
139
+ * why this conjunct and no other is a target's claim. ABSENT ⇒ the clause never admits, which is
140
+ * the behaviour this pass had before it existed. */
141
+ export interface NarrowLocalOptions {
142
+ /** the compiler collapses `if (…) x = a; else x = b;` into `x = b; if (…) x = a;` when both arms
143
+ * are one speculatable SET (`gcc/jump.c:471-502`). Absent ⇒ false. */
144
+ hoistsSingleSetArm?: boolean;
145
+ }
146
+
147
+ /** What the gates below judge: one block parameter and the extension that reads it. */
148
+ export interface NarrowLocalCandidate {
149
+ /** the block parameter the extension reads */
150
+ param: Value;
151
+ /** the extension's `width` attribute, or 0 when the sole reader is not an extension */
152
+ width: number;
153
+ /** the parameter belongs to the ENTRY block */
154
+ isEntryParam: boolean;
155
+ /** reads of the RAW parameter by op OPERANDS anywhere in the function. Branch arguments are NOT
156
+ * counted here — they are `forwarded`'s — which is where this differs from the identically
157
+ * named field in raise/paramwidth.ts, whose `useCount` sums both. */
158
+ operandReads: number;
159
+ /** the sole reader is a `sext`/`zext` */
160
+ readerIsExtension: boolean;
161
+ /** occurrences of the RAW parameter as a branch argument */
162
+ forwarded: number;
163
+ /** every value arriving on an in-edge is observed only through this carrier's own truncation */
164
+ edgeArgsObservedNarrow: boolean;
165
+ /** every value arriving on an in-edge is itself an extension of at most this width, or a constant */
166
+ edgeArgsExtend: boolean;
167
+ /** the sole reader is a `zext {w}` whose own sole reader is a `sext {w}` — PROMOTE_MODE's
168
+ * write-back truncation sunk past a join, then the declaration's own sign extension */
169
+ writeBackTruncation: boolean;
170
+ /** the carrier's block is a two-armed merge (`mergeArms`). READ BY NO GATE — `armsHoistable`
171
+ * implies it — and carried only so a census can tell two different refusals apart: no diamond
172
+ * at all, against a diamond over arms gcc could not have collapsed. */
173
+ mergeDiamond: boolean;
174
+ /** …and the arms of that merge are ones that guard could have collapsed. The guard is about the
175
+ * ARM, so a diamond over arms gcc could not have collapsed survives whatever the local's width
176
+ * and carries no information at all — see `armIsOneSet`, whose refusals are two benchmark rows.
177
+ * IMPLIES `mergeDiamond`: there are no arms to judge without one. */
178
+ armsHoistable: boolean;
179
+ /** the TARGET's claim, kept apart from the IR facts above because it is not one. `armsHoistable`
180
+ * says what the arms are; this says whether this compiler's optimizer would have acted on that.
181
+ * Fused into one field the name lies and every census number over it is target-conditioned. */
182
+ targetHoistsSingleSetArm: boolean;
183
+ }
184
+
185
+ export const NARROW_LOCAL_GATES: readonly Gate<NarrowLocalCandidate>[] = [
186
+ {
187
+ // SOUND, and its safety lives in ANOTHER table — which the `sound` flag has no word for, so it
188
+ // is spelled out here. Dropping this rule does not re-decide an entry parameter, it takes the
189
+ // decision from raise/paramwidth.ts: this pass runs first and deletes the extension, leaving
190
+ // `proto-width` and `not-prologue` nothing to judge. Measured on the shape they exist for — a
191
+ // prologue `sext16` under a caller prototype declaring `u8` — paramwidth narrows 0, this pass
192
+ // narrows 0, this pass WITHOUT this gate narrows 1 to `s16` and paramwidth then sees nothing.
193
+ // A wrong parameter width costs bytes at every prototyped call site, which no per-function
194
+ // differ sees.
195
+ id: 'entry-param',
196
+ why: "a function's own arguments are the prologue pass's territory",
197
+ sound: true,
198
+ guardedBy: 'narrow-local.test.ts: an entry parameter is left to raise/paramwidth.ts',
199
+ rejects: (c) => c.isEntryParam,
200
+ },
201
+ {
202
+ id: 'param-typed',
203
+ why: 'the pointer/aggregate recovery already decided this parameter',
204
+ sound: true,
205
+ guardedBy: 'narrow-local.test.ts: a parameter the pointer recovery already typed is left alone',
206
+ rejects: (c) => c.param.type.kind !== 'unknown',
207
+ },
208
+ {
209
+ // ORDERED ABOVE `cast-width`, which is where the attribution lives: over 2288 sa3 functions
210
+ // this rule refuses 2114 carriers and `cast-width` refuses none, and below it every one of
211
+ // those 2114 read as "a width no C type spells".
212
+ //
213
+ // THIS RULE AND `cast-width` ARE ONE SOUNDNESS ARGUMENT IN TWO ENTRIES, and neither is
214
+ // ablatable alone: a non-extension reader gives `width = 0`, which the other refuses, and no
215
+ // producer in the tree emits an extension at a width outside `CAST_WIDTHS` (the pattern engine
216
+ // and frontend/ppc.ts write 8 and 16; raise/narrow.ts re-writes a width already gated on that
217
+ // set), so `cast-width` fires 0 times in this order. Drop BOTH and the pass types a carrier
218
+ // `u0` and deletes the op that read it — which is why the joint ablation is the guard both
219
+ // name, and why neither may rest on an ablation of its own.
220
+ id: 'reader-is-extension',
221
+ why: 'a carrier whose sole reader is not an extension states no width at all',
222
+ sound: true,
223
+ guardedBy: 'narrow-local.test.ts: the width pair is jointly load-bearing and neither half alone',
224
+ rejects: (c) => !c.readerIsExtension,
225
+ },
226
+ {
227
+ id: 'cast-width',
228
+ why: 'only 8 and 16 are widths a `zext`/`sext` — and so a C declaration — carries',
229
+ sound: true,
230
+ guardedBy: 'narrow-local.test.ts: the width pair is jointly load-bearing and neither half alone',
231
+ rejects: (c) => !CAST_WIDTHS.has(c.width),
232
+ },
233
+ {
234
+ id: 'raw-reader',
235
+ // Operand reads ONLY. raise/paramwidth.ts ships this id over a `useCount` that sums operands
236
+ // AND successor arguments; here a forwarded carrier is `forwarded`'s refusal, so the same shape
237
+ // is refused by both tables under two different names.
238
+ why: 'a reader of the un-extended carrier observes the bits the declaration would drop',
239
+ sound: true,
240
+ guardedBy: 'narrow-local.test.ts: a second reader of the raw carrier refuses the narrowing',
241
+ rejects: (c) => c.operandReads !== 1,
242
+ },
243
+ {
244
+ id: 'forwarded',
245
+ why: 'a carrier passed on to another block parameter is read there at its full width',
246
+ sound: true,
247
+ guardedBy: 'narrow-local.test.ts: a carrier forwarded as a branch argument refuses the narrowing',
248
+ rejects: (c) => c.forwarded > 0,
249
+ },
250
+ {
251
+ id: 'edge-reader',
252
+ why: 'the C names the in-edge value with the carrier, so its other readers read the truncation',
253
+ sound: true,
254
+ guardedBy: 'narrow-local.test.ts: an in-edge value read at full width elsewhere refuses the narrowing',
255
+ rejects: (c) => !c.edgeArgsObservedNarrow,
256
+ },
257
+ {
258
+ id: 'edge-extends',
259
+ // NOT sound: `s32 v` + one `(s16)v` at the use computes the same numbers, so what this decides
260
+ // is a spelling and the header's 2×2 is the evidence for the direction. It is NOT the same
261
+ // judgment as `paramwidth`'s `not-prologue`, which is `sound: true` for a reason this rule has
262
+ // no access to: a parameter's width is its SIGNATURE, and agbcc truncates at every prototyped
263
+ // call site of a narrow-declared callee — bytes in other functions, which no per-function
264
+ // differ sees. A block local's width leaves the function's interface alone, so the worst this
265
+ // rule can do is pick the losing spelling of two that compile, and nothing here is wrong.
266
+ why: 'no truncation on an in-edge, none sunk to the join, and a join gcc would have hoisted',
267
+ sound: false,
268
+ guardedBy: 'narrow-local.test.ts: a merge whose in-edges carry no truncation is a cast, not a declaration',
269
+ // ONE ENTRY, three conjuncts, and it cannot be split: a gate is a REJECTION, so "refuse unless
270
+ // some evidence" is one rule — split across rows, each half over-fires alone. `without`
271
+ // therefore cannot ablate the join half, but the COMPILER FACT can, and that is the second
272
+ // reason `hoistsSingleSetArm` is threaded rather than defaulted: clearing it leaves the two
273
+ // edge conjuncts standing and removes exactly this one. That is the ablation `narrow-local.
274
+ // test.ts: a target that claims no single-SET hoist gets no join evidence` runs and the sa3
275
+ // census is measured against; the FIXTURES then price the conjunct's own halves —
276
+ // MERGE_HOISTED_ARM the join, MERGE_DIAMOND_BIG_ARM / _LOAD_ARMS / _POOL_ARM the arms.
277
+ rejects: (c) => !c.edgeArgsExtend && !c.writeBackTruncation && !(c.armsHoistable && c.targetHoistsSingleSetArm),
278
+ },
279
+ ];
280
+
281
+ /** What the join shape says about the source — one record, because the second field is a property
282
+ * of the arms the first identifies. */
283
+ export interface MergeShape {
284
+ /** the block is a TWO-ARMED DIAMOND: `gcc/jump.c:471-502`'s input shape. */
285
+ diamond: boolean;
286
+ /** …and both arms are ones that guard could have collapsed. Never true without `diamond`. */
287
+ hoistable: boolean;
288
+ }
289
+
290
+ /** THE ARMS OF A TWO-ARMED DIAMOND, or `null` — `gcc/jump.c:443-445`'s input shape, read conjunct
291
+ * by conjunct off that transform's guard at `:471-502` in the agbcc checkout:
292
+ *
293
+ * :472/:474 `temp3 = prev_active_insn (insn)` … `single_set (temp3)` the `x = a;` arm, ONE insn
294
+ * :490/:491 `temp = prev_active_insn (temp3)` … `condjump_p (temp)` the arm's own
295
+ * predecessor insn IS the conditional jump
296
+ * :478/:480 `temp2 = next_active_insn (insn)` … `single_set (temp2)` the `x = b;` arm, ONE insn
297
+ * :482/:483 `! side_effects_p` … `! may_trap_p (SET_SRC (temp4))` …and speculatable
298
+ *
299
+ * THE HEAD TEST — both arms' SOLE predecessor being the same `cond_br` — is what keeps out the
300
+ * three two-predecessor joins this rule has no evidence about:
301
+ *
302
+ * • a LOOP HEADER, whose preheader and latch really are distinct non-branching predecessors, and
303
+ * `gcc/jump.c` never considers a back edge. Their sole predecessors differ.
304
+ * • a FRONTEND-INVENTED join: an empty forwarding block cut at a label is an "arm" with no insn
305
+ * for `:480`'s `single_set` to match, and its own predecessor is another join.
306
+ * • the ENTRY block, whose implicit entry edge a predecessor map cannot see (the trap
307
+ * `raise/divpow2.ts:92-98` documents for the same recognizer).
308
+ *
309
+ * `raise/divpow2.ts:99-115` walks a ONE-armed diamond (head → bias arm → merge, plus a direct
310
+ * head → merge edge) and is deliberately not shared with this: that pass DELETES a block and needs
311
+ * the arm's contents, this one only reads a shape.
312
+ *
313
+ * THESE REFUSALS ARE NOT `NARROW_LOCAL_GATES` ENTRIES and cannot be: a gate rejects a CANDIDATE,
314
+ * and these are per-BLOCK shape facts read once per function, before any candidate exists. What
315
+ * the candidate carries instead is the ANSWER, split so a census can attribute it — `mergeDiamond`
316
+ * for the shape and `armsHoistable` for the arms. Their reach, over the 13733 blocks of 2288 sa3
317
+ * sources, first refusal only: pred-count 10734, arm-not-br 2169, head-not-shared 387,
318
+ * arm-unsafe-op 324, arm-op-count 59, head-not-cond_br 1, entry-block 0, and 59 hoistable. The
319
+ * entry-block guard is the one that never fires there; it stays because the shape it refuses is a
320
+ * wrong ANSWER rather than a missing one, and refusing costs nothing. */
321
+ function mergeArms(preds: Map<Block, Block[]>, fn: Fn, blk: Block): [Block, Block] | null {
322
+ const p = preds.get(blk);
323
+ if (p === undefined || p.length !== 2 || blk === fn.blocks[0]) {
324
+ return null;
325
+ }
326
+ const [x, y] = p;
327
+ const term = (b: Block): Op | undefined => b.ops[b.ops.length - 1];
328
+ for (const arm of [x, y]) {
329
+ const t = term(arm);
330
+ if (t === undefined || t.opcode !== 'br' || t.successors[0]?.block !== blk) {
331
+ return null;
332
+ }
333
+ }
334
+ const hx = preds.get(x) ?? [];
335
+ const hy = preds.get(y) ?? [];
336
+ if (hx.length !== 1 || hy.length !== 1 || hx[0] !== hy[0]) {
337
+ return null;
338
+ }
339
+ const ht = term(hx[0]);
340
+ if (ht === undefined || ht.opcode !== 'cond_br') {
341
+ return null;
342
+ }
343
+ return [x, y];
344
+ }
345
+
346
+ /** ONE INSN HOLDING ONE SET, approximated over the lifted IR — `gcc/jump.c:474`/`:480`'s
347
+ * `single_set` and `:482`/`:483`'s `! side_effects_p` / `! may_trap_p` beside it. THREE THINGS AN
348
+ * OP COUNT ALONE GETS WRONG, and the first two are the benchmark rows `mergeldcast`/`mergepool`:
349
+ *
350
+ * • a MEMORY READ is one op and is NOT one speculatable SET. `gcc/rtlanal.c:1770-1771` sends a
351
+ * MEM to `rtx_addr_can_trap_p` and `:144-147` says an address held in a plain pseudo CAN trap,
352
+ * so `jump.c` never hoists a load and the diamond survives under BOTH spellings, carrying no
353
+ * information. `REEVAL_UNSAFE_OPS` answers this (effects, reads, or traps — `may_trap_p`
354
+ * refuses the trapping divides too, at `rtlanal.c:1774-1784`); `HOIST_UNSAFE_OPS` does NOT,
355
+ * because it omits reads for `raise/shortcircuit.ts`, whose arm C's own `&&` re-guards, and
356
+ * that exemption does not transfer to speculation above a compare.
357
+ * • a CONSTANT is not free. `v = a + 0x12345` is a literal-pool `ldr` plus the `add` — two
358
+ * insns, no `single_set` — while `v = a + 3` is one `adds`. The lifted IR spells both as
359
+ * `const` feeding `add` and does not say which immediate the target can fold, so constants
360
+ * COUNT: refusing the foldable case costs nothing, because agbcc really does hoist it and the
361
+ * join is then not a diamond at all.
362
+ * • an arm with NOTHING in it is not one SET either — which is why the budget is EXACTLY one and
363
+ * not at most one: `:480` runs `single_set` on the arm's own insn, and an arm whose only insn
364
+ * is its jump has none.
365
+ *
366
+ * Over-refusal here is free by construction: it returns the carrier to the wide-local-plus-cast
367
+ * spelling this pass emits without the clause.
368
+ *
369
+ * A SECOND READER, AND WHY THE PREDICATE IS SHARED RATHER THAN RE-DERIVED. `raise/retsink.ts`'s
370
+ * `arms-are-one-set` asks this same question of the same optimizer for a different purpose: a
371
+ * merge-variable select whose arms this guard would have collapsed never comes back as a diamond,
372
+ * so a TARGET holding one was written with early returns. Compiled both ways with agbcc -O2
373
+ * -mthumb and committed (`test/corpus/agbcc-select-{merge,early}.s`), the three bullets above hold
374
+ * term for term in that direction too: `selcomp` (`v = a + b`, one SET) loses its diamond in the
375
+ * merge spelling and keeps it in the early one; `selload` (`v = *p`) keeps a diamond in BOTH,
376
+ * carrying no information; `selcomp3` (three ops) likewise. ONE DIFFERENCE IS WORTH NAMING: the
377
+ * constant bullet's "refusing the foldable case costs nothing" is an argument about THIS pass's
378
+ * fallback, and it does not transfer — `selk3` (`v = a + 3`) is one `adds`, so `jump.c`'s own guard
379
+ * counts one SET where this predicate counts two, and retsink's refusal of it costs a candidate
380
+ * there rather than nothing. That divergence is argued from the optimizer rather than compiled:
381
+ * `selk3` is deliberately outside the committed pair. It still never costs an ANSWER (every
382
+ * clause in that table is `sound: false`), so the shared conservative predicate is the right one
383
+ * until a row asks for the cost model neither pass has. */
384
+ export function armIsOneSet(b: Block): boolean {
385
+ return (
386
+ !b.ops.some((op) => REEVAL_UNSAFE_OPS.has(op.opcode)) && b.ops.filter((op) => op.results.length > 0).length === 1
387
+ );
388
+ }
389
+
390
+ /** The join shape of every block, read ONCE off the IR it is handed.
391
+ *
392
+ * WHERE it is read is part of the rule, because the shape is a claim about what AGBCC emitted and
393
+ * asmlift rewrites the CFG on the way here. Two rewriters move it:
394
+ *
395
+ * • this pass itself, which deletes an extension and re-enumerates: an arm whose `lsl/asr/add` a
396
+ * SIBLING carrier's narrowing just shortened to `add` would re-read as one SET.
397
+ * • the twelve pre-recovery passes ahead of it, eight of them followed by `dce`. Over sa3, 20
398
+ * blocks gain `diamond` and 10 gain `hoistable` between lift and narrowlocal's turn, and
399
+ * `branch-shortcircuit` accounts for all 10 (`IsWorldPtActive`, `IsScreenPtActive`,
400
+ * `sub_802C0D4`, `sub_802C1F8`, …): `raise/shortcircuit.ts` MANUFACTURES the shape out of
401
+ * condition trees the ROM never merged, and the head test does not answer it, because the head
402
+ * test PASSES on exactly those blocks.
403
+ *
404
+ * So `runPreRecovery` calls this ONCE before the first pass and threads the map down
405
+ * (`PreRecoveryFacts`). A block a later pass creates is absent from the map and reads as no
406
+ * diamond, which refuses it.
407
+ *
408
+ * IT IS STILL NOT THE ROM: the frontend cuts blocks at labels and `applyIdiomPatterns` folds shift
409
+ * pairs into casts before pre-recovery runs, so this is the CFG as it ENTERS pre-recovery, the
410
+ * earliest point any pass can name. Both of those only ever make an arm SHORTER, so they can admit
411
+ * a diamond gcc's guard would have refused. */
412
+ export function mergeShapes(fn: Fn): Map<Block, MergeShape> {
413
+ const preds = predecessors(fn);
414
+ const out = new Map<Block, MergeShape>();
415
+ for (const b of fn.blocks) {
416
+ const arms = mergeArms(preds, fn, b);
417
+ out.set(b, { diamond: arms !== null, hoistable: arms !== null && arms.every(armIsOneSet) });
418
+ }
419
+ return out;
420
+ }
421
+
422
+ /** Every value arriving at `blk`'s parameter `idx`, over every edge in the function. */
423
+ function incomingArgs(fn: Fn, blk: Block, idx: number): (Value | undefined)[] {
424
+ const args: (Value | undefined)[] = [];
425
+ for (const b of fn.blocks) {
426
+ for (const op of b.ops) {
427
+ for (const s of op.successors) {
428
+ if (s.block === blk) {
429
+ args.push(s.args[idx]);
430
+ }
431
+ }
432
+ }
433
+ }
434
+ return args;
435
+ }
436
+
437
+ /** Every op that reads `v` as an operand, and how many times `v` appears as a branch argument. */
438
+ function readersOf(fn: Fn, v: Value): { ops: Op[]; forwarded: number } {
439
+ const ops: Op[] = [];
440
+ let forwarded = 0;
441
+ for (const b of fn.blocks) {
442
+ for (const op of b.ops) {
443
+ for (const o of op.operands) {
444
+ if (o === v) {
445
+ ops.push(op);
446
+ }
447
+ }
448
+ for (const s of op.successors) {
449
+ forwarded += s.args.filter((a) => a === v).length;
450
+ }
451
+ }
452
+ }
453
+ return { ops, forwarded };
454
+ }
455
+
456
+ /** Every block parameter this pass judges, with the extension that would be deleted — the gate
457
+ * table's INPUT, separated from its application so a test can ask WHICH gate refuses a shape
458
+ * rather than only whether the pass fired. */
459
+ export function narrowLocalCandidates(
460
+ fn: Fn,
461
+ shapes: Map<Block, MergeShape> = mergeShapes(fn),
462
+ opts: NarrowLocalOptions = {},
463
+ ): { c: NarrowLocalCandidate; ext: Op }[] {
464
+ const out: { c: NarrowLocalCandidate; ext: Op }[] = [];
465
+ const defs = defOpMap(fn);
466
+ for (const [i, b] of fn.blocks.entries()) {
467
+ const shape = shapes.get(b) ?? { diamond: false, hoistable: false };
468
+ for (const [pi, p] of b.params.entries()) {
469
+ const { ops, forwarded } = readersOf(fn, p);
470
+ const ext = ops[0];
471
+ if (ext === undefined) {
472
+ continue;
473
+ }
474
+ const isExt = ext.opcode === 'sext' || ext.opcode === 'zext';
475
+ const w = isExt ? (ext.attrs.width as number) : 0;
476
+ const args = incomingArgs(fn, b, pi);
477
+ // HALF TWO of the soundness argument, and the evidence half beside it — both are properties
478
+ // of the values that ARRIVE, and neither is visible from the carrier's own readers.
479
+ const observedNarrow = args.every((a) => {
480
+ if (a === undefined) {
481
+ return false;
482
+ }
483
+ const r = readersOf(fn, a);
484
+ // …read only through an extension NARROWER THAN OR EQUAL TO the bits the declaration
485
+ // keeps. Such a reader re-extends from the name explicitly (`(s16)v`), so it observes
486
+ // exactly what it observed of the raw value; its signedness is its own business. Anything
487
+ // else — an `add`, an `icmp`, a store of a wider width — reads bits the declaration drops.
488
+ if (!r.ops.every((o) => (o.opcode === 'sext' || o.opcode === 'zext') && (o.attrs.width as number) <= w)) {
489
+ return false;
490
+ }
491
+ // …and handed to no block parameter but this one (another would read it full-width)
492
+ return r.forwarded === args.filter((x) => x === a).length;
493
+ });
494
+ const argExtends = args.every((a) => {
495
+ const d = a === undefined ? undefined : defs.get(a);
496
+ if (d === undefined) {
497
+ return false;
498
+ }
499
+ return d.opcode === 'const' || ((d.opcode === 'sext' || d.opcode === 'zext') && (d.attrs.width as number) <= w);
500
+ });
501
+ // …and the same truncation SUNK PAST THE JOIN, which is where gcc puts it when every arm
502
+ // writes the local: the carrier's own reader is the `zext` write-back, and the sole reader of
503
+ // THAT is the sign extension the narrow declaration is read through. A cast on a wide local
504
+ // writes one extension, never this pair.
505
+ const extRead = ext.opcode === 'zext' ? readersOf(fn, ext.results[0]) : undefined;
506
+ const writeBackTruncation =
507
+ extRead !== undefined &&
508
+ extRead.forwarded === 0 &&
509
+ extRead.ops.length === 1 &&
510
+ extRead.ops[0].opcode === 'sext' &&
511
+ extRead.ops[0].attrs.width === w;
512
+ out.push({
513
+ c: {
514
+ param: p,
515
+ width: w,
516
+ isEntryParam: i === 0,
517
+ operandReads: ops.length,
518
+ readerIsExtension: isExt,
519
+ forwarded,
520
+ edgeArgsObservedNarrow: observedNarrow,
521
+ edgeArgsExtend: argExtends,
522
+ writeBackTruncation,
523
+ mergeDiamond: shape.diamond,
524
+ armsHoistable: shape.hoistable,
525
+ targetHoistsSingleSetArm: opts.hoistsSingleSetArm === true,
526
+ },
527
+ ext,
528
+ });
529
+ }
530
+ }
531
+ return out;
532
+ }
533
+
534
+ /** Type each block parameter at the width its sole reading extension proves, and drop that
535
+ * extension. Returns the number of carriers narrowed. */
536
+ export function narrowBlockLocals(
537
+ fn: Fn,
538
+ gates: readonly Gate<NarrowLocalCandidate>[] = NARROW_LOCAL_GATES,
539
+ opts: NarrowLocalOptions = {},
540
+ // The join shape, read before ANY pre-recovery pass rewrote the CFG — `mergeShapes` says why the
541
+ // reading point is part of the rule. Defaulted for callers with no pass list around them (the
542
+ // tests). Everything else the gates read is re-enumerated after every rewrite, deliberately.
543
+ shapes: Map<Block, MergeShape> = mergeShapes(fn),
544
+ ): number {
545
+ let narrowed = 0;
546
+ // Re-enumerated after each rewrite: narrowing one carrier deletes an op and re-points its
547
+ // readers, which is exactly the evidence the edge rules of a LATER carrier read. `done` is the
548
+ // re-entry guard and nothing else — `param-typed` is the RULE about an already-typed parameter,
549
+ // and it has to stay ablatable.
550
+ const done = new Set<Value>();
551
+ for (let again = true; again;) {
552
+ again = false;
553
+ for (const { c, ext } of narrowLocalCandidates(fn, shapes, opts)) {
554
+ if (done.has(c.param) || firstRejection(gates, c) !== null) {
555
+ continue;
556
+ }
557
+ done.add(c.param);
558
+ c.param.type = T.int(c.width, ext.opcode === 'sext');
559
+ replaceAllUsesWith(fn, ext.results[0], c.param);
560
+ for (const blk of fn.blocks) {
561
+ const at = blk.ops.indexOf(ext);
562
+ if (at >= 0) {
563
+ blk.ops.splice(at, 1);
564
+ }
565
+ }
566
+ narrowed++;
567
+ again = true;
568
+ break;
569
+ }
570
+ }
571
+ return narrowed;
572
+ }