@asmlift/core 0.5.0 → 0.6.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 (86) 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 +238 -167
  5. package/src/backend/cpp.ts +1 -0
  6. package/src/backend/pascal.ts +26 -12
  7. package/src/contracts.ts +194 -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 +54 -0
  14. package/src/ir/bits.ts +75 -0
  15. package/src/ir/core.ts +337 -2
  16. package/src/ir/opcodes.ts +140 -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/argbase.ts +2 -1
  24. package/src/l3/ast.ts +464 -57
  25. package/src/l3/basecse.ts +664 -76
  26. package/src/l3/coalesce.ts +429 -43
  27. package/src/l3/dce.ts +31 -9
  28. package/src/l3/gates.ts +21 -0
  29. package/src/l3/hoist.ts +293 -14
  30. package/src/l3/homesplit.ts +285 -0
  31. package/src/l3/initfirst.ts +301 -0
  32. package/src/l3/inlinebase.ts +193 -0
  33. package/src/l3/mentions.ts +113 -0
  34. package/src/l3/mulfirst.ts +42 -0
  35. package/src/l3/nearbase.ts +152 -0
  36. package/src/l3/offmember.ts +371 -0
  37. package/src/l3/parkfirst.ts +96 -0
  38. package/src/l3/pollguard.ts +154 -0
  39. package/src/l3/ptrfield.ts +227 -0
  40. package/src/l3/regspell.ts +110 -85
  41. package/src/l3/reindex.ts +715 -78
  42. package/src/l3/scopebase.ts +644 -218
  43. package/src/l3/sinkinit.ts +40 -0
  44. package/src/l3/slotorder.ts +123 -0
  45. package/src/l3/storage.ts +48 -0
  46. package/src/l3/symbol-refs.ts +41 -8
  47. package/src/l3/tailmerge.ts +15 -0
  48. package/src/l3/typing.ts +198 -9
  49. package/src/l3/unmerge.ts +263 -0
  50. package/src/l3/unreduce.ts +971 -0
  51. package/src/l3/volatileptr.ts +207 -0
  52. package/src/l3/volatileval.ts +130 -0
  53. package/src/l3/volstore.ts +229 -0
  54. package/src/l3/zerosub.ts +62 -0
  55. package/src/pattern/engine.ts +236 -13
  56. package/src/pipeline.ts +157 -56
  57. package/src/proto.ts +112 -14
  58. package/src/raise/arrays.ts +6 -1
  59. package/src/raise/divpow2.ts +2 -2
  60. package/src/raise/globalshape.ts +1038 -0
  61. package/src/raise/gvn.ts +33 -18
  62. package/src/raise/latch.ts +126 -0
  63. package/src/raise/memberarrays.ts +594 -0
  64. package/src/raise/narrow.ts +124 -0
  65. package/src/raise/narrowlocal.ts +556 -0
  66. package/src/raise/paramwidth.ts +179 -0
  67. package/src/raise/pre-recovery.ts +97 -14
  68. package/src/raise/recover.ts +56 -23
  69. package/src/raise/retsink.ts +210 -10
  70. package/src/raise/shortcircuit.ts +474 -74
  71. package/src/raise/struct-arrays.ts +19 -2
  72. package/src/raise/structs.ts +33 -3
  73. package/src/rank-axes.ts +630 -0
  74. package/src/rank-declare.ts +256 -0
  75. package/src/rank.ts +1723 -272
  76. package/src/structure/analysis.ts +1392 -141
  77. package/src/structure/bitfields.ts +332 -0
  78. package/src/structure/globalaccess.ts +274 -0
  79. package/src/structure/hazards.ts +411 -20
  80. package/src/structure/loops.ts +2 -49
  81. package/src/structure/namecoalesce.ts +435 -0
  82. package/src/structure/structure.ts +2678 -526
  83. package/src/structure/switch-recover.ts +616 -144
  84. package/src/symbols.ts +62 -1
  85. package/src/target.ts +367 -24
  86. package/src/trace.ts +111 -32
@@ -1,13 +1,36 @@
1
+ // THE OTHER COALESCER is `structure/namecoalesce.ts`, and the dividing line is worth stating: it
2
+ // merges the SOURCE AND DESTINATION OF A COPY, which is copy coalescing; this one merges two
3
+ // UNRELATED locals whose spans are disjoint, which is register reuse. Neither subsumes the other —
4
+ // a copy pair overlaps by construction, so `overlap` below rejects every candidate that one takes.
5
+ //
6
+ // TWO admission paths live here, each with its own gate table and its own reading of loops. The
7
+ // SPAN path (COALESCE_GATES) proves disjoint liveness from preorder position, so it asks which
8
+ // loops RE-RUN a mention of each local and refuses a pair only when one loop holds both. The
9
+ // ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one `if` picks
10
+ // between them, so it asks only whether ANY loop encloses that `if` — a second entry breaks the
11
+ // argument however the arms' own loops relate. `coalesceCandidates` offers both.
1
12
  import { typeToString } from '../ir/types';
2
13
  import type { Expr, SFn, Stmt } from './ast';
3
14
  import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
