@asmlift/core 0.3.0 → 0.5.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 (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +130 -4
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +181 -4
  7. package/src/declare.ts +35 -9
  8. package/src/frontend/mips.ts +37 -29
  9. package/src/frontend/opaque.ts +70 -20
  10. package/src/frontend/ppc.ts +18 -7
  11. package/src/frontend/ssa.ts +279 -56
  12. package/src/frontend/thumb.ts +1372 -87
  13. package/src/ir/alias.ts +75 -0
  14. package/src/ir/opcodes.ts +57 -3
  15. package/src/ir/simplify.ts +72 -0
  16. package/src/l3/argbase.ts +221 -0
  17. package/src/l3/ast.ts +127 -5
  18. package/src/l3/basecse.ts +58 -62
  19. package/src/l3/coalesce.ts +215 -0
  20. package/src/l3/dce.ts +33 -41
  21. package/src/l3/gates.ts +67 -0
  22. package/src/l3/hoist.ts +65 -0
  23. package/src/l3/reindex.ts +7 -0
  24. package/src/l3/scopebase.ts +440 -0
  25. package/src/l3/tailmerge.ts +124 -0
  26. package/src/macros.ts +222 -13
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +65 -6
  29. package/src/raise/divpow2.ts +227 -0
  30. package/src/raise/gvn.ts +151 -0
  31. package/src/raise/pre-recovery.ts +39 -3
  32. package/src/raise/recover.ts +24 -7
  33. package/src/raise/retsink.ts +37 -7
  34. package/src/raise/shortcircuit.ts +262 -22
  35. package/src/raise/struct-arrays.ts +2 -1
  36. package/src/raise/structs.ts +41 -3
  37. package/src/rank.ts +196 -20
  38. package/src/structure/analysis.ts +175 -89
  39. package/src/structure/structure.ts +588 -55
  40. package/src/structure/switch-recover.ts +117 -30
  41. package/src/symbols.ts +128 -13
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +9 -0
@@ -0,0 +1,65 @@
1
+ // L3 — the naming MECHANISM shared by every pass that hoists a value into a fresh local.
2
+ //
3
+ // Two passes name bases today (`basecse.ts` hoists a REUSED base; `argbase.ts` names a call's
4
+ // argument bases), and they differ in POLICY — which bases are eligible, and when it is worth
5
+ // doing — but not in how a name is chosen. That half was copied, and the copy silently lost a
6
+ // safety guard: basecse added the callee-name exclusion in its own audit precisely so a hoist
7
+ // local could not shadow a called function, and the second implementation did not have it. A third
8
+ // hoisting pass would lose it again, so the mechanism lives here and the policy stays with each
9
+ // caller.
10
+ import type { Expr, SFn, Stmt } from './ast';
11
+ import { mapExprChildren, stmtChildren, stmtExprs } from './ast';
12
+
13
+ /** Every identifier a hoist name must not collide with, anywhere in `sfn`.
14
+ *
15
+ * Wider than "the declared locals" on purpose, and each addition is a real collision:
16
+ * - params and locals, obviously;
17
+ * - every `var`/`addr` mentioned — a GLOBAL is referenced by bare name, so a local shadowing one
18
+ * silently redirects every later mention of it;
19
+ * - every CALL TARGET — a local named like a callee shadows the function;
20
+ * - every assignment target, which includes names no declaration list carries. */
21
+ function takenNames(sfn: SFn): Set<string> {
22
+ const taken = new Set<string>([...sfn.params.map((p) => p.name), ...sfn.locals.map((l) => l.name)]);
23
+ const visit = (e: Expr): void => {
24
+ if (e.k === 'var' || e.k === 'addr') {
25
+ taken.add(e.name);
26
+ }
27
+ if (e.k === 'call') {
28
+ taken.add(e.fn);
29
+ }
30
+ mapExprChildren(e, (c) => {
31
+ visit(c);
32
+ return c;
33
+ });
34
+ };
35
+ const walk = (stmts: Stmt[]): void => {
36
+ for (const s of stmts) {
37
+ if (s.k === 'assign') {
38
+ taken.add(s.name);
39
+ }
40
+ stmtExprs(s).forEach(visit);
41
+ walk(stmtChildren(s));
42
+ }
43
+ };
44
+ walk(sfn.body);
45
+ return taken;
46
+ }
47
+
48
+ /**
49
+ * A generator of fresh `p<n>` hoist names for `sfn`, colliding with nothing already in it.
50
+ *
51
+ * Returned as a closure over one `taken` set so successive calls cannot collide with each OTHER
52
+ * either — the failure a caller re-deriving the set per name would hit.
53
+ */
54
+ export function nameAllocator(sfn: SFn): () => string {
55
+ const taken = takenNames(sfn);
56
+ return () => {
57
+ let n = 0;
58
+ while (taken.has(`p${n}`)) {
59
+ n++;
60
+ }
61
+ const nm = `p${n}`;
62
+ taken.add(nm);
63
+ return nm;
64
+ };
65
+ }
package/src/l3/reindex.ts CHANGED
@@ -163,10 +163,17 @@ function reindexExpr(e: Expr, walk: WalkLoop, iv: string): Expr | null {
163
163
  if (mentionsVar(e.idx, walk.p)) {
164
164
  return null; // a p-dependent element offset — beyond the v1 shape
165
165
  }
166
+ if (e.lead && e.lead.length > 0) {
167
+ return null; // leading subscripts (a multidim array global) — the rebuild below would drop
168
+ // them, turning an element access into a row's. Decline rather than reindex.
169
+ }
166
170
  const idx: Expr =
167
171
  e.idx.k === 'const' && e.idx.value === 0
168
172
  ? { k: 'var', name: iv }
169
173
  : { k: 'bin', op: '+', l: { k: 'var', name: iv }, r: e.idx };
174
+ // NOTE: this rebuilds the node from parts, so any field not named here is DROPPED. `lead` is
175
+ // declined above (the deref side); it cannot arrive on the base side either, since `walk.base`
176
+ // is a local pointer and structuring only ever puts `lead` on an array GLOBAL's own name.
170
177
  return { k: 'index', base: { k: 'var', name: walk.base }, idx, width: e.width, signed: e.signed };
171
178
  }
172
179
  let failed = false;
@@ -0,0 +1,440 @@
1
+ // L3 re-spelling lever: hoist a reused global base into a pointer local at the INNERMOST scope
2
+ // that contains all of its uses.
3
+ //
4
+ // The lever earns its place: returning `null` from `hoistScopedBases` costs
5
+ // kleod:UpdateHUDCounterDisplay its match, so the benchmark's zero-lost gate guards this file.
6
+ //
7
+ // `l3/basecse.ts` already hoists a reused leaf base — but always to the FUNCTION TOP, and only for
8
+ // an `addr`/`const` base. Both limits are load-bearing here, and each costs a real row:
9
+ //
10
+ // PLACEMENT. A base used only inside one `if` arm, hoisted to the function top, is live across
11
+ // everything before that arm — a live range the original never had, which is the register-pressure
12
+ // failure basecse's own loop gate exists for. That argument is why the lever is scope-aware; it is
13
+ // NOT a claim about what the lever achieves, and no committed measurement separates the two
14
+ // placements (the one that did edited a reference source by hand and cannot be re-run). On
15
+ // kleod:UpdateHUDCounterDisplay the primary path declines outright (a later pass retired the phi
16
+ // it keyed on, so the base's uses span the function body), and the cluster fallback below is what
17
+ // recovers it.
18
+ // basecse's header already names the gap — "a loop-body base is left
19
+ // inline for a future scope-aware hoist" — and this is that hoist.
20
+ //
21
+ // ELIGIBILITY. With a symbol map that states an array's RANK, the access renders as the bare
22
+ // `gSym[0][i]`, whose base node is a `var` naming the global, not an `addr`. basecse's
23
+ // `isHoistableBase` takes only `addr`/`const`, so the rank-aware spelling — the one a project with
24
+ // real headers actually gets — is invisible to it.
25
+ //
26
+ // WHY IT MATCHES, and it is not a readability preference: a store whose destination address the
27
+ // compiler materialized into a register before computing the source reads back as exactly this
28
+ // shape. The decomp author's alternative is a no-op read-modify-write (`g[0][K] += 0;`) purely to
29
+ // force that materialization; naming the base is the same codegen without the quirk.
30
+ //
31
+ // A LEVER, not a rewrite: emitted as an ADDITIONAL candidate (rank.ts `/scopebase`) with the
32
+ // differ refereeing, so the un-hoisted spelling is always still in the list and this can never cost
33
+ // a match.
34
+ //
35
+ // SEMANTICS ARE PRESERVED BY CONSTRUCTION. The hoisted value is a pure ADDRESS of a global — no
36
+ // load, nothing observable, nothing that can fault — so evaluating it earlier in a scope that
37
+ // DOMINATES every use is invisible. The rewritten accesses keep their own width/signedness, so
38
+ // every stride is unchanged. Domination is the load-bearing half: `collect` and `rewriteStmt` must
39
+ // walk the SAME tree, or an access the planner never placed gets repointed at a local whose
40
+ // assignment does not reach it — compiling C that reads an uninitialized pointer, which neither
41
+ // boundary contract catches (they check resolution and deref typing, not definite assignment).
42
+ //
43
+ // ORDERING: `hoistReusedGlobalBases` (basecse) runs unconditionally in `structureChecked`, BEFORE
44
+ // rank's levers see the tree. So this pass's `addr`/`const` input is only what basecse REFUSED —
45
+ // loop uses and repeated-constant-offset uses — which is why it carries basecse's const-offset gate
46
+ // rather than assuming those bases never arrive.
47
+ import { type IrType, T, scalarTypeForAccess } from '../ir/types';
48
+ import type { Expr, SFn, Stmt } from './ast';
49
+ import { mapExprChildren, stmtExprs } from './ast';
50
+ import { nameAllocator } from './hoist';
51
+
52
+ /** A base this lever may name: a leaf whose value is a fixed address.
53
+ *
54
+ * `var` is included ONLY for a name in `SFn.globals`. That list is populated by `noteGlobal` alone
55
+ * (two call sites in structure.ts, both on the `bareArrayLead` path, which requires
56
+ * `shape === 'array'`) — so a `var` base here is always an ARRAY-declared global and `(T *)&gSym`
57
+ * is its start address under any declaration. The invariant is worth stating because it is what
58
+ * keeps a POINTER-shaped global out: for one of those, `(T *)&gPtr` names the pointer CELL rather
59
+ * than the object it points at, which would be silently the wrong address. A local `var` is
60
+ * excluded for the ordinary reason: it can be assigned between the hoist point and a use. */
61
+ type LeafBase = Extract<Expr, { k: 'addr' } | { k: 'const' } | { k: 'var' }>;
62
+ const isLeaf = (e: Expr, globals: ReadonlySet<string>): e is LeafBase =>
63
+ e.k === 'addr' || e.k === 'const' || (e.k === 'var' && globals.has(e.name));
64
+
65
+ /** THE identity of a base — what makes two accesses "the same address".
66
+ *
67
+ * A global reaches L3 under two spellings, `addr g` and the bare `var g`, and they denote the same
68
+ * cell; keying on the NAME alone means a function that mixes them still sees one base. */
69
+ const baseId = (b: LeafBase): string => (b.k === 'const' ? `c:${b.value}` : `n:${b.name}`);
70
+
71
+ /** An access this lever may re-point, or null.
72
+ *
73
+ * REFUSES a non-zero `lead`. `lead` pins the leading subscripts of a multidimensional array, so
74
+ * `g[1][i]` is a whole ROW past `g[0][i]`. The hoisted local points at the START of the object, and
75
+ * the rewrite DROPS the lead — sound only when every leading subscript is 0. A non-zero lead would
76
+ * silently address the wrong row, which no contract checks: the tree stays well-typed and
77
+ * spellable, it just names different bytes. (Today `bareArrayLead` only ever emits zeros; this
78
+ * guard is what keeps that an implementation detail rather than a correctness dependency.) */
79
+ function eligible(e: Expr, globals: ReadonlySet<string>): Extract<Expr, { k: 'index' }> | null {
80
+ if (e.k !== 'index' || !isLeaf(e.base, globals)) {
81
+ return null;
82
+ }
83
+ return (e.lead ?? []).every((n) => n === 0) ? e : null;
84
+ }
85
+
86
+ /** The (base, access-shape) key an access shares with its reuse siblings. Width and signedness are
87
+ * part of it because the hoisted local carries the access's pointer type — two widths through one
88
+ * base are two different locals, exactly as in basecse. */
89
+ const keyOf = (n: Extract<Expr, { k: 'index' }>): string => `${baseId(n.base as LeafBase)} ${n.width} ${n.signed}`;
90
+
91
+ /** One use, located by its chain of enclosing statement LISTS (outermost first).
92
+ *
93
+ * `loop[i]` says whether `path[i]` is a LOOP BODY. Recorded here, at the only point the tree walk
94
+ * actually knows it, so the loop question below is a lookup rather than a second traversal that
95
+ * could disagree with this one. */
96
+ interface Site {
97
+ path: Stmt[][];
98
+ loop: boolean[];
99
+ /** `idx[i]` is the index, within `path[i]`, of the statement this use sits under. Used to place
100
+ * the hoist immediately before the FIRST statement that needs it rather than at the list head:
101
+ * a call between the assignment and the first use is exactly what forces the pointer into a
102
+ * CALLEE-SAVED register and adds the prologue push/pop the original avoided — the same failure,
103
+ * one level smaller, that this module exists to fix. argbase.ts places by the same rule. */
104
+ idx: number[];
105
+ /** the use runs EVERY ITERATION of a loop whose body is not on `path` — a loop's own condition,
106
+ * or a `for`'s increment. No scope reachable from `path` runs at that cadence, so a key with any
107
+ * such use is refused outright rather than hoisted to a point that runs once. */
108
+ perIteration: boolean;
109
+ }
110
+
111
+ /** Set when the tree holds a shape `collect` and `rewriteStmt` would disagree about — see the
112
+ * `for`-part note below. The pass then declines outright. */
113
+ let compound = false;
114
+
115
+ /** Walk every expression in the tree, recording each eligible access's key and its scope path. */
116
+ function collect(
117
+ body: Stmt[],
118
+ globals: ReadonlySet<string>,
119
+ out: Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }>; constOff: Map<number, number> }>,
120
+ path: Stmt[][],
121
+ loop: boolean[],
122
+ idxPath: number[],
123
+ ): void {
124
+ let at = 0;
125
+ const visit = (e: Expr, perIteration: boolean): void => {
126
+ const ix = eligible(e, globals);
127
+ if (ix) {
128
+ const k = keyOf(ix);
129
+ const rec = out.get(k) ?? { uses: [], sample: ix, constOff: new Map<number, number>() };
130
+ rec.uses.push({ path, loop, perIteration, idx: [...idxPath, at] });
131
+ if (ix.idx.k === 'const') {
132
+ rec.constOff.set(ix.idx.value, (rec.constOff.get(ix.idx.value) ?? 0) + 1);
133
+ }
134
+ out.set(k, rec);
135
+ }
136
+ mapExprChildren(e, (c) => {
137
+ visit(c, perIteration);
138
+ return c;
139
+ });
140
+ };
141
+ for (const [i, s] of body.entries()) {
142
+ at = i;
143
+ const isLoop = s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
144
+ // A loop's OWN condition runs every iteration — a base there is loop-invariant exactly as a
145
+ // body use is, and it lives at THIS list, which does not. basecse.ts and argbase.ts treat the
146
+ // CONDITION the same way. They do NOT agree about a `for`'s `init`: basecse counts it in-loop
147
+ // (its `stmtChildren('for')` is `[init, inc, …body]`, recursed with `nested`), this pass counts
148
+ // it at the enclosing cadence, which is the truthful reading — it runs once. Recorded because
149
+ // the divergence is real and an extraction has to pick one; both readings are pinned in
150
+ // test/addr-placement.test.ts so the pick is deliberate rather than whichever survives.
151
+ stmtExprs(s).forEach((e) => visit(e, isLoop));
152
+ if (s.k === 'for') {
153
+ // `init`/`inc` are typed as the full Stmt union, so a COMPOUND one is type-legal. `stmtExprs`
154
+ // reaches only its own expressions while `rewriteStmt` descends into any nested list — the
155
+ // round-1 walker asymmetry, one node kind deeper, and the fuzz reproduces it (a use inside
156
+ // `for (if (1) i = g[3]; …)` gets repointed at a local the `if` arm may never have assigned).
157
+ // No producer emits a compound part today (structure.ts and reindex.ts both emit `assign`), so
158
+ // rather than grow a second recursion this REFUSES the whole function — loud decline over a
159
+ // silently unreachable definition. Delete this when `stmtLists` makes collect/rewrite share
160
+ // one traversal.
161
+ if (childLists(s.init).length > 0 || childLists(s.inc).length > 0) {
162
+ compound = true;
163
+ }
164
+ // `init` and `inc` are STATEMENTS, so their expressions are reached by neither `stmtExprs`
165
+ // nor `childLists` — yet `rewriteStmt` rewrites them. Collect and rewrite MUST see the same
166
+ // tree: an access the planner never counted would still be repointed, at a local whose
167
+ // assignment need not dominate it (`for (i = p0[3]; …)` after an `if` arm that defines p0).
168
+ // `init` runs once, at this list's cadence; `inc` runs every iteration, like the condition.
169
+ stmtExprs(s.init).forEach((e) => visit(e, false));
170
+ stmtExprs(s.inc).forEach((e) => visit(e, true));
171
+ }
172
+ for (const child of childLists(s)) {
173
+ collect(child, globals, out, [...path, child], [...loop, isLoop], [...idxPath, i]);
174
+ }
175
+ }
176
+ }
177
+
178
+ /** The nested statement LISTS of a statement — the scopes a hoist could land in.
179
+ *
180
+ * Deliberately not `stmtChildren`, which flattens a `for`'s `init`/`inc` in with its body: those
181
+ * are single statements, not lists, and a hoist has nowhere legal to go in either (before the loop
182
+ * changes when it runs, inside the body repeats it). A `for`'s body IS a list and is included. */
183
+ function childLists(s: Stmt): Stmt[][] {
184
+ switch (s.k) {
185
+ case 'if':
186
+ return [s.then, s.else];
187
+ case 'while':
188
+ case 'dowhile':
189
+ case 'for':
190
+ return [s.body];
191
+ case 'switch':
192
+ return [...s.cases.map((c) => c.body), ...(s.default ? [s.default] : [])];
193
+ // Exhaustive on purpose — no `default`. A future Stmt kind carrying a nested list must be a
194
+ // COMPILE error here, exactly as it is in `stmtChildren`: a silent `[]` would collect that
195
+ // kind's uses at the wrong scope while `rewriteStmt`, which IS exhaustive, still rewrote them.
196
+ case 'assign':
197
+ case 'store':
198
+ case 'exprstmt':
199
+ case 'return':
200
+ case 'break':
201
+ case 'continue':
202
+ return [];
203
+ }
204
+ }
205
+
206
+ /** The innermost statement list common to every use, or null when they span the function body.
207
+ *
208
+ * Null is NOT a decline any more: the caller falls through to `deepestCluster`. Kept as a distinct
209
+ * answer because "one scope holds everything" is the better shape when it exists — every use is
210
+ * named, not just a cluster. The consolidation this file still owes would make both of these one
211
+ * selector parameter over a single collected index. */
212
+ function commonScope(uses: Site[]): { scope: Stmt[]; depth: number } | null {
213
+ const first = uses[0].path;
214
+ let depth = 0;
215
+ while (depth < first.length && uses.every((u) => u.path[depth] === first[depth])) {
216
+ depth++;
217
+ }
218
+ return depth === 0 ? null : { scope: first[depth - 1], depth };
219
+ }
220
+
221
+ /** The DEEPEST statement list holding 2+ uses, with just those uses — the fallback when no single
222
+ * scope holds them all.
223
+ *
224
+ * Ties are broken by first appearance, so emission stays deterministic. Returning a SUBSET is the
225
+ * whole point: the uses outside the cluster keep their original spelling, which is exactly the
226
+ * mixed form the compiler produces when it materializes an address in one arm and re-derives it
227
+ * elsewhere. */
228
+ function deepestCluster(all: Site[]): { scope: Stmt[]; depth: number; uses: Site[] } | null {
229
+ const byList = new Map<Stmt[], { depth: number; uses: Site[] }>();
230
+ for (const u of all) {
231
+ u.path.forEach((list, i) => {
232
+ const e = byList.get(list) ?? { depth: i + 1, uses: [] };
233
+ e.uses.push(u);
234
+ byList.set(list, e);
235
+ });
236
+ }
237
+ let best: { scope: Stmt[]; depth: number; uses: Site[] } | null = null;
238
+ for (const [scope, e] of byList) {
239
+ if (e.uses.length >= 2 && (best === null || e.depth > best.depth)) {
240
+ best = { scope, depth: e.depth, uses: e.uses };
241
+ }
242
+ }
243
+ return best;
244
+ }
245
+
246
+ /** Does any use sit inside a LOOP nested below the chosen scope?
247
+ *
248
+ * OVER-REFUSES in two shapes, deliberately: a `do { … } while (g[1]) ;` body head and a
249
+ * `for (…; …; i = g[5])` body head both DO run at the flagged cadence, so a hoist there would be
250
+ * legal. Refusing them costs a missed spelling and nothing else (bench: 0 lost, 0 gained), and the
251
+ * precise rule needs the loop-DEPTH model an extraction would bring. Otherwise:
252
+ * the hoist would be loop-invariant code motion to a point the original never had — the
253
+ * register-pressure failure `basecse.ts`'s own `inLoop` gate refuses, and the reason that gate
254
+ * exists. When EVERY use is inside the loop, the common scope IS the loop body: the assignment
255
+ * then runs per iteration exactly as the inline spelling did, and there is nothing to refuse. */
256
+ function underNestedLoop(uses: Site[], depth: number): boolean {
257
+ return uses.some((u) => u.perIteration || u.loop.slice(depth).some(Boolean));
258
+ }
259
+
260
+ /**
261
+ * The `/scopebase` re-spelling, or null when nothing qualifies (the caller then adds no candidate
262
+ * rather than a duplicate of the primary).
263
+ */
264
+ export function hoistScopedBases(sfn: SFn): SFn | null {
265
+ compound = false;
266
+ // A name that is BOTH a declared global and a local/param is not safely a global here: `&g` would
267
+ // take the address of the LOCAL, silently a different object. Excluded rather than assumed apart.
268
+ const shadowed = new Set([...sfn.locals.map((l) => l.name), ...sfn.params.map((p) => p.name)]);
269
+ const globals = new Set((sfn.globals ?? []).map((g) => g.name).filter((n) => !shadowed.has(n)));
270
+ const found = new Map<
271
+ string,
272
+ { uses: Site[]; sample: Extract<Expr, { k: 'index' }>; constOff: Map<number, number> }
273
+ >();
274
+ collect(sfn.body, globals, found, [], [], []);
275
+ if (compound) {
276
+ return null;
277
+ }
278
+
279
+ const fresh = nameAllocator(sfn);
280
+ // key → (scope list identity, local name)
281
+ const plan: { scope: Stmt[]; key: string; name: string; type: IrType; base: LeafBase; before: number }[] = [];
282
+ for (const [key, rec] of found) {
283
+ if (rec.uses.length < 2) {
284
+ continue; // one access re-materializes as cheaply as a named local
285
+ }
286
+ // A constant offset touched 2+ times is a SCALAR re-access at one fixed location (an MMIO
287
+ // read-modify-write, a repeated `*p`), which the compiler re-materializes rather than
288
+ // register-holds. basecse.ts learned this by LOSING the ProcessHBlankWait match to it. Inherited
289
+ // here rather than re-lost — but honestly: the evidence is a `const` MMIO address, and it
290
+ // applies cleanly only to the `addr`/`const` half of this pass's input, which is exactly what
291
+ // basecse refused and left behind. For the `var` (array-global) half basecse never ran, so this
292
+ // is an EXTRAPOLATION, not an inheritance. Conservative direction, so the cost is a missed
293
+ // hoist rather than a wrong one. It also SLIPS on a fixed offset not spelled as a literal —
294
+ // two identical `g[i]` accesses are not tallied — which basecse acknowledges in its own comment
295
+ // and which this pass is MORE exposed to, since it deliberately admits loop-body uses, exactly
296
+ // the input basecse's `inLoop` gate kept away from that hole.
297
+ if ([...rec.constOff.values()].some((n) => n >= 2)) {
298
+ continue;
299
+ }
300
+ let at = commonScope(rec.uses);
301
+ let uses = rec.uses;
302
+ if (!at) {
303
+ // The uses span the FUNCTION BODY, so no single scope holds them. Rather than decline, take a
304
+ // scope that holds two or more and name the base for THOSE only, leaving the rest as they
305
+ // were.
306
+ //
307
+ // The selection rule is DEEPEST, with no size term, and that is a real limitation rather than
308
+ // a model of the compiler: a scope with four uses enclosing a nested scope with two will name
309
+ // the TWO and leave the four re-deriving the address. Only ONE cluster is ever served, and
310
+ // when two siblings tie on depth the first-appearing wins — arbitrary, not principled.
311
+ // Largest-cluster-with-deepest-as-tie-break is the better rule; it is a behaviour change and
312
+ // belongs with the placement-selector consolidation, not bolted on here.
313
+ //
314
+ // NOTE this fires for an `addr`/`const` base too — nothing here tests the base kind. That is
315
+ // not a duplicate of basecse's hoist: basecse runs FIRST (see the ordering note in the file
316
+ // header), so any `addr`/`const` base reaching this pass is one basecse already REFUSED.
317
+ const cluster = deepestCluster(rec.uses);
318
+ if (!cluster) {
319
+ continue;
320
+ }
321
+ at = { scope: cluster.scope, depth: cluster.depth };
322
+ uses = cluster.uses;
323
+ }
324
+ if (underNestedLoop(uses, at.depth)) {
325
+ continue;
326
+ }
327
+ const type = T.ptr(scalarTypeForAccess(rec.sample.width, rec.sample.signed));
328
+ // the earliest statement of the scope list that (transitively) holds a use
329
+ // `path` starts EMPTY, so `idx` carries one entry more than `path`: idx[j+1] is the index
330
+ // within path[j]. The scope is path[depth-1], so its index is idx[depth].
331
+ const before = Math.min(...uses.map((u) => u.idx[at.depth]));
332
+ plan.push({ scope: at.scope, key, name: fresh(), type, base: rec.sample.base as LeafBase, before });
333
+ }
334
+ if (plan.length === 0) {
335
+ return null;
336
+ }
337
+
338
+ // A plan entry may own only a SUBSET of its key's uses (see deepestCluster), so repointing is
339
+ // scoped: a key becomes active when the rewrite enters its scope and inactive on the way out.
340
+ // Repointing by key alone would rewrite uses the hoist does not dominate.
341
+ // SAFE ONLY because `plan` holds at most one entry per key, so `delete` on the way out cannot
342
+ // discard an outer binding. Serving a second cluster for one key — the obvious next step — makes
343
+ // that false, and an inner delete would silently unbind the outer one for the rest of its scope:
344
+ // a use of an unassigned pointer, the defect class this module has already shipped twice. Switch
345
+ // to save/restore (or pass the bindings as an argument) before serving more than one cluster.
346
+ const active = new Map<string, string>();
347
+ const point = (e: Expr): Expr => {
348
+ const ix = eligible(e, globals);
349
+ if (ix) {
350
+ const nm = active.get(keyOf(ix));
351
+ if (nm) {
352
+ // `lead` is DROPPED — the local already points at the object start, and `eligible` has
353
+ // established every leading subscript is 0.
354
+ const { lead: _drop, ...rest } = ix;
355
+ return { ...rest, base: { k: 'var', name: nm }, idx: point(ix.idx) };
356
+ }
357
+ }
358
+ return mapExprChildren(e, point);
359
+ };
360
+
361
+ // Rebuild the tree, inserting each hoist at the head of its own scope list. Statement lists are
362
+ // matched by IDENTITY against the ORIGINAL tree, so the rewrite walks the original and emits a
363
+ // fresh tree in one pass — a two-pass version would compare rebuilt lists that no longer match.
364
+ const rewriteList = (list: Stmt[]): Stmt[] => {
365
+ const here = plan.filter((p) => p.scope === list);
366
+ // SAVE/RESTORE, not set/delete. A plain delete on the way out is correct only while `plan`
367
+ // holds one entry per key; the moment a second cluster for one key is served, an inner exit
368
+ // would unbind an OUTER hoist for the rest of its scope — under-repointing silently. Restoring
369
+ // makes the nesting correct by construction instead of by an unguarded invariant.
370
+ const saved = here.map((p) => [p.key, active.get(p.key)] as const);
371
+ for (const p of here) {
372
+ active.set(p.key, p.name);
373
+ }
374
+ const rewritten = list.map(rewriteStmt);
375
+ for (const [key, prev] of saved) {
376
+ if (prev === undefined) {
377
+ active.delete(key);
378
+ } else {
379
+ active.set(key, prev);
380
+ }
381
+ }
382
+ // Insert each hoist immediately before the first statement that uses it. Descending by index so
383
+ // earlier insertions do not shift the positions later ones were computed against. NOTE that two
384
+ // hoists sharing a `before` come out REVERSED relative to `plan` order — the sort is stable and
385
+ // descending, so both splice at the same index and the later one ends up first. Deterministic
386
+ // and semantically irrelevant, but it is not first-appearance order, which this comment used to
387
+ // claim.
388
+ for (const p of [...here].sort((a, b) => b.before - a.before)) {
389
+ rewritten.splice(p.before, 0, {
390
+ k: 'assign',
391
+ name: p.name,
392
+ // The always-valid form: `(T *)&gSym` is byte-identical under ANY declaration of gSym, which
393
+ // is why it is also what `bareArrayLead` falls back to. A `const` base keeps its literal.
394
+ value: { k: 'cast', to: p.type, e: p.base.k === 'const' ? p.base : { k: 'addr', name: p.base.name } },
395
+ });
396
+ }
397
+ return rewritten;
398
+ };
399
+ const rewriteStmt = (s: Stmt): Stmt => {
400
+ switch (s.k) {
401
+ case 'assign':
402
+ return { ...s, value: point(s.value) };
403
+ case 'store':
404
+ return { ...s, lval: point(s.lval), value: point(s.value) };
405
+ case 'exprstmt':
406
+ return { ...s, value: point(s.value) };
407
+ case 'return':
408
+ return s.value === undefined ? s : { ...s, value: point(s.value) };
409
+ case 'if':
410
+ return { ...s, cond: point(s.cond), then: rewriteList(s.then), else: rewriteList(s.else) };
411
+ case 'while':
412
+ case 'dowhile':
413
+ return { ...s, cond: point(s.cond), body: rewriteList(s.body) };
414
+ case 'for':
415
+ return {
416
+ ...s,
417
+ init: rewriteStmt(s.init),
418
+ cond: point(s.cond),
419
+ inc: rewriteStmt(s.inc),
420
+ body: rewriteList(s.body),
421
+ };
422
+ case 'switch':
423
+ return {
424
+ ...s,
425
+ scrutinee: point(s.scrutinee),
426
+ cases: s.cases.map((c) => ({ ...c, body: rewriteList(c.body) })),
427
+ ...(s.default ? { default: rewriteList(s.default) } : {}),
428
+ };
429
+ case 'break':
430
+ case 'continue':
431
+ return s;
432
+ }
433
+ };
434
+
435
+ const body = rewriteList(sfn.body);
436
+ // Declared from `plan`, one per hoist — NOT accumulated inside `rewriteList`, which would emit a
437
+ // duplicate declaration (non-compiling C) if a `Stmt[]` were ever structurally shared by two tree
438
+ // positions.
439
+ return { ...sfn, body, locals: [...sfn.locals, ...plan.map((p) => ({ name: p.name, type: p.type }))] };
440
+ }
@@ -0,0 +1,124 @@
1
+ // L3 structural simplification: a statement that ends EVERY arm of an `if` moves below the `if`.
2
+ //
3
+ // SSA destruction puts the same merge-variable write at the end of each arm, because each arm is
4
+ // where that edge's copy belongs:
5
+ //
6
+ // if (c) { v4 = 1; } else { g[594] = g[659]; v4 = 1; }
7
+ //
8
+ // The source wrote it once. Both arms execute it LAST on their own path, so hoisting it below the
9
+ // `if` runs it exactly once, on the same paths, in the same order relative to everything else —
10
+ // which is why this needs no liveness or dominance analysis and holds even for a side-effecting
11
+ // statement. It is the merge direction that is unconditionally sound: hoisting a common HEAD above
12
+ // the `if` would move it across the condition's own evaluation, which is not.
13
+ //
14
+ // Runs BEFORE `eliminateDeadStores`, whose empty-then peephole then flips the arm this empties:
15
+ //
16
+ // if (c) { } else { g[594] = g[659]; } v4 = 1; → if (!c) { g[594] = g[659]; } v4 = 1;
17
+ //
18
+ // THE BENCHMARK DOES NOT GUARD THIS PASS. It fires on two of its 743 rows and both match with the
19
+ // merge and without it, so no score moves if this file breaks — and none moves if the pipeline
20
+ // simply stops calling it. `test/tailmerge.test.ts` covers that gap explicitly, end to end, on one
21
+ // of those two functions; the rest of that file calls this pass directly and cannot see an unwiring.
22
+ //
23
+ // Placement is not a matter of taste: peeling the same statements ABOVE the `if` is a THIRD option,
24
+ // unsound for its own reason (it crosses the condition as well as both arms). The soundness argument
25
+ // above covers only below-vs-in-arms.
26
+ //
27
+ // KNOWN INTERACTIONS, both byte-level rather than soundness. This pass is unconditional like
28
+ // `dce.ts` and `basecse.ts` rather than a differ-refereed lever, and the argument those files each
29
+ // state for themselves applies here too and was missing: a wrong merge changes recompiled bytes and
30
+ // surfaces as a LOST match under the zero-lost gate, never as wrong C.
31
+ //
32
+ // - it DEFEATS basecse's scalar-fixed-offset gate. That gate counts repeated constant offsets
33
+ // function-wide and refuses to hoist them; it was bought by losing the ProcessHBlankWait match.
34
+ // Deleting an arm's duplicate drops the count 2→1, so a base that gate would have refused is now
35
+ // hoisted. Systematic, not incidental.
36
+ // - it does NOT reach a fixpoint with `eliminateDeadStores`. A DIFFERING DEAD statement at the end
37
+ // of the arms hides the common tail, and DCE only removes it afterwards, so the shape this pass
38
+ // exists for is missed. The fix is a fixpoint of the pair, not one extra call — a lone second
39
+ // pass leaves an empty `if` behind.
40
+ //
41
+ // SCOPE. Only `assign`/`store`/`exprstmt` merge, compared structurally through `exprEquals`.
42
+ // Control flow (`break`/`continue`/`return`) is excluded: moving one out of an arm changes which
43
+ // statements the arm can still reach. Nested `if`/loop/`switch` statements are excluded because
44
+ // comparing them needs a full `Stmt` congruence, and there is no second inhabitant for one — the
45
+ // `Expr`-level comparison is the part that already exists, is tested, and is all this needs.
46
+ //
47
+ // An `ASMLIFT_ERROR` marker ending both arms merges like anything else. The gap stays loud (the
48
+ // artifact still refuses to compile) but `collectMarkers` then reports it once rather than twice,
49
+ // which is accurate — it is one gap that ran on both paths.
50
+ import type { SFn, Stmt } from './ast';
51
+ import { exprEquals } from './ast';
52
+
53
+ /** Statements this pass may move. Deliberately narrow — see SCOPE. */
54
+ type Mergeable = Extract<Stmt, { k: 'assign' } | { k: 'store' } | { k: 'exprstmt' }>;
55
+ const isMergeable = (s: Stmt): s is Mergeable => s.k === 'assign' || s.k === 'store' || s.k === 'exprstmt';
56
+
57
+ /** Do these two statements write the same thing from the same expression? */
58
+ function sameStmt(a: Stmt, b: Stmt): boolean {
59
+ if (!isMergeable(a) || !isMergeable(b) || a.k !== b.k) {
60
+ return false;
61
+ }
62
+ if (a.k === 'assign' && b.k === 'assign') {
63
+ return a.name === b.name && exprEquals(a.value, b.value);
64
+ }
65
+ if (a.k === 'store' && b.k === 'store') {
66
+ return exprEquals(a.lval, b.lval) && exprEquals(a.value, b.value);
67
+ }
68
+ const av = (a as Extract<Stmt, { k: 'exprstmt' }>).value;
69
+ const bv = (b as Extract<Stmt, { k: 'exprstmt' }>).value;
70
+ return exprEquals(av, bv);
71
+ }
72
+
73
+ /** Rewrite one statement, then the list it lives in. */
74
+ function rewrite(s: Stmt): Stmt[] {
75
+ const list = (xs: Stmt[]): Stmt[] => xs.flatMap(rewrite);
76
+ switch (s.k) {
77
+ case 'if': {
78
+ const then = list(s.then);
79
+ const els = list(s.else);
80
+ // Peel from the END of both arms while they agree. The length test is a precondition of the
81
+ // NEXT peel, not a floor: `if (c) { a } else { a }` peels until BOTH arms are empty, which is
82
+ // fine — `eliminateDeadStores` then drops the `if` and keeps its condition as an `exprstmt`
83
+ // only when the condition itself has a side effect. Note what that means for a condition that
84
+ // is a memory LOAD: a compare-and-branch present in the asm disappears from the emitted C,
85
+ // and the surviving bare `*(u16 *)&gReg;` is something the compiler may elide — so a read the
86
+ // original performed unconditionally becomes one it may not. Byte-level, and the differ sees
87
+ // it, but it is the one shape where a zero-arm merge is qualitatively unlike a partial one.
88
+ const tail: Stmt[] = [];
89
+ while (then.length > 0 && els.length > 0 && sameStmt(then[then.length - 1], els[els.length - 1])) {
90
+ tail.unshift(then[then.length - 1]);
91
+ then.pop();
92
+ els.pop();
93
+ }
94
+ return [{ ...s, then, else: els }, ...tail];
95
+ }
96
+ case 'while':
97
+ case 'dowhile':
98
+ return [{ ...s, body: list(s.body) }];
99
+ case 'for':
100
+ return [{ ...s, body: list(s.body) }];
101
+ case 'switch':
102
+ // Case bodies are NOT merged: a case that falls through to the next has no "end" of its own,
103
+ // so peeling its last statement would move code across a fall-through boundary.
104
+ return [
105
+ {
106
+ ...s,
107
+ cases: s.cases.map((c) => ({ ...c, body: list(c.body) })),
108
+ ...(s.default ? { default: list(s.default) } : {}),
109
+ },
110
+ ];
111
+ case 'assign':
112
+ case 'store':
113
+ case 'exprstmt':
114
+ case 'return':
115
+ case 'break':
116
+ case 'continue':
117
+ return [s];
118
+ }
119
+ }
120
+
121
+ /** Move every statement that ends all arms of an `if` below that `if`. */
122
+ export function mergeCommonTails(sfn: SFn): SFn {
123
+ return { ...sfn, body: sfn.body.flatMap(rewrite) };
124
+ }