@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
@@ -3,17 +3,33 @@
3
3
  // A RISC target builds a 32-bit literal in two halves: a high-half load (MIPS `lui`, PPC `lis`) then a
4
4
  // low-half `ori`/`addiu`. The frontends lift that pair faithfully as `or(const(hi<<16), const(lo))` /
5
5
  // `add(const(hi<<16), const(lo))` — a live binary op over two `const` ops — because neither frontend can
6
- // see across the two instructions. This pass folds any such const/const `or`/`add` into a single `const`,
6
+ // see across the two instructions. This pass folds such a const/const `or`/`add` into a single `const`,
7
7
  // which is the form that (a) type-recovers as one 32-bit literal and (b) recompiles to the exact
8
8
  // `lui;ori` / `lis;ori` pair. Without it a magic-division reciprocal or an address literal is never a
9
9
  // single value the later passes can reason about.
10
10
  //
11
+ // THE CLIENTELE IS "A VALUE THE TARGET MATERIALISES IN TWO INSTRUCTIONS", and it is wider than the
12
+ // RISC pair above in both directions, and both halves are measured:
13
+ // - It is NOT ARM-free. On Thumb a literal is a pool word plus an immediate `add`, which is exactly a
14
+ // two-instruction materialisation and lifts as the same const/const pair. Ablating the whole pass
15
+ // costs three agbcc BYTE-MATCHES — `synthetic:dmafield` MATCH -> diff:29, `synthetic:fieldbase`
16
+ // MATCH -> diff:22, `synthetic:bgfixed` MATCH -> diff:2 (measured, whole pass off). Do not gate
17
+ // this pass on a RISC target.
18
+ // - The pair is NOT confined to ONE block. `synthetic:mergepool:gcc2.7.2kmc` lifts a genuine
19
+ // `lui;ori` as `or(const 65536, const 9029)` whose two halves are defined in DIFFERENT blocks
20
+ // (instrumented: `b0=0 b1=1`), because gcc hoisted the high half above the branch. A guard
21
+ // tightened to a literal same-block test loses folds the corpus depends on.
22
+ // What the refusal below actually excludes is narrower than either: an operand a terminator hands to a
23
+ // successor's block-parameter — a REGISTER the compiler held live across a branch — where the pair is
24
+ // ALSO not recognisable as a hi/lo pair and the result is not an address. See its site for the cost.
25
+ //
11
26
  // This cannot be a data-`RewritePattern`: the fold's result is COMPUTED from the two operands' values,
12
27
  // which the pattern engine's numeric-exact `attrEquals` cannot express. So it lives here as an always-on
13
28
  // recognizer, run before type recovery. Value-preserving and local; a single left-to-right pass suffices
14
29
  // (SSA guarantees each const is defined before the op that consumes it, and a folded result feeds
15
30
  // forward for any chained materialisation).
16
- import { Fn, Op, defOpMap, mkOp } from '../ir/core';
31
+ import { Fn, Op, Value, defOpMap, mkOp } from '../ir/core';
32
+ import { MEM_BASE_OPS } from '../ir/opcodes';
17
33
 
18
34
  // The binary opcodes whose const/const form is a constant. `>> 0` normalises to a signed 32-bit result
19
35
  // (hardware wraparound): `|` already yields int32, `+` may exceed it and is truncated to match `addu`/`add`.
@@ -22,11 +38,123 @@ const FOLD: Record<string, (a: number, b: number) => number> = {
22
38
  add: (a, b) => (a + b) >> 0,
23
39
  };
24
40
 