4
15
  import { type Gate, firstRejection } from './gates';
5
16
 
17
+ /** THE loop-kind test, shared by both admission paths in this file — the span model's enclosure
18
+ * walk and the arm path's `visit`. */
19
+ const isLoop = (s: Stmt): boolean => s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
20
+
6
21
  function namesIn(e: Expr, out: Set<string>): void {
7
- // `addr` names a GLOBAL, never a localcollected anyway. A name reaching BOTH forms would
8
- // otherwise get a span that ignores its `addr` mentions, and a SHORT span is a clobber while a
9
- // long one is only a missed merge. `structure.ts` keeps locals to /^[vt]\d+$/ and excludes global
10
- // names, so this cannot fire today; collecting is the direction that stays safe if that changes.
22
+ // `addr` names a GLOBAL (`&gSym`) or a LOCALthe structurer renders an `laddr` frame object
23
+ // as `&sp0`, an addr node over a name that IS in `sfn.locals`. Both are collected, because a
24
+ // name mentioned only through `&` still has a live range: a span that ignored its `addr`
25
+ // mentions would be SHORT, and a short span is a clobber where a long one is only a missed
26
+ // merge. Collecting a global name costs nothing — it is not in `sfn.locals`, so no pair is
27
+ // ever built for it.
28
+ //
29
+ // `rename` DISAGREES WITH THIS, and knowingly: it rewrites only `var` leaves, so a local
30
+ // absorbed while mentioned through `&` leaves that mention standing against a declaration the
31
+ // merge deleted. Reconciling the two changes which candidates compile — a measured change, not a
32
+ // cleanup — so today's behaviour is pinned exactly in coalesce.test.ts ('rename') rather than
33
+ // repaired here.
11
34
  if (e.k === 'var' || e.k === 'addr') out.add(e.name);
12
35
  for (const c of exprChildren(e)) namesIn(c, out);
13
36
  }
@@ -21,39 +44,116 @@ function mentions(e: Expr, n: string): boolean {
21
44
  export interface Span {
22
45
  first: number;
23
46
  last: number;
24
- inLoop: boolean;
47
+ /** every loop that RE-RUNS a mention of the local — the whole ancestor chain, not just the
48
+ * innermost, because an outer loop re-runs an inner one's statements too. A `for`'s init is
49
+ * not re-run by its own loop, so it contributes only the enclosing ones. */
50
+ loops: Set<Stmt>;
25
51
  constFed: boolean;
26
52
  /** the local's FIRST mention is a write, not a read */
27
53
  firstIsWrite: boolean;
28
54
  }
55
+ /** The INDUCTION VARIABLE a `for` drives: the local its init writes and whose step computes the
56
+ * next value from the CURRENT one, arithmetically. Those two writes are the variable's own
57
+ * definition and its own history — not a feed from somewhere the compiler had a reason to
58
+ * respect, which is what `const-fed` reads every non-const assign as.
59
+ *
60
+ * Both halves of the step test are load-bearing, and neither is what the pipeline's `for`
61
+ * producers check — `recognizeForLoops` (structure/structure.ts) admits ANY self-referencing step,
62
+ * and l3/reindex.ts's two walk rewrites mint `iv = iv + 1`, so this predicate is implied by all
63
+ * three rather than trusting any of them; a `for` reaching here may be a walk, not a count. `a = *p` (no self-read) and `p = p->next` / `a = tab[a]` (a self-read
64
+ * that is still a MEMORY read every iteration) are both locals `const-fed` exists to refuse; only
65
+ * arithmetic over the variable is its own history. The read must be a `var`: `mentions` counts an
66
+ * `addr` too, and `&a` is not a read of `a`. */
67
+ const readsVarArithmetically = (e: Expr, n: string): boolean => {
68
+ if (e.k === 'index' || e.k === 'field' || e.k === 'call' || e.k === 'marker' || e.k === 'addr') {
69
+ return false;
70
+ }
71
+ return (e.k === 'var' && e.name === n) || exprChildren(e).some((c) => readsVarArithmetically(c, n));
72
+ };
73
+ const stepIsArithmetic = (e: Expr, n: string): boolean => readsVarArithmetically(e, n) && !hasMemoryRead(e);
74
+ const hasMemoryRead = (e: Expr): boolean =>
75
+ e.k === 'index' || e.k === 'field' || e.k === 'call' || e.k === 'marker' || exprChildren(e).some(hasMemoryRead);
76
+ const forInductionVar = (s: Extract<Stmt, { k: 'for' }>): string | null =>
77
+ s.init.k === 'assign' &&
78
+ s.inc.k === 'assign' &&
79
+ s.init.name === s.inc.name &&
80
+ stepIsArithmetic(s.inc.value, s.inc.name)
81
+ ? s.init.name
82
+ : null;
83
+
84
+ /** Is this local's declaration carrying a `volatile` qualifier of either kind — the object itself,
85
+ * or its pointee? THE one spelling of the question both gate tables' `volatile` rule asks, kept
86
+ * shared while the two rules stay separate objects: `typeToString` spells neither qualifier, so a
87
+ * path that asked only about one would let a qualified local absorb into a plain one. */
88
+ const isVolatileLocal = (l: SFn['locals'][number]): boolean => l.volatile === true || l.pointeeVolatile === true;
89
+
29
90
  function spans(body: Stmt[]): Map<string, Span> {
30
91
  const out = new Map<string, Span>();
31
92
  let at = 0;
32
- const walk = (list: Stmt[], inLoop: boolean): void => {
93
+ /** the statement's OWN mentions — an assign target, a condition, a scrutinee — at its own
94
+ * position, inside the loops it runs under */
95
+ const record = (s: Stmt, loops: readonly Stmt[], inductionVar: string | null = null): void => {
96
+ at++;
97
+ const here = new Set<string>();
98
+ if (s.k === 'assign') here.add(s.name);
99
+ for (const e of stmtExprs(s)) namesIn(e, here);
100
+ for (const n of here) {
101
+ const sp = out.get(n) ?? {
102
+ first: at,
103
+ last: at,
104
+ loops: new Set<Stmt>(),
105
+ constFed: true,
106
+ // an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
107
+ // let `g` receive the absorbed value
108
+ firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
109
+ };
110
+ sp.last = at;
111
+ for (const l of loops) {
112
+ sp.loops.add(l);
113
+ }
114
+ if (s.k === 'assign' && s.name === n && s.value.k !== 'const' && n !== inductionVar) sp.constFed = false;
115
+ out.set(n, sp);
116
+ }
117
+ };
118
+ /** the loop set a while/do-while's own condition and children run under — its own. A `for` is
119
+ * routed separately: `stmtChildren` hands back its init too, and the init is the one child its
120
+ * loop does not re-run. */
121
+ const under = (s: Stmt, loops: readonly Stmt[]): readonly Stmt[] => (isLoop(s) ? [...loops, s] : loops);
122
+ const walk = (list: Stmt[], loops: readonly Stmt[]): void => {
33
123
  for (const s of list) {
34
- at++;
35
- const here = new Set<string>();
36
- if (s.k === 'assign') here.add(s.name);
37
- for (const e of stmtExprs(s)) namesIn(e, here);
38
- for (const n of here) {
39
- const sp = out.get(n) ?? {
40
- first: at,
41
- last: at,
42
- inLoop,
43
- constFed: true,
44
- // an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
45
- // let `g` receive the absorbed value
46
- firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
47
- };
48
- sp.last = at;
49
- sp.inLoop ||= inLoop;
50
- if (s.k === 'assign' && s.name === n && s.value.k !== 'const') sp.constFed = false;
51
- out.set(n, sp);
124
+ // A `for`'s INIT runs ONCE, ahead of the condition and outside the loop; its cond, inc and
125
+ // body run per iteration. Walking the init first is what makes `for (i = *p; )` read as a
126
+ // local whose first mention is a WRITE, which is what it is.
127
+ if (s.k === 'for') {
128
+ const inductionVar = forInductionVar(s);
129
+ // The INIT runs once, so the `for` does not re-run it — but only an ASSIGN there is a
130
+ // shape this case places directly. Anything else (a loop, an `if`) goes through the generic
131
+ // walk, which places it and its children by its own kind; `forInductionVar` requires an
132
+ // assign init, so `counter` is null on that path anyway. The INC mirrors it, where the
133
+ // routing is uniformity rather than coverage: an inc's statements sit inside the `for`,
134
+ // which is already in every span the pair could share.
135
+ if (s.init.k === 'assign') {
136
+ record(s.init, loops, inductionVar);
137
+ } else {
138
+ walk([s.init], loops);
139
+ }
140
+ const inner = [...loops, s];
141
+ record(s, inner);
142
+ walk(s.body, inner);
143
+ if (s.inc.k === 'assign') {
144
+ record(s.inc, inner, inductionVar);
145
+ } else {
146
+ walk([s.inc], inner);
147
+ }
148
+ continue;
52
149
  }
53
- walk(stmtChildren(s), inLoop || s.k === 'while' || s.k === 'dowhile' || s.k === 'for');
150
+ // a while/do-while CONDITION re-runs with the body, so it counts as inside its own loop
151
+ const inner = under(s, loops);
152
+ record(s, inner);
153
+ walk(stmtChildren(s), inner);
54
154
  }
55
155
  };
56
- walk(body, false);
156
+ walk(body, []);
57
157
  return out;
58
158
  }
59
159
  function rename(body: Stmt[], from: string, to: string): Stmt[] {
@@ -89,20 +189,36 @@ export interface MergePair {
89
189
  y: Span;
90
190
  sameType: boolean;
91
191
  eitherIsParam: boolean;
192
+ /** either local is object-volatile or carries a pointee-volatile qualifier — typeToString
193
+ * spells neither, so `sameType` alone lets a qualified local absorb into a plain one */
194
+ eitherIsVolatile: boolean;
195
+ /** some loop holds a mention of BOTH locals */
196
+ sharesLoop: boolean;
92
197
  }
93
198
 
94
199
  /** The admission rules, in evaluation order. Two arguments the `why` fields have no room for:
95
200
  *
96
- * WHAT `loop` BUYS is the right to read preorder statement order as liveness. Preorder is a
97
- * topological order of the CFG except where a later-indexed statement can run before an earlier
98
- * one, and every position that does that a `for`'s `init`/`inc`, any loop body is inside a
99
- * loop. A loop's own CONDITION is not covered: it is visited at the loop statement's own index with
100
- * the ENCLOSING flag, which is safe only because a condition cannot WRITE.
201
+ * WHAT `shared-loop` BUYS is the right to read preorder statement order as liveness. Preorder is a
202
+ * topological order of the CFG except where a back edge runs a later-indexed statement before an
203
+ * earlier one and a back edge returns only to the head of its OWN loop, whose body is one
204
+ * contiguous preorder range. So the reordering can reach a PAIR only when some loop holds a
205
+ * mention of both locals: there the survivor's write can be followed, on the next iteration, by
206
+ * the absorbed local's read. Where no loop holds both — two sibling loops, or one local living
207
+ * before the loop the other lives in — no back edge connects the two ranges and preorder IS
208
+ * execution order. The ancestor chain is what the span records, not the innermost loop: an outer
209
+ * loop re-runs an inner one's statements, so it can reorder a pair that no inner loop shares.
101
210
  *
102
- * ABLATE `first-is-write` ALONE AND NOTHING HAPPENS — `const-fed` masks it, so a survivor first
103
- * mentioned by a read was uninitialized there in the original too. Drop both to see what it does,
104
- * which is to bound the accepted class below by an order of magnitude. `const-fed` likewise bounds
105
- * candidate growth: merges go as `L(L-1)/2` in the local count, each a distinct compile. */
211
+ * `first-is-write` AND `const-fed` ARE INDEPENDENT, and neither masks the other: a survivor whose
212
+ * every feed is a constant can still be first MENTIONED by a read, and ablating `first-is-write`
213
+ * alone then offers the merge (coalesce.test.ts pins exactly that program). What `const-fed`
214
+ * bounds is candidate GROWTH: merges go as `L(L-1)/2` in the local count, each a distinct compile.
215
+ * On a loop-heavy function it is what bounds them, because `shared-loop` refuses only pairs a back
216
+ * edge can reorder: klonoa's LoadBGTilemapData declares 43 locals — 1806 ordered pairs — and
217
+ * `const-fed` is what keeps three of them (ablate it and the span path offers 273). A rule
218
+ * refusing every in-loop local would make that bound redundant; this one does not, so any further
219
+ * relaxation of `const-fed` is a multiplier, and two call sites pay it (`/coalesce` and
220
+ * `/scopebase-coalesce`; the `/livebase` pairings enumerate the ARM path and pay
221
+ * ARM_DISJOINT_GATES' `arm-init` instead). */
106
222
  export const COALESCE_GATES: readonly Gate<MergePair>[] = [
107
223
  {
108
224
  id: 'param',
@@ -117,15 +233,26 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
117
233
  rejects: (c) => !c.sameType,
118
234
  },
119
235
  {
120
- id: 'loop',
121
- why: 'a back edge can run a later statement first, so preorder stops implying disjoint liveness',
236
+ id: 'volatile',
237
+ why: 'a volatile qualifier (object or pointee) is observable and typeToString does not spell it — merging strips or adds it',
122
238
  sound: true,
123
- guardedBy: 'coalesce-fuzz.test.ts: dropping it clobbers a defined read',
124
- rejects: (c) => c.x.inLoop || c.y.inLoop,
239
+ guardedBy: 'coalesce.test.ts: a volatile pair never merges',
240
+ rejects: (c) => c.eitherIsVolatile,
241
+ },
242
+ {
243
+ id: 'shared-loop',
244
+ why: 'a back edge of a loop holding both locals re-runs the absorbed read after the survivor is written',
245
+ sound: true,
246
+ // The ablation sweep proves this gate is load-bearing WHILE it is in the table; it is blind to
247
+ // the two ways the rule can go wrong from here — a relaxation, and an outright deletion, which
248
+ // would simply drop the gate from the table the sweep iterates. Both land on the arm named
249
+ // below, which checks what the pass still emits.
250
+ guardedBy: 'coalesce-fuzz.test.ts: no candidate the pass emits changes a DEFINED read',
251
+ rejects: (c) => c.sharesLoop,
125
252
  },
126
253
  {
127
254
  id: 'const-fed',
128
- why: 'a load-fed local is one the compiler had a reason to keep where it was',
255
+ why: 'a load-fed local — other than a for induction variable, whose feeds are its own — is one the compiler had a reason to keep where it was',
129
256
  sound: false,
130
257
  rejects: (c) => !c.x.constFed || !c.y.constFed,
131
258
  },
@@ -155,12 +282,268 @@ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
155
282
  * emitted and the differ referees.
156
283
  *
157
284
  * ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
158
- * the paths that skip it. The original read an uninitialized local there, so both spellings are
285
+ * the paths that skip it — a loop that runs zero iterations is one such path. The original read an uninitialized local there, so both spellings are
159
286
  * ill-defined rather than one being wrong — but this is a real difference and the differ, not any
160
287
  * gate, is what keeps it from faking a match. The fuzz asserts it stays reachable, so the carve-out
161
288
  * that excuses it cannot quietly become dead. */
162
289
  export function coalesceCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
163
- return coalesceUnder(COALESCE_GATES, sfn).candidates;
290
+ const { candidates } = coalesceUnder(COALESCE_GATES, sfn);
291
+ const seen = new Set(candidates.map((c) => c.merged));
292
+ for (const c of armDisjointCandidates(sfn)) {
293
+ if (!seen.has(c.merged)) {
294
+ candidates.push(c);
295
+ }
296
+ }
297
+ return candidates;
298
+ }
299
+
300
+ /** The survivor's declaration list after `gone` is absorbed into `kept`.
301
+ *
302
+ * The merged local's declaration is dropped, so any attribute on it would be lost — and one of
303
+ * them is load-bearing: `slots`, the `[sp,#k]`s the machine homed it at (ir/core.ts `SlotHomes`).
304
+ * The survivor takes the UNION and chooses nothing: a merged pair can reproduce at most one slot,
305
+ * but WHICH of the two is the earlier declaration rank depends on the frame's direction, and this
306
+ * function is handed a locals list with no target in it. `l3/slotorder.ts` reduces, once, where
307
+ * the direction is in hand. Neither homed ⇒ no slots: the merge invents nothing. */
308
+ function localsAfterMerge(locals: SFn['locals'], gone: string, kept: string): SFn['locals'] {
309
+ const goneSlots = locals.find((l) => l.name === gone)?.slots;
310
+ return locals
311
+ .filter((l) => l.name !== gone)
312
+ .map((l) => {
313
+ if (l.name !== kept || goneSlots === undefined) {
314
+ return l;
315
+ }
316
+ return { ...l, slots: [...new Set([...(l.slots ?? []), ...goneSlots])].sort((x, y) => x - y) };
317
+ });
318
+ }
319
+
320
+ /** One candidate ARM-DISJOINT merge: every mention of `a` inside one arm of a single `if`, every
321
+ * mention of `b` inside the other. There is no param gate to mirror from the span table because
322
+ * the enumeration refuses params structurally: pair members come from `sfn.locals` only, and a
323
+ * local that SHADOWS a param name is excluded too (see `confined`) — rename() must never touch
324
+ * a param's mentions. */
325
+ export interface ArmPair {
326
+ a: string;
327
+ b: string;
328
+ /** the confining `if` has a loop ancestor, so it can run more than once */
329
+ ifInLoop: boolean;
330
+ sameType: boolean;
331
+ /** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
332
+ eitherIsVolatile: boolean;
333
+ /** each local's FIRST preorder mention inside its arm is a pure const write */
334
+ bothArmConstInit: boolean;
335
+ }
336
+
337
+ /** The arm-disjoint admission — the SECOND way a pair can merge, and what it uniquely buys is the
338
+ * DIRECTION: the span path's survivor is always the later RANGE, because `overlap` orders the pair
339
+ * by position, while this path's is the earlier DECLARATION — the two disagree exactly when
340
+ * declaration order disagrees with range order. It also admits a pair the span gates refuse for a
341
+ * different reason: `arm-init` is a FIRST-MENTION rule where `const-fed` is an every-assign one,
342
+ * so arms that open with a const write and then compute (`x = 0; x = x + 1;`) merge here and not
343
+ * there. Two locals confined to
344
+ * OPPOSITE arms of one `if` never coexist at runtime: the `if` picks one arm, so no read of either
345
+ * can observe the other's write — no liveness reasoning needed. That argument is exactly what the
346
+ * `loop` gate here protects: a loop ancestor re-enters the `if`, later entries can take the other
347
+ * arm, and a value written on one visit becomes readable on the next. Note this gate wants ANY
348
+ * enclosing loop, not the span model's shared-loop rule: never-coexisting is a claim about one
349
+ * entry, so a second entry breaks it however the two arms' loops relate. */
350
+ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
351
+ {
352
+ id: 'type',
353
+ why: 'the survivor keeps its own declared type, so the two must agree',
354
+ sound: false,
355
+ rejects: (c) => !c.sameType,
356
+ },
357
+ {
358
+ id: 'volatile',
359
+ why: 'a volatile qualifier (object or pointee) is observable — merging strips or adds it',
360
+ sound: true,
361
+ guardedBy: 'coalesce.test.ts: a volatile pair never merges',
362
+ rejects: (c) => c.eitherIsVolatile,
363
+ },
364
+ {
365
+ id: 'loop',
366
+ why: 'a loop ancestor re-enters the if, so opposite arms both run and a value could cross',
367
+ sound: true,
368
+ guardedBy: 'coalesce.test.ts: an in-loop if never admits its arm pair',
369
+ rejects: (c) => c.ifInLoop,
370
+ },
371
+ {
372
+ id: 'arm-init',
373
+ why: 'a local not const-initialized at its arm’s first mention is one the compiler had a reason to keep — the growth bound const-fed gives the span table',
374
+ sound: false,
375
+ rejects: (c) => !c.bothArmConstInit,
376
+ },
377
+ ];
378
+
379
+ /** The arm-disjoint merges alone — the class the livebase pairings enumerate (rank.ts): the
380
+ * demanding row's shared counter is arm-disjoint, and the span-model merges already ride the
381
+ * plain /coalesce label, so pairing them too would multiply candidates with no row behind it. */
382
+ export function armDisjointCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
383
+ return armDisjointUnder(ARM_DISJOINT_GATES, sfn).candidates;
384
+ }
385
+
386
+ /** Every name each statement of `list` MENTIONS, and how many statements mention it — an assign
387
+ * target counts, and so does every name in the statement's own expressions. Nested statements are
388
+ * counted too, so this is the whole subtree's census. */
389
+ function countMentions(list: Stmt[]): Map<string, number> {
390
+ const out = new Map<string, number>();
391
+ const walk = (stmts: Stmt[]): void => {
392
+ for (const st of stmts) {
393
+ const here = new Set<string>();
394
+ if (st.k === 'assign') here.add(st.name);
395
+ for (const e of stmtExprs(st)) namesIn(e, here);
396
+ for (const n of here) out.set(n, (out.get(n) ?? 0) + 1);
397
+ walk(stmtChildren(st));
398
+ }
399
+ };
400
+ walk(list);
401
+ return out;
402
+ }
403
+
404
+ /** The three mention queries the arm-disjoint path asks, sharing ONE set of memos.
405
+ *
406
+ * MEMOISED ON NODE IDENTITY, so the index belongs to one tree and one call: build it per
407
+ * `armDisjointUnder` invocation and never hold it across a rewrite. Sound because nothing on this
408
+ * path mutates the tree — the only rewrite is `rename`, which rebuilds every statement it touches
409
+ * and leaves the input alone (structure-purity.test.ts pins the same promise one level up). The
410
+ * memos earn their keep because `firstMention` walks a statement's whole subtree once per
411
+ * statement it scans, and the a×b loop's two calls each depend on only ONE of a and b.
412
+ *
413
+ * `firstMentionIn` and `firstMention` are mutually recursive and stay inside for that reason —
414
+ * the recursion runs back through the memo, not around it. */
415
+ function mentionIndex(): {
416
+ mentionsOf: (list: Stmt[]) => Map<string, number>;
417
+ mentionsUnder: (st: Stmt) => Map<string, number>;
418
+ firstMention: (list: Stmt[], n: string) => 'const-write' | 'other' | null;
419
+ } {
420
+ const listMentions = new Map<Stmt[], Map<string, number>>();
421
+ const mentionsOf = (list: Stmt[]): Map<string, number> => {
422
+ let m = listMentions.get(list);
423
+ if (m === undefined) {
424
+ m = countMentions(list);
425
+ listMentions.set(list, m);
426
+ }
427
+ return m;
428
+ };
429
+ // `stmtChildren` builds a FRESH array every call, so a statement's subtree counts are keyed on
430
+ // the statement rather than on the list `mentionsOf` would see.
431
+ const childMentions = new Map<Stmt, Map<string, number>>();
432
+ const mentionsUnder = (st: Stmt): Map<string, number> => {
433
+ let m = childMentions.get(st);
434
+ if (m === undefined) {
435
+ m = countMentions(stmtChildren(st));
436
+ childMentions.set(st, m);
437
+ }
438
+ return m;
439
+ };
440
+ // The first PREORDER mention of `n` in an arm, looked for through if statements whose own
441
+ // condition does not read it (an if's cond evaluates before either arm). 'const-write' is a
442
+ // pure `n = K`; anything else mentioning n first — a read, a computed assign, a loop — refuses.
443
+ const firstMentionIn = (list: Stmt[], n: string): 'const-write' | 'other' | null => {
444
+ for (const st of list) {
445
+ const here = new Set<string>();
446
+ if (st.k === 'assign') here.add(st.name);
447
+ for (const e of stmtExprs(st)) namesIn(e, here);
448
+ const inChildren = mentionsUnder(st).has(n);
449
+ if (!here.has(n) && !inChildren) {
450
+ continue;
451
+ }
452
+ if (st.k === 'assign' && st.name === n && st.value.k === 'const' && !mentions(st.value, n)) {
453
+ return 'const-write';
454
+ }
455
+ if (st.k === 'if' && !here.has(n)) {
456
+ const arm = mentionsOf(st.then).has(n) ? st.then : st.else;
457
+ return firstMention(arm, n);
458
+ }
459
+ return 'other';
460
+ }
461
+ return null;
462
+ };
463
+ // Per (arm, NAME): the answer depends on both, and the a×b loop asks for each `a` once per `b`
464
+ // and each `b` once per `a`.
465
+ const firstMentions = new Map<Stmt[], Map<string, 'const-write' | 'other' | null>>();
466
+ const firstMention = (list: Stmt[], n: string): 'const-write' | 'other' | null => {
467
+ let per = firstMentions.get(list);
468
+ if (per === undefined) {
469
+ per = new Map();
470
+ firstMentions.set(list, per);
471
+ }
472
+ if (!per.has(n)) {
473
+ per.set(n, firstMentionIn(list, n));
474
+ }
475
+ return per.get(n)!;
476
+ };
477
+ return { mentionsOf, mentionsUnder, firstMention };
478
+ }
479
+
480
+ /** `armDisjointCandidates` with the gate table supplied plus which gate refused each pair — the
481
+ * same ablation-as-a-value seam `coalesceUnder` provides for the span table. */
482
+ export function armDisjointUnder(
483
+ gates: readonly Gate<ArmPair>[],
484
+ sfn: SFn,
485
+ ): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
486
+ const refusals = new Map<string, number>();
487
+ if (sfn.locals.length < 2) {
488
+ return { candidates: [], refusals };
489
+ }
490
+ const { mentionsOf, firstMention } = mentionIndex();
491
+ const total = mentionsOf(sfn.body);
492
+ const params = new Set(sfn.params.map((p) => p.name));
493
+ const locals = new Map(sfn.locals.map((l) => [l.name, l]));
494
+ const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
495
+ const out: { merged: string; sfn: SFn }[] = [];
496
+ const declIdx = new Map(sfn.locals.map((l, i) => [l.name, i]));
497
+ const isVolatile = (n: string): boolean => {
498
+ const l = locals.get(n);
499
+ return l !== undefined && isVolatileLocal(l);
500
+ };
501
+ const visit = (stmts: Stmt[], inLoop: boolean): void => {
502
+ for (const st of stmts) {
503
+ if (st.k === 'if' && st.then.length && st.else.length) {
504
+ const thenM = mentionsOf(st.then);
505
+ const elseM = mentionsOf(st.else);
506
+ // locals only, and never a name that is ALSO a param — the span path holds the same
507
+ // belief as a gate, and a local shadowing a param would let rename() rewrite the param's
508
+ // own mentions
509
+ const confined = (m: Map<string, number>): string[] =>
510
+ [...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
511
+ for (const a of confined(thenM)) {
512
+ for (const b of confined(elseM)) {
513
+ // the survivor is the earlier declaration, matching how a shared source local reads.
514
+ //
515
+ // THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into the
516
+ // target's frame order at EMIT time (l3/slotorder.ts), after this pass, so `declIdx`
517
+ // is the naming walk's order and the choice means "the earlier declaration in the
518
+ // source asmlift recovered". Ordering the list any earlier would silently change which
519
+ // local survives every arm-disjoint merge on a function whose frame order disagrees
520
+ // with its declaration order — exactly the population the ordering exists for.
521
+ const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
522
+ const refused = firstRejection(gates, {
523
+ a: gone,
524
+ b: kept,
525
+ ifInLoop: inLoop,
526
+ sameType: typeOf.get(a) === typeOf.get(b),
527
+ eitherIsVolatile: isVolatile(a) || isVolatile(b),
528
+ bothArmConstInit:
529
+ firstMention(st.then, a) === 'const-write' && firstMention(st.else, b) === 'const-write',
530
+ });
531
+ if (refused !== null) {
532
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
533
+ continue;
534
+ }
535
+ out.push({
536
+ merged: `${gone}-${kept}`,
537
+ sfn: { ...sfn, body: rename(sfn.body, gone, kept), locals: localsAfterMerge(sfn.locals, gone, kept) },
538
+ });
539
+ }
540
+ }
541
+ }
542
+ visit(stmtChildren(st), inLoop || isLoop(st));
543
+ }
544
+ };
545
+ visit(sfn.body, false);
546
+ return { candidates: out, refusals };
164
547
  }
165
548
 
166
549
  /** `coalesceCandidates` with the gate table supplied, plus which gate refused each pair.
@@ -179,6 +562,7 @@ export function coalesceUnder(
179
562
  }
180
563
  const params = new Set(sfn.params.map((p) => p.name));
181
564
  const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
565
+ const volatiles = new Set(sfn.locals.filter(isVolatileLocal).map((l) => l.name));
182
566
  const sp = spans(sfn.body);
183
567
  const candidates: { merged: string; sfn: SFn }[] = [];
184
568
  for (const a of sfn.locals.map((l) => l.name)) {
@@ -197,6 +581,8 @@ export function coalesceUnder(
197
581
  y,
198
582
  sameType: typeOf.get(a) === typeOf.get(b),
199
583
  eitherIsParam: params.has(a) || params.has(b),
584
+ eitherIsVolatile: volatiles.has(a) || volatiles.has(b),
585
+ sharesLoop: [...x.loops].some((l) => y.loops.has(l)),
200
586
  });
201
587
  if (refused !== null) {
202
588
  refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
@@ -207,7 +593,7 @@ export function coalesceUnder(
207
593
  // that is wrong but plausible.
208
594
  candidates.push({
209
595
  merged: `${a}-${b}`,
210
- sfn: { ...sfn, body: rename(sfn.body, a, b), locals: sfn.locals.filter((l) => l.name !== a) },
596
+ sfn: { ...sfn, body: rename(sfn.body, a, b), locals: localsAfterMerge(sfn.locals, a, b) },
211
597
  });
212
598
  }
213
599
  }
package/src/l3/dce.ts CHANGED
@@ -50,12 +50,12 @@ function reads(e: Expr): Set<string> {
50
50
  * - `marker` — the annotate-mode ASMLIFT_ERROR gap signal, which must survive so the gap stays loud;
51
51
  * - the strict-mode `?` unresolved sentinel (`{k:'var', name:'?'}`) — dropping it would let a
52
52
  * value asmlift could NOT lift slip past `assertResolved`, silently downgrading a loud gap;
53
- * - a memory load (`index`/`field`) — asmlift models no `volatile`, so a possibly-effectful read
53
+ * - a memory load (`index`/`field`) — a deref's volatility is unknowable here, so a possibly-effectful read
54
54
  * is never deleted (this pass never removes a memory access).
55
55
  * - a read of a VOLATILE local (the frame object whose address escaped) — a volatile read is an
56
56
  * observable access the machine performed; deleting the dead assignment would delete the read.
57
57
  * A dead assignment whose value contains any of these is kept. */
58
- function mustKeep(e: Expr, volatiles: ReadonlySet<string> = new Set()): boolean {
58
+ function mustKeep(e: Expr, volatiles: ReadonlySet<string>): boolean {
59
59
  if (e.k === 'call' || e.k === 'marker' || e.k === 'index' || e.k === 'field') {
60
60
  return true;
61
61
  }
@@ -76,13 +76,32 @@ function allReadsInto(stmts: Stmt[], out: Set<string>): void {
76
76
  }
77
77
  }
78
78
 
79
+ /** Every name whose ADDRESS is taken anywhere within these statements. Globals land here too and
80
+ * are harmless — they were never store-eligible. */
81
+ function allAddrNamesInto(stmts: Stmt[], out: Set<string>): void {
82
+ const walk = (e: Expr) => {
83
+ if (e.k === 'addr') {
84
+ out.add(e.name);
85
+ }
86
+ for (const c of exprChildren(e)) {
87
+ walk(c);
88
+ }
89
+ };
90
+ for (const s of stmts) {
91
+ for (const e of stmtExprs(s)) {
92
+ walk(e);
93
+ }
94
+ allAddrNamesInto(stmtChildren(s), out);
95
+ }
96
+ }
97
+
79
98
  /** Backward live-variable walk over one block. `liveOut` is the set of locals live on exit;
80
99
  * returns the rewritten block and the set live on entry. */
81
100
  function dceBlock(
82
101
  stmts: Stmt[],
83
102
  liveOut: ReadonlySet<string>,
84
103
  locals: ReadonlySet<string>,
85
- volatiles: ReadonlySet<string> = new Set(),
104
+ volatiles: ReadonlySet<string>,
86
105
  ): { out: Stmt[]; liveIn: Set<string> } {
87
106
  const live = new Set(liveOut);
88
107
  const rev: Stmt[] = [];
@@ -234,13 +253,16 @@ function referencedNames(stmts: Stmt[], out: Set<string>): void {
234
253
  /** Remove dead local stores and simplify the branches they empty out, then drop any local
235
254
  * declaration left unreferenced. Returns a new SFn; the input is not mutated. */
236
255
  export function eliminateDeadStores(sfn: SFn): SFn {
237
- // A VOLATILE local is never eligible: its stores are observable through the escaped address (the
238
- // DMA hardware reads them) wherever they sit. The `addr`-as-read pin alone only protected stores
239
- // UPSTREAM of an `&sp0` occurrence in this backward walk the legal publish-address-then-fill
240
- // ordering (`*dmaReg = &sp0;` THEN `sp0 = v;`) had its store deleted by this very pass, defeating
241
- // the volatile the frontend added precisely so the RECOMPILER would not delete it.
256
+ // AN ADDRESS-TAKEN local is never eligible, whatever its qualifiers: every store to it is
257
+ // observable through the escaped pointer wherever it sits, and this walk is BACKWARD, so the
258
+ // `addr`-as-read pin above only ever protected the stores UPSTREAM of an `&sp0` occurrence.
259
+ // Publish-the-address-then-fill (`g(&sp0); sp0 = v;`) puts one downstream, and this very pass
260
+ // deleted it. `volatile` stays in the test beside it because the qualifier is a separate reason
261
+ // (an MMIO cell the frontend never rendered an `&` for), not a spelling of this one.
262
+ const addressTaken = new Set<string>();
263
+ allAddrNamesInto(sfn.body, addressTaken);
242
264
  const volatiles = new Set(sfn.locals.filter((l) => l.volatile).map((l) => l.name));
243
- const locals = new Set(sfn.locals.filter((l) => !l.volatile).map((l) => l.name));
265
+ const locals = new Set(sfn.locals.filter((l) => !l.volatile && !addressTaken.has(l.name)).map((l) => l.name));
244
266
  const body = dceBlock(sfn.body, new Set<string>(), locals, volatiles).out;
245
267
  const used = new Set<string>();
246
268
  referencedNames(body, used);