41
+ /** Is `v` a RISC HIGH HALF — what one `lui`/`lis`/`addis` puts in a register? `hi << 16`, for a
42
+ * NON-ZERO `hi`. Zero is excluded deliberately, and that exclusion is what makes this test
43
+ * trustworthy as a positive: `lui rD, 0` is a no-op no compiler emits, so a `const 0` is never a
44
+ * half being materialised — it is an initialised register, exactly the shape the refusal below
45
+ * exists to protect.
46
+ *
47
+ * IT COVERS THE RISC HALF OF THE CLIENTELE ONLY, because the 16/16 split is an ISA fact: censused
48
+ * over the corpus's const/const fold sites, all 19 RISC ones (gcc2.7.2kmc / mwcc_242_81 / ido7.1)
49
+ * are recognisable to it and 0 of the 20 agbcc ones are — an ARM pool word is an arbitrary 32-bit
50
+ * value (`0x03001C00`, low half `0x1C00`) and can never pass. The ARM half of the clientele is
51
+ * protected by `memBases` and by simply not being edge-carried; four agbcc sites on three rows
52
+ * (`dmascope` ×2, `dmascope2`, `dmafield`'s `add(…,112)`) sit behind neither carve-out. If a row
53
+ * ever needs the buy-back on ARM, the half-width belongs on the target description —
54
+ * `PreRecoveryPass` already threads `target`, and `softdiv` gates on `capabilities.hwDivide` —
55
+ * rather than as a second constant here. */
56
+ const isHighHalf = (v: number): boolean => (v & 0xffff) === 0 && v !== 0;
57
+
58
+ /** Is `v` a LOW HALF — what one `ori`/`addi`/`addiu` can supply? `ori` takes an UNSIGNED 16-bit
59
+ * immediate and `addi`/`addiu` a SIGNED one, so the admissible range is the union: mwcc completes an
60
+ * `addis` with a NEGATIVE `addi` whenever bit 15 of the low half is set (`0x12350000 + -25924` is
61
+ * `0x12345ABC`). Deliberately NOT split per-opcode (`or` unsigned, `add` signed): that narrowing is
62
+ * true of the ISA, reaches 0 rows of the corpus, and no fixture reddens when it is removed, so it
63
+ * would be an unpinned clause. The half-width belongs on the target description if a row needs it. */
64
+ const isLowHalf = (v: number): boolean => v >= -0x8000 && v <= 0xffff;
65
+
66
+ /** The constant a foldable const/const pair denotes, or `null` when `opcode` is not one this pass
67
+ * folds. Exported because `structure.ts` repairs the refusal's residue at RENDER time and must
68
+ * print exactly what an unrefused fold here would have produced — the opcode set and the int32
69
+ * normalisation are one decision, so they live in one place. */
70
+ export function foldConstPair(opcode: string, a: number, b: number): number | null {
71
+ const f = FOLD[opcode];
72
+ return f ? f(a, b) : null;
73
+ }
74
+
75
+ /** Does this opcode's const/const form denote a constant? The membership half of `foldConstPair`,
76
+ * for callers that must classify an op before they have its operands' values. */
77
+ export const isConstFoldOpcode = (opcode: string): boolean => opcode in FOLD;
78
+
79
+ /** THE EVIDENCE THE FOLD WOULD OTHERWISE DESTROY, stamped on the literal it produces.
80
+ *
81
+ * Three Thumb shapes put two accesses a constant distance apart, and the assembly tells them
82
+ * apart: two pool words (two independent `const` ops), one pool word plus memory-operand
83
+ * displacements (`l3/ast.ts`'s `operandOff`), and one pool word plus an `add` to the register
84
+ * that already held the first address (`ldr r3,=X; strh [r3]; adds r3,#2; strh [r3]`). The third
85
+ * lifts as `add(const X, const 2)` — indistinguishable, once folded, from a literal `X + 2` the
86
+ * compiler materialised in two instructions, which is this pass's whole clientele. So the fold
87
+ * still happens and the distinction is recorded, exactly as `structure.ts` records `operandOff`
88
+ * before its own fold (see the `operandOff` note in l3/ast.ts).
89
+ *
90
+ * WHAT IT ASSERTS is narrow and is a fact about REGISTERS, not about C: at this instruction the
91
+ * machine held the base address in a register, used it as an address, and advanced it by `step`
92
+ * bytes to reach another address it also used. THE READER decides what to spell — `l3/advance.ts`
93
+ * offers a pointer local advanced in place, which is the only C spelling that reproduces the
94
+ * `add` on a compiler that folds a constant subscript into the memory operand, and which is
95
+ * INERT (same bytes as the subscript) wherever the pointee is not `volatile`.
96
+ *
97
+ * THE THREE REFUSALS, each of which makes the stamp mean something else:
98
+ * • `or`, not `add` — a hi/lo `or` is a literal being assembled, never a pointer being moved.
99
+ * • the ADDEND is itself a memory base. Then both halves are addresses and neither is the step;
100
+ * the shape is not an advance and the stamp would name an arbitrary one of them.
101
+ * • either the base or the result is never used AS AN ADDRESS. A register that only feeds
102
+ * arithmetic is a value, and `X + 2` over two values is a literal by every reading.
103
+ * A zero step is dropped too: it names no advance, and `l3/ast.ts`'s readers all test
104
+ * `!== undefined` rather than truthiness, so a stamped 0 would read as a real one. */
105
+ function advanceEvidence(op: Op, a: number, c: number, memBases: ReadonlySet<Value>): { advancedBy?: number } {
106
+ if (op.opcode !== 'add' || !memBases.has(op.results[0])) {
107
+ return {};
108
+ }
109
+ const baseIdx = memBases.has(op.operands[0]) ? 0 : memBases.has(op.operands[1]) ? 1 : -1;
110
+ if (baseIdx < 0 || memBases.has(op.operands[1 - baseIdx])) {
111
+ return {};
112
+ }
113
+ const step = baseIdx === 0 ? c : a;
114
+ return step === 0 ? {} : { advancedBy: step };
115
+ }
116
+
25
117
  /** Fold each const/const `or`/`add` into one `const`, in place. Returns whether anything changed. The
26
118
  * now-dead source consts are left for DCE (they may still have other uses; liveness is not our concern). */
27
119
  export function recognizeConsts(fn: Fn): boolean {
28
120
  let changed = false;
29
121
  const defs = defOpMap(fn);
122
+ // The two facts the CLIENTELE REFUSAL below reads, both collected in one walk.
123
+ // `edgeSlots` — for every value a terminator hands to a successor, WHICH block-parameters it
124
+ // feeds. Being in the map at all is "a register the compiler held live across a branch": in
125
+ // functional-form SSA the machine had this value in a register at the branch and the join
126
+ // reads it back. WHICH parameter is the second question, and the buy-back below needs it.
127
+ // `memBases` — every value used as a memory base, i.e. the values that ARE addresses.
128
+ const edgeSlots = new Map<Value, Set<Value>>();
129
+ const memBases = new Set<Value>();
130
+ for (const b of fn.blocks) {
131
+ for (const op of b.ops) {
132
+ for (const sc of op.successors) {
133
+ sc.args.forEach((v, i) => {
134
+ const param = sc.block.params[i];
135
+ if (param === undefined) {
136
+ return;
137
+ }
138
+ const slots = edgeSlots.get(v);
139
+ if (slots) {
140
+ slots.add(param);
141
+ } else {
142
+ edgeSlots.set(v, new Set([param]));
143
+ }
144
+ });
145
+ }
146
+ if (MEM_BASE_OPS.has(op.opcode) && op.operands.length > 0) {
147
+ memBases.add(op.operands[0]);
148
+ }
149
+ }
150
+ }
151
+ /** Do these two values reach the SAME block-parameter — i.e. are they two arms' feeds of one
152
+ * merge? See the refusal for why that is what tells an accumulator from a shared high half. */
153
+ const meetAtSameParam = (x: Value, y: Value): boolean => {
154
+ const sx = edgeSlots.get(x);
155
+ const sy = edgeSlots.get(y);
156
+ return !!sx && !!sy && [...sx].some((k) => sy.has(k));
157
+ };
30
158
  const constOf = (op: Op | undefined): number | null =>
31
159
  op && op.opcode === 'const' ? (op.attrs.value as number) : null;
32
160
  for (const b of fn.blocks) {
@@ -41,8 +169,80 @@ export function recognizeConsts(fn: Fn): boolean {
41
169
  if (a === null || c === null) {
42
170
  continue;
43
171
  }
172
+ // ── THE REFUSAL: this shape is not a literal being materialised ───────────────────────────
173
+ // An operand a terminator ALSO hands to a successor's block-parameter is a register the compiler
174
+ // held live across the branch, whose value on this path happens to be a constant. agbcc's
175
+ // `s = 0; ... if (c) s += 1;` lifts as `add(%s = const 0, const 1)` in the taken arm, where
176
+ // `%s` is also the value bb0 hands the join. Folding it to `const 1` deletes the accumulator's
177
+ // last reference, so every later level sees an arm that materialises a literal and spells it
178
+ // as one (`v = 1;` with an `else v = 0;`) instead of the `s += 1` the target records — and the
179
+ // enumeration gate for the shipped `/merge-home` variation, which is what would have spelled the
180
+ // hoisted init, reads FALSE because the merge feed it looks for is gone.
181
+ //
182
+ // The mapping is a FUNCTION, not an open question, so this is a default and not a variation: a register
183
+ // carried across a branch is not a literal being materialised, whichever compiler produced it.
184
+ //
185
+ // IT IS A PROXY, and the two carve-outs are where it is bought back. Edge-carrying is evidence
186
+ // of a register, not proof, and the same `add(const 0, const K)` still folds wherever nothing
187
+ // carries the zero (6 sites on 5 marioparty3/snowboardkids2 rows) — defensible, since with no
188
+ // merge there is no home to hoist and the incident cannot occur, but the rule is narrower than
189
+ // "never fold a const/const pair over a branch".
190
+ // - `hiLoPair` — the pass's OWN clientele beats the proxy. mwcc materialises `0x12345678` as
191
+ // `lis; addi` and shares the `lis` across a branch whenever the high half is live at the
192
+ // join, so the genuine pair IS edge-carried and the proxy refuses it: measured, a
193
+ // `base = 0x12340000; if (c) q[0] = base|0x5678; else q[1] = base|0x9ABC; *p = base;` row
194
+ // emitted `*a2 = 305397760 + 22136;` on mwcc_242_81 where every other toolchain emitted the
195
+ // folded literal. The literal is then no longer ONE value for `recognizeMagicDivision`, type
196
+ // recovery or the symbol map — the same never-enumerated failure this refusal exists to fix,
197
+ // one level down.
198
+ //
199
+ // `feedsSameMerge` IS A CONDITION ON THAT BUY-BACK: read as a bare "is this a hi/lo pair"
200
+ // it re-opens the very incident this refusal exists for. An accumulator's `const 0` init
201
+ // passes `isLowHalf`, so `s = 0; if (c) s += 0x10000;` — one 16.16 fixed-point step — is a
202
+ // hi/lo pair by the letter of the test, and its MIRROR `s = 0x10000; if (c) s += 1;` is
203
+ // one whichever operand is read as the half. Folded, a two-arm `s += 0x10000` row scores
204
+ // **diff:1** on mwcc_242_81 with `hasMergeFeedHome` FALSE; refused, **MATCH** with the
205
+ // gate TRUE. What separates the two shapes is WHERE THE VALUES GO rather than their bit
206
+ // patterns: an accumulator's init and its updated copy are two arms' feeds of ONE block
207
+ // parameter — exactly the merge `/merge-home` exists to home — whereas a shared high half
208
+ // reaches the join while the COMPLETED literal is stored or returned, never merged with
209
+ // the half it was built from. Measured FREE: with it in, 770 synthetic + 252 real rows are
210
+ // byte-identical in post-recovery IR, in emitted source and in gap list to the branch
211
+ // without it.
212
+ // - `memBases` — an address literal, `0x03001C00 + 1206` reached through one arm's base
213
+ // register. It decides 0 folds over the corpus's 806 lifted rows and is here as a
214
+ // statement of scope; `hiLoPair` is the clause that carries real traffic. Deliberately NOT
215
+ // transitive and NOT extended to call arguments: an address literal escaping as a call
216
+ // argument is a shape the refusal HELPS (a probe row scored diff:7 -> MATCH with it
217
+ // firing), so widening this test would give that back.
218
+ //
219
+ // NOT CONVERTIBLE TO A `Gate` TABLE, and the reason is the type rather than the minutes.
220
+ // `firstRejection` reads a table as a DISJUNCTION of independent refusals; the test below is
221
+ // a CONJUNCTION with two buy-backs, and "refuse unless exempted" decomposes only by making
222
+ // every term re-carry the whole conjunction. The census would then report one opaque id and
223
+ // stay silent about WHICH exemption fired — the only question the paragraphs above ask. Said
224
+ // here because a selector that ranks passes by refusal count or by instrument minutes points
225
+ // at this file (`grep -n "raise/const.ts" docs/level-tower.md`), and both readings are wrong
226
+ // about it for different reasons.
227
+ //
228
+ // A REFUSAL IS ALSO A SCHEDULING DECISION, and that coupling is invisible at this site:
229
+ // `pre-recovery.ts` registers this pass `dce: true` and runs `dce(fn)` only when the pass
230
+ // returns TRUTHY, so a function whose ONLY const/const pair is refused gets no DCE here at
231
+ // all — and `addrnum` above is `dce: false`, so this is the first pass that can schedule one.
232
+ // Measured inert: forcing the cancelled `dce(fn)` removes 0 ops on every invocation of every
233
+ // reachable row (`sinkacc`, 3 invocations). Worth knowing before this refusal is widened.
234
+ const edgeCarried = edgeSlots.has(op.operands[0]) || edgeSlots.has(op.operands[1]);
235
+ const feedsSameMerge =
236
+ meetAtSameParam(op.results[0], op.operands[0]) || meetAtSameParam(op.results[0], op.operands[1]);
237
+ const hiLoPair = ((isHighHalf(a) && isLowHalf(c)) || (isHighHalf(c) && isLowHalf(a))) && !feedsSameMerge;
238
+ if (edgeCarried && !hiLoPair && !memBases.has(op.results[0])) {
239
+ continue;
240
+ }
44
241
  // Reuse the SAME result Value → every existing use already points at it (no RAUW needed).
45
- const folded = mkOp('const', { results: [op.results[0]], attrs: { value: fold(a, c) } });
242
+ const folded = mkOp('const', {
243
+ results: [op.results[0]],
244
+ attrs: { value: fold(a, c), ...advanceEvidence(op, a, c, memBases) },
245
+ });
46
246
  b.ops.splice(i, 1, folded);
47
247
  defs.set(op.results[0], folded); // keep the def map current so a chained fold sees this const
48
248
  changed = true;
@@ -31,9 +31,9 @@
31
31
  // SELF-VERIFYING. asmlift emits a plain `x / 2^k` and the target compiler regenerates ITS own
32
32
  // lowering; a wrong divisor recompiles to different bytes and shows up as a nonmatch, never as a
33
33
  // false match. The residual exposure is a lost match, not a miscompile — on a compiler that lowers
34
- // `/2^k` branchlessly, a diamond of this shape came from hand-written biasing, and respelling it
34
+ // `/2^k` branchlessly, a diamond of this shape came from hand-written biasing, and rewriting it
35
35
  // costs a match that used to land. Measured positive on ido7.1 (two flips), agbcc and gcc2.7.2kmc
36
- // (modpow2 stays byte-exact through the respelling); mwcc_242_81 and gcc2.7.2 have no inhabitant, so
36
+ // (modpow2 stays byte-exact through the rewrite); mwcc_242_81 and gcc2.7.2 have no inhabitant, so
37
37
  // they are unmeasured rather than clean.
38
38
  //
39
39
  // It is deliberately IDENTITY-OR-DECLINE about the shape (the bias constant must be exactly
@@ -41,7 +41,7 @@
41
41
  // the biased arm must be the negative one — the consistency check m2c's `49b5d87` also adds),
42
42
  // because every one of those is a way for a superficially similar diamond to mean something else.
43
43
  import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
44
- import { HOIST_UNSAFE_OPS } from '../ir/opcodes';
44
+ import { EFFECTFUL_OPS } from '../ir/opcodes';
45
45
  import { T } from '../ir/types';
46
46
 
47
47
  /** `shr_s v {imm=k}` → k, else null. */
@@ -110,7 +110,7 @@ export function recognizeDivPow2(fn: Fn): boolean {
110
110
  continue;
111
111
  }
112
112
  const bp = preds.get(bias) ?? [];
113
- if (bp.length !== 1 || bias.ops.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
113
+ if (bp.length !== 1 || bias.ops.some((op) => EFFECTFUL_OPS.has(op.opcode))) {
114
114
  continue;
115
115
  }
116
116
  const h = bp[0];
@@ -0,0 +1,342 @@
1
+ // A NARROWING EXTENSION FUSED WITH THE LEFT SHIFT THAT SCALES IT.
2
+ //
3
+ // agbcc lowers `(u8)x` to a shift pair, `lsl #24; lsr #24` (the CAST_PATTERNS idiom,
4
+ // pattern/engine.ts). Scale the narrowed value and gcc's combiner merges the right half of the pair
5
+ // with the scale, so `(u8)x << 3` is TWO instructions, not three:
6
+ //
7
+ // lsl r0, r0, #0x18 %1 = shl %x {imm=24}
8
+ // lsr r0, r0, #0x15 %2 = shr_u %1 {imm=21} == zext8(x) << 3
9
+ //
10
+ // Exactly: `(x << L) >>u R` for `0 < R < L` keeps x's low `32 - L` bits and lands them at bit
11
+ // `L - R`, which is `zext(x, 32 - L) << (L - R)`; `>>s` is the same with `sext`. What the fold buys
12
+ // is the two facts the raw pair hides from every pass below — and ONLY those: the spelling it
13
+ // prints when neither is claimed is not byte-neutral, so that spelling never ships (WHAT NOBODY
14
+ // CLAIMED, below):
15
+ //
16
+ // • THE WIDTH. A declared `u8 idx` is extended in the prologue, and that extension is what
17
+ // raise/paramwidth.ts reads to type the parameter. When the extension is fused, the prologue
18
+ // holds a bare `shl` instead: paramwidth sees no extension of this parameter, AND its prologue
19
+ // scan stops at that `shl`, so every LATER parameter's extension reads as body code too. Typed
20
+ // `u32`, the function recompiles with both shifts at the use and the whole schedule moves.
21
+ // • THE SCALE. `shl(ext(x), k)` is the element-scaled index raise/arrays.ts and
22
+ // raise/struct-arrays.ts legalize; `shr_u(shl(x, 24), 21)` is an opaque byte offset to both.
23
+ //
24
+ // WHERE THE EXTENSION GOES is the one real decision here, and it is read off the machine, not
25
+ // chosen. The two halves of the pair are not scheduled together. Compiled with this benchmark's
26
+ // agbcc, the same body over a narrow and a wide parameter:
27
+ //
28
+ // void pa(u8 a, u8 b) { gA |= 4; gB = (u32)&gT + a * 4; gC = b; }
29
+ // lsl r0,#24 / lsl r1,#24 / lsr r1,#24 / …body… / lsr r0,#22 / add …
30
+ // void pb(u32 a, u8 b) { gA |= 4; gB = (u32)&gT + (u8)a * 4; gC = b; }
31
+ // lsl r1,#24 / lsr r1,#24 / …body… / lsl r0,#24 / lsr r0,#22 / add …
32
+ //
33
+ // The declaration's extension starts in the prologue and only its SECOND half is merged into the
34
+ // use; a cast in the body is lowered at the use, both halves together. So the extension is placed
35
+ // where its `shl` stood and the scale where the right shift stood: a prologue `lsl` yields a
36
+ // prologue extension, which paramwidth's own gates then judge. Those gates keep a body cast wide
37
+ // only when body code that READS a value comes first — `not-prologue` steps over materializations,
38
+ // a pool-loaded address among them — which is why the fold records a pair it finds behind one
39
+ // (THE BODY CAST BEHIND A POOL LOAD, below). Placing both at the right shift instead throws `pa`'s
40
+ // prologue evidence away, so `pa` reads as `pb` and stays wide — measured on the benchmark's sa3
41
+ // rows whose first parameter is a declared `u8` scaled this way:
42
+ // `sa2__sub_8007858` 39/60 anchored against 44/61 at the right shift, and `sa2__sub_8007958` 64/87
43
+ // against 66/88.
44
+ //
45
+ // The SIGNED form does not carry that evidence on agbcc: `void pd(s16 a, …) { … a * 2 … }` and its
46
+ // wide twin `(s16)a * 2` compile to the SAME object, both halves at the use — the ambiguity of
47
+ // paramwidth's `pc` pair — so the placement rule is the same either way, and where the pair sits the
48
+ // judgement is paramwidth's.
49
+ //
50
+ // REFUSES — the pair is left as it is — when:
51
+ // • `R >= L`. `R == L` is the plain cast, which CAST_PATTERNS already folded; `R > L` keeps
52
+ // `32 - R` bits of `x << L` and scales nothing — a bitfield EXTRACT, structure/bitfields.ts's
53
+ // shape — so the two readings are disjoint by the shift amounts alone;
54
+ // • `32 - L` is not a C cast width (8 or 16) — `lsl #28; lsr #26` is `(x & 15) << 2`, which no
55
+ // cast spells;
56
+ // • either shift is the two-register form (no `imm`) or `R` is 0;
57
+ // • the target is not one CAST_PATTERNS applies to. Same predicate, because the fused pair is
58
+ // that cast's lowering with a scale merged in: where `(u8)x` is not a shift pair (IDO and GCC
59
+ // `andi`), `(u8)x << k` is not one either, and a `sll; srl` there is some other arithmetic.
60
+ //
61
+ // • another narrowing of the same value, at the same width and with the same SIGN, sits in the
62
+ // pair's block — a second right shift of a `shl(x, L)`, fused or plain, or the extension
63
+ // CAST_PATTERNS made of a plain one. agbcc extends a value once per block per signedness:
64
+ // `p[(u8)a * 4] = 1; return (u8)a;` is one `lsl #24; lsr #24` and a `lsl #4` at the use, and no
65
+ // fused pair. So a same-sign sibling means the source wrote the shift itself —
66
+ //
67
+ // u32 r6(u32 a, u32 *p) { u32 t = a << 24; p[t >> 22] = 1; return t >> 24; }
68
+ // lsl r0,#24 / lsr r2,r0,#20 / … / lsr r0,r0,#24
69
+ //
70
+ // which folded, struct-arrays spelled `a1[(u8)a0].field_0`: objdiff 3 where the raw pair is
71
+ // byte-exact. Opposite signs share only the `lsl` — `p[(u8)a] = (s8)a` is `lsl #24; lsr #22 …
72
+ // asr #24` — and both of those ARE casts, so they fold. The test is per BLOCK because the
73
+ // sharing is: the same two casts in two blocks lower twice, each with its own `lsl`
74
+ // (`if (c) p[1] = (u8)a; return p[(u8)a];`), and the fused one folds; and a `shl` shared
75
+ // ACROSS blocks is a declaration too — sa3's `sub_8010184` indexes `s->hitboxes[hbIndex]`
76
+ // twice over an `s16 hbIndex`: one `lsl #16`, read by an `asr #13` in each of two blocks. The
77
+ // idiom patterns turn a plain pair into its extension in place, so the test reads the same
78
+ // before them as after, and raise/globalshape.ts, which reads the lift, agrees with the fold
79
+ // about every pair.
80
+ //
81
+ // THE BODY CAST BEHIND A POOL LOAD folds, and the fold records it: the pair's `shl` reads an entry
82
+ // parameter and the machine ran it after a pool-loaded address. Compiled with this benchmark's
83
+ // agbcc, a body cast whose only predecessors are pool loads puts nothing between them and the
84
+ // extension that paramwidth's scan stops at, so the extension reads as a declaration:
85
+ //
86
+ // void pc1(u32 a) { gB = (u32)&gT + (u8)a * 4; } ldr r2,=gB / lsl r0,#24 / lsr r0,#22
87
+ //
88
+ // narrowed to `u8 a0` (objdiff 2) where the raw pair is MATCH. An UNSIGNED declared parameter's
89
+ // `lsl` comes before any pool load, symbol or numeric: all 44 over the benchmark's agbcc
90
+ // references. A signed one need not (27 before, 2 after): the signed form carries no placement
91
+ // evidence (above), and its narrow and wide spellings compile to the same object. So only the
92
+ // WIDTH is refused, by raise/paramwidth.ts's `fused-behind-pool` reading the record; the SCALE stays
93
+ // for the array passes: `gB = gW[(u8)a]` is objdiff 2 as the raw pair and byte-exact as
94
+ // `gW[(u8)a0]`, and sa3's `DemoPlayAlloc` (`gDemoRecordings[demoIndex]` over an `s16`) is spelled
95
+ // as the subscript it is.
96
+ // The order is read off the LIFTED function (`poolOrderOf`, which says why). NOT caught: a NUMERIC
97
+ // pool word lifts to `const`, the same op a `movs` does, and paramwidth's own header says why a
98
+ // `movs` ahead of the pair decides nothing — so a body cast behind only a numeric pool load
99
+ // (`& 0xfff` over a wide parameter in a loop) still narrows.
100
+ //
101
+ // One extension per (`shl`, signedness): two folded scales of one sign off one `shl` — in two
102
+ // blocks, `sub_8010184`'s shape — share it, and opposite signs get one each. The `shl` dies when
103
+ // every reader was folded; one that keeps a raw reader (`p[t >> 22] = 1; return t;`) stays beside
104
+ // the extension.
105
+ //
106
+ // WHAT NOBODY CLAIMED goes back (`restoreUnclaimedScales`, the last pre-recovery pass). An
107
+ // extension no width pass took and a scale no array pass took print as `(u8)a1 << 12`, and that is
108
+ // NOT the same object as the lifted `a1 << 24 >> 12`: straight-line they compile alike (the `pb`
109
+ // pair above), but kleod's `SetWorldMapTilePalette` — two `u8` parameters, the first used in agbcc's
110
+ // shifted domain so paramwidth's scan stops at its `shl` and refuses the second's extension — scores
111
+ // 54/93 with the raw pair and 59/91 with the cast, the loop's r4/r6 allocation swapped. So every
112
+ // scale the fold made that still reads its extension is rewritten in place to the pair it replaced;
113
+ // what survives the pass is exactly what a consumer claimed, and everywhere else the output is the
114
+ // one the lift alone produces.
115
+ //
116
+ // That is a decision about the DEFAULT, not a finding that the raw spelling is better: the unclaimed cast
117
+ // has both signs. Over the 55 corpus functions the fold fires on, scored on the benchmark's own
118
+ // path, restoring moves four scores — `SetWorldMapTilePalette` 59/91 → 54/93 and sa3
119
+ // `UnpackSaveSector` 347 → 346 better, sa3 `ClearSave` 189 → 191 and `CompleteSave` 185 → 187
120
+ // worse, all four to what the lift alone scores (and sa3 `ValidateSave`'s denominator, 213/378 →
121
+ // 213/379). A spelling with both signs is the differ's to referee (a ranked variation), and none is
122
+ // built; the default is the one that asserts nothing the lift did not.
123
+ import { type Block, type Fn, type Op, type Value, defOpMap, mkOp, mkValue, replaceAllUsesWith } from '../ir/core';
124
+ import { CAST_WIDTHS } from '../ir/opcodes';
125
+ import { CAST_PATTERNS, patternApplies } from '../pattern/engine';
126
+ import type { TargetDescription } from '../target';
127
+
128
+ /** `shr_{u,s}(shl(src, L), R)` read as `ext(src, 32 - L) << (L - R)`. */
129
+ export interface ScaledExtension {
130
+ /** the value being narrowed — the `shl`'s operand */
131
+ src: Value;
132
+ /** the cast width, `32 - L` */
133
+ width: number;
134
+ /** `shr_s` (sign-extend) rather than `shr_u` */
135
+ signed: boolean;
136
+ /** the scale's shift amount, `L - R` */
137
+ shift: number;
138
+ /** the `shl` — where the extension begins */
139
+ inner: Op;
140
+ }
141
+
142
+ /** The fused pair's SHAPE rooted at `op`, or null — the header's refusals on the amounts and the
143
+ * two-register form. The sibling refusal is {@link foldablePairs}', the target's the caller's. */
144
+ export function scaledExtensionOf(op: Op | undefined, defs: Map<Value, Op>): ScaledExtension | null {
145
+ if (!op || (op.opcode !== 'shr_u' && op.opcode !== 'shr_s') || op.operands.length !== 1) {
146
+ return null;
147
+ }
148
+ const inner = defs.get(op.operands[0]);
149
+ const r = op.attrs.imm;
150
+ const l = inner?.attrs.imm;
151
+ if (inner?.opcode !== 'shl' || inner.operands.length !== 1 || typeof r !== 'number' || typeof l !== 'number') {
152
+ return null;
153
+ }
154
+ if (!CAST_WIDTHS.has(32 - l) || r <= 0 || r >= l) {
155
+ return null;
156
+ }
157
+ return { src: inner.operands[0], width: 32 - l, signed: op.opcode === 'shr_s', shift: l - r, inner };
158
+ }
159
+
160
+ /** Does `target` lower a narrowing cast to a shift pair — the gate the fold shares with the cast
161
+ * idiom itself. */
162
+ export const foldsShiftPairCasts = (target: TargetDescription): boolean =>
163
+ CAST_PATTERNS.every((p) => patternApplies(p, target));
164
+
165
+ /** Where the MACHINE put each entry-block op relative to its first pool-loaded address, read off a
166
+ * function whose entry block is still in lifted order. See THE BODY CAST BEHIND A POOL LOAD. */
167
+ export interface PoolOrder {
168
+ /** the function's parameters */
169
+ entryParams: ReadonlySet<Value>;
170
+ /** the entry block's ops that come after a `gaddr` */
171
+ afterPoolLoad: ReadonlySet<Op>;
172
+ }
173
+
174
+ /** Read {@link PoolOrder} off `fn`. Valid only BEFORE raise/gvn.ts's `addrnum` runs: that pass
175
+ * hoists a duplicated address to the head of the entry block, after which a `gaddr`'s position no
176
+ * longer says where the machine loaded it. The idiom patterns before it leave both a `gaddr` and a
177
+ * fused pair's `shl` where they were — a plain cast's pair is folded at its right half, which is
178
+ * why this fact is read for the FUSED form only. */
179
+ export function poolOrderOf(fn: Fn): PoolOrder {
180
+ const entry = fn.blocks[0];
181
+ const afterPoolLoad = new Set<Op>();
182
+ let seen = false;
183
+ for (const op of entry?.ops ?? []) {
184
+ if (seen) {
185
+ afterPoolLoad.add(op);
186
+ }
187
+ seen ||= op.opcode === 'gaddr';
188
+ }
189
+ return { entryParams: new Set(entry?.params ?? []), afterPoolLoad };
190
+ }
191
+
192
+ /** Is `o` a narrowing of `m.src` at `m.width` with `m`'s signedness — a right shift of a
193
+ * `shl(src, 32 - width)`, fused or plain, or the extension CAST_PATTERNS made of a plain one? The
194
+ * answer is the same before the idiom patterns and after them: they turn a plain pair into its
195
+ * extension at the pair's own position. */
196
+ function narrowsLike(o: Op, m: ScaledExtension, defs: Map<Value, Op>): boolean {
197
+ if (o.opcode === (m.signed ? 'sext' : 'zext')) {
198
+ return o.operands[0] === m.src && o.attrs.width === m.width;
199
+ }
200
+ if (o.opcode !== (m.signed ? 'shr_s' : 'shr_u') || o.operands.length !== 1) {
201
+ return false;
202
+ }
203
+ const l = 32 - m.width;
204
+ const r = o.attrs.imm;
205
+ const d = defs.get(o.operands[0]);
206
+ return (
207
+ d?.opcode === 'shl' &&
208
+ d.operands.length === 1 &&
209
+ d.operands[0] === m.src &&
210
+ d.attrs.imm === l &&
211
+ typeof r === 'number' &&
212
+ r > 0 &&
213
+ r <= l
214
+ );
215
+ }
216
+
217
+ /** Every fused pair in `fn` AS THE FOLD TAKES IT, keyed by its right shift: the shape, minus a pair
218
+ * with a same-sign sibling in its block. THE ONE READING — the fold and raise/globalshape.ts's
219
+ * stride reader both ask here, and both before any pair is rewritten; a reader that took a pair the
220
+ * fold leaves raw would license an element scale that no pass below legalizes. The TARGET half of
221
+ * the gate is the caller's: the pass list gates the fold, and globalshape asks
222
+ * {@link foldsShiftPairCasts} before it reads. */
223
+ export function foldablePairs(fn: Fn, defs: Map<Value, Op> = defOpMap(fn)): Map<Op, ScaledExtension> {
224
+ const out = new Map<Op, ScaledExtension>();
225
+ for (const b of fn.blocks) {
226
+ for (const op of b.ops) {
227
+ const m = scaledExtensionOf(op, defs);
228
+ if (m !== null && !b.ops.some((o) => o !== op && narrowsLike(o, m, defs))) {
229
+ out.set(op, m);
230
+ }
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /** What the fold made, for the two passes that read it later: raise/paramwidth.ts's
237
+ * `fused-behind-pool` gate and {@link restoreUnclaimedScales}. One per pre-recovery run
238
+ * (`PreRecoveryFacts.scales`), keyed by op identity. */
239
+ export interface ScaleRecord {
240
+ /** each scale the fold made → the extension it reads and the pair it replaced */
241
+ folded: Map<Op, { ext: Op; l: number; r: number }>;
242
+ /** the extensions over an entry parameter whose `shl` the machine ran behind a pool load */
243
+ behindPool: Set<Op>;
244
+ }
245
+
246
+ export const emptyScaleRecord = (): ScaleRecord => ({ folded: new Map(), behindPool: new Set() });
247
+
248
+ /** Rewrite every fused pair to `shl(ext(src), shift)`, the extension spliced in at the `shl`'s
249
+ * position and the scale at the right shift's, and write what it made into `record`. Returns the
250
+ * number of pairs folded; the `shl`s left without a reader are the pass driver's DCE. `order` is
251
+ * the lifted-order fact the driver read before `addrnum` (`PreRecoveryFacts.poolOrder`); the
252
+ * default reads it off `fn` itself, which is right only while `fn`'s entry block is still in lifted
253
+ * order — a test's parsed IR. */
254
+ export function foldScaledExtensions(
255
+ fn: Fn,
256
+ order: PoolOrder = poolOrderOf(fn),
257
+ record: ScaleRecord = emptyScaleRecord(),
258
+ ): number {
259
+ const defs = defOpMap(fn);
260
+ const blockOf = new Map<Op, Block>();
261
+ for (const b of fn.blocks) {
262
+ for (const op of b.ops) {
263
+ blockOf.set(op, b);
264
+ }
265
+ }
266
+ const exts = new Map<Op, Map<boolean, Op>>();
267
+ let folded = 0;
268
+ for (const [op, m] of foldablePairs(fn, defs)) {
269
+ const bySign = exts.get(m.inner) ?? exts.set(m.inner, new Map()).get(m.inner)!;
270
+ let ext = bySign.get(m.signed);
271
+ if (ext === undefined) {
272
+ ext = mkOp(m.signed ? 'sext' : 'zext', {
273
+ operands: [m.src],
274
+ results: [mkValue(m.inner.results[0].type)],
275
+ attrs: { width: m.width },
276
+ });
277
+ const home = blockOf.get(m.inner)!;
278
+ home.ops.splice(home.ops.indexOf(m.inner), 0, ext);
279
+ if (order.entryParams.has(m.src) && order.afterPoolLoad.has(m.inner)) {
280
+ record.behindPool.add(ext);
281
+ }
282
+ bySign.set(m.signed, ext);
283
+ }
284
+ const scaled = mkOp('shl', {
285
+ operands: [ext.results[0]],
286
+ results: [mkValue(op.results[0].type)],
287
+ attrs: { imm: m.shift },
288
+ });
289
+ const b = blockOf.get(op)!;
290
+ b.ops.splice(b.ops.indexOf(op), 1, scaled);
291
+ replaceAllUsesWith(fn, op.results[0], scaled.results[0]);
292
+ record.folded.set(scaled, { ext, l: 32 - m.width, r: 32 - m.width - m.shift });
293
+ folded++;
294
+ }
295
+ return folded;
296
+ }
297
+
298
+ /** Put back the machine's own pair wherever no pass below claimed what the fold exposed. Returns
299
+ * the number of scales restored; an extension left without a reader is the driver's DCE.
300
+ *
301
+ * A scale is CLAIMED when it no longer reads the fold's extension — raise/paramwidth.ts or
302
+ * raise/narrowlocal.ts retyped the value and dropped the extension, so the scale now reads that
303
+ * value — or when it is gone, legalized into an element index by raise/arrays.ts or
304
+ * raise/struct-arrays.ts. Anything else still reads the extension, and is rewritten in place (same
305
+ * result value, same position) to `shr(shl(src, L), R)`, with the `shl` where the extension stood:
306
+ * the ops the frontend lifted, in the order it lifted them. */
307
+ export function restoreUnclaimedScales(fn: Fn, record: ScaleRecord): number {
308
+ const shls = new Map<Op, Op>();
309
+ let restored = 0;
310
+ for (const b of fn.blocks) {
311
+ for (const op of [...b.ops]) {
312
+ const f = record.folded.get(op);
313
+ if (f === undefined || op.opcode !== 'shl' || op.operands[0] !== f.ext.results[0]) {
314
+ continue;
315
+ }
316
+ // The pair is rebuilt from the amounts RECORDED at the fold, so they must still be the ones
317
+ // the two ops carry: a pass that re-scaled the `shl` or re-widened the extension in place
318
+ // would make the recorded pair compute a different value. None does today; refuse, not guess.
319
+ const extended = f.ext.opcode === 'zext' || f.ext.opcode === 'sext';
320
+ if (!extended || f.ext.attrs.width !== 32 - f.l || op.attrs.imm !== f.l - f.r) {
321
+ continue;
322
+ }
323
+ let shl = shls.get(f.ext);
324
+ if (shl === undefined) {
325
+ const home = fn.blocks.find((x) => x.ops.includes(f.ext))!;
326
+ shl = mkOp('shl', {
327
+ operands: [f.ext.operands[0]],
328
+ results: [mkValue(f.ext.results[0].type)],
329
+ attrs: { imm: f.l },
330
+ });
331
+ home.ops.splice(home.ops.indexOf(f.ext), 0, shl);
332
+ shls.set(f.ext, shl);
333
+ }
334
+ op.opcode = f.ext.opcode === 'sext' ? 'shr_s' : 'shr_u';
335
+ op.operands = [shl.results[0]];
336
+ op.attrs = { imm: f.r };
337
+ record.folded.delete(op);
338
+ restored++;
339
+ }
340
+ }
341
+ return restored;
342
+ }