@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,15 +1,51 @@
1
- // asmlift structurer — LOOP-EMISSION HAZARD checks: may this loop's updates be emitted before
2
- // its condition/exit/post-loop reads, or would some read then see a clobbered (post-update)
3
- // value that the original IR read PRE-update? Every check here is PURE — it reads the analysis
4
- // maps and decides; nothing mutates which is what lets the emission sites call it freely
5
- // before committing to a loop form, and decline loud instead of miscompiling.
1
+ // asmlift structurer — the checks a loop emitter runs BEFORE it commits to a loop form, so it can
2
+ // decline loud instead of miscompiling. Two questions live here:
3
+ //
4
+ // may this loop's updates be emitted before its condition/exit/post-loop reads, or would some
5
+ // read then see a clobbered (post-update) value that the original IR read PRE-update — and
6
+ // which of those reads can be REPAIRED by moving the copy into the body (`sinkable…`);
7
+ // • is a value the emitted form DROPS redundant — `sameAtEntry`, which reads an expression on
8
+ // the loop's entry state so a guard about to be fused away can be checked against the test
9
+ // that replaces it.
10
+ //
11
+ // WHAT MAKES A SUNK COPY LEGAL. The exit edge's value is not moved, it is REBUILT: the copy lands
12
+ // at the top of the body and spells the arg's def-tree again there. Two things have to hold — the
13
+ // tree gives the same answer there, and every name it reads still denotes the same value — and the
14
+ // arg gates in `PREUPDATE_SINK_GATES` are those two plus the degenerate leaf that is neither.
15
+ //
16
+ // The tree must give the same answer at the new point. Two ways it might not, and `REEVAL_UNSAFE_OPS`
17
+ // is the registry view that names both. ORDER: the copy opens the body, so a load in the tree would
18
+ // move ahead of every store the body makes, and an effect would move against the others.
19
+ // SPECULATION: the def dominates the latch, and the loop is single-latch at both call sites, but an
20
+ // early-`return` arm still lets an iteration leave BEFORE the latch — so a tree the top of the body
21
+ // evaluates is one that iteration never evaluated, and a trapping divide would fault where the
22
+ // original returned.
23
+ //
24
+ // One gate covers both because `REEVAL_UNSAFE_OPS` answers both, and the candidate carries no arm
25
+ // information to tell them apart. KNOWN COST: a loop with NO early-return arm speculates nothing,
26
+ // so a divide in its exit-arg tree is refused for a hazard it cannot have. Threading "this loop can
27
+ // leave before its latch" into the candidate is what would recover it; no benchmark row asks yet.
28
+ //
29
+ // And every name the rebuilt expression reads must still denote the same value there. A loop
30
+ // variable does: the update sits at the bottom, so at the top of the body its name holds exactly
31
+ // the value the edge read. A name the body itself defines does NOT — at the top of the body it
32
+ // still holds the previous iteration.
33
+ //
34
+ // KNOWN GAP: `body` is the natural-loop body, which EXCLUDES the blocks an early-return arm owns
35
+ // even though their statements are emitted inside the loop. A name assigned only in such an arm is
36
+ // invisible to both predicates below. Harmless while an arm leaves the function — nothing after it
37
+ // reads the name — and an arm that merely breaks would need it.
38
+ //
39
+ // Every check is PURE: it reads the analysis maps and decides, nothing mutates.
6
40
  //
7
41
  // The factory takes its dependencies EXPLICITLY (`LoopHazardDeps`), the switch-recover pattern.
8
42
  // The maps are captured as LIVE REFERENCES, deliberately: `varName` is still being populated by
9
43
  // the naming pipeline when the factory is created, and each hazard check reads whatever names
10
44
  // exist at CALL time (emission runs after naming completes). Snapshotting them would break this.
11
45
  import { Block, Op, Value } from '../ir/core';
46
+ import { NEGATED_ICMP, REEVAL_UNSAFE_OPS } from '../ir/opcodes';
12
47
  import { Stmt } from '../l3/ast';
48
+ import { type Gate, firstRejection } from '../l3/gates';
13
49
  import type { UseSite } from './analysis';
14
50
 
15
51
  export interface LoopHazardDeps {
@@ -19,6 +55,18 @@ export interface LoopHazardDeps {
19
55
  varName: Map<Value, string>;
20
56
  /** every positioned use of a value (analysis.ts) */
21
57
  useSitesOf: Map<Value, UseSite[]>;
58
+ /** values read at-or-after each block's entry (analysis.ts) */
59
+ liveIn: Map<Block, Set<Value>>;
60
+ /** op → the block holding it (analysis.ts) */
61
+ opBlock: Map<Op, Block>;
62
+ /** defs that emit as named temps at their own position (analysis.ts) — `loopWriteSet` reads it
63
+ * to see the in-place write an adopted materialized def performs. LIVE, like `varName`. */
64
+ materialize: Set<Op>;
65
+ /** Ops whose LOWERING discards their operand tree and spells something else — today the
66
+ * bitfield-extract fold, which renders a global member read in place of a shift pair. A walk
67
+ * over the operands cannot see what such an op will render, so predicates that reason about
68
+ * the rendered expression have to treat it as opaque. LIVE, like `varName`. */
69
+ respelledDefs: ReadonlyMap<Op, unknown>;
22
70
  }
23
71
 
24
72
  export interface LoopHazards {
@@ -28,7 +76,6 @@ export interface LoopHazards {
28
76
  sub: Map<Value, string>,
29
77
  updateWrites: Set<string>,
30
78
  region?: Set<Block> | null,
31
- loopParams?: Set<Value>,
32
79
  ): boolean;
33
80
  loopUpdateHazard(
34
81
  condV: Value,
@@ -37,8 +84,18 @@ export interface LoopHazards {
37
84
  sub: Map<Value, string>,
38
85
  updateWrites: Set<string>,
39
86
  region: Set<Block> | null,
40
- loopParams: Set<Value>,
41
87
  ): boolean;
88
+ sinkablePreUpdateSlots(
89
+ header: Block,
90
+ exit: Block,
91
+ exitArgs: readonly Value[],
92
+ body: Set<Block>,
93
+ sub: Map<Value, string>,
94
+ updateWrites: Set<string>,
95
+ gates?: readonly Gate<SinkCandidate>[],
96
+ ): Set<number>;
97
+ sameAtEntry(a: Value, b: Value, entry: Map<Value, Value>, negated?: boolean): boolean;
98
+ loopWriteSet(updates: Stmt[], bodyBlocks: Iterable<Block>, header: Block): Set<string>;
42
99
  }
43
100
 
44
101
  /** The names a loop update assigns (its non-identity copies) — the write set every loop-emission
@@ -46,8 +103,151 @@ export interface LoopHazards {
46
103
  export const updateWriteSet = (updates: Stmt[]): Set<string> =>
47
104
  new Set(updates.filter((st): st is Extract<Stmt, { k: 'assign' }> => st.k === 'assign').map((st) => st.name));
48
105
 
106
+ /** Why an exit arg cannot be REBUILT at the top of the loop body — see the note above
107
+ * `PREUPDATE_SINK_GATES`. The walk collects EVERY one it finds, not the first: with one blocker
108
+ * per gate, stopping early would let ablating one gate disable another on any tree where the
109
+ * other's blocker happens to be found first, and the ablation would then be measuring less than
110
+ * its name says. */
111
+ export type ArgBlocker = 'order-sensitive' | 'stale-name' | 'no-definition';
112
+
113
+ /** One exit slot weighed for sinking. `destName` is the name the sunk copy would write. */
114
+ export interface SinkCandidate {
115
+ /** everything that stops the arg's def-tree from being rebuilt at the top of the body */
116
+ argBlockers: ReadonlySet<ArgBlocker>;
117
+ destName: string | undefined;
118
+ /** the names of the loop's own variables */
119
+ headerNames: ReadonlySet<string | undefined>;
120
+ /** the names the emitted update assigns */
121
+ updateWrites: ReadonlySet<string>;
122
+ /** some other value under `destName` is read inside the loop */
123
+ destBusyInLoop: boolean;
124
+ }
125
+
126
+ /** When a pre-update exit copy may move into the loop body. The set-level
127
+ * rules — two slots wanting one name, a slot that stays behind reading a sunk name — are not in
128
+ * the table because they are properties of the whole edge rather than of a candidate; they live in
129
+ * `sinkablePreUpdateSlots` with the same refusal discipline. */
130
+ export const PREUPDATE_SINK_GATES: readonly Gate<SinkCandidate>[] = [
131
+ {
132
+ id: 'arg-safe-to-reevaluate',
133
+ why: 'an effect, a memory read or a trap gives a different answer where the rebuilt copy lands',
134
+ sound: true,
135
+ guardedBy: 'hazards.test.ts: ablating arg-safe-to-reevaluate admits an exit arg that reads memory',
136
+ rejects: (c) => c.argBlockers.has('order-sensitive'),
137
+ },
138
+ {
139
+ id: 'arg-reads-current-names',
140
+ why: 'a value computed in the body still holds the PREVIOUS iteration at the top of it, where the copy lands',
141
+ sound: true,
142
+ guardedBy: 'hazards.test.ts: ablating arg-reads-current-names admits an arg over a body-computed name',
143
+ rejects: (c) => c.argBlockers.has('stale-name'),
144
+ },
145
+ {
146
+ id: 'arg-has-a-definition',
147
+ why: 'a leaf with neither a name nor a def renders as a gap, which the contract catches loudly',
148
+ sound: false,
149
+ rejects: (c) => c.argBlockers.has('no-definition'),
150
+ },
151
+ {
152
+ // The update assigns exactly the loop variables' names, so these two tests are one set today;
153
+ // both are spelled so the rule does not rest on that coincidence.
154
+ id: 'dest-not-loop-variable',
155
+ why: 'a copy into a name the loop itself assigns is overwritten by the update, or is a self-assignment',
156
+ sound: true,
157
+ guardedBy: 'hazards.test.ts: ablating dest-not-loop-variable admits a self-assignment',
158
+ rejects: (c) => c.destName === undefined || c.headerNames.has(c.destName) || c.updateWrites.has(c.destName),
159
+ },
160
+ {
161
+ id: 'dest-free-inside-loop',
162
+ why: 'the name already denotes a value the loop reads, which a write at the top of the body clobbers',
163
+ sound: true,
164
+ guardedBy: 'hazards.test.ts: ablating dest-free-inside-loop admits a name the loop still reads',
165
+ rejects: (c) => c.destBusyInLoop,
166
+ },
167
+ ];
168
+
169
+ // `x + 0` / `x - 0` / `x | 0` are `x`. Substituting a loop variable by its init constant turns
170
+ // ordinary index arithmetic into exactly these, and a guard that spells the same value without
171
+ // the arithmetic would otherwise compare unequal.
172
+ function fold(defs: Map<Value, Op>, v: Value): Value {
173
+ const d = defs.get(v);
174
+ if (!d || d.operands.length !== 2 || !['add', 'sub', 'or'].includes(d.opcode)) {
175
+ return v;
176
+ }
177
+ const z = defs.get(d.operands[1]);
178
+ return z?.opcode === 'const' && z.attrs?.value === 0 ? fold(defs, d.operands[0]) : v;
179
+ }
180
+
181
+ // Does `a`, read on a loop's ENTRY values, denote the same thing as `b`? `entry` maps each header
182
+ // param and back-edge arg to the arg the forward edge passes, so substituting through it models
183
+ // the FIRST iteration — the state a guard in front of the loop tested. `negated` compares against
184
+ // b's logical opposite instead, for the usual case where a guard spells the loop's own test the
185
+ // other way round (`beq` to the exit vs `bne` to the header).
186
+ //
187
+ // Structural, not semantic: distinct ops with the same opcode, attributes and operands compare
188
+ // equal (two `const 0`s do), anything else does not. A false negative costs a loud decline, which
189
+ // is the direction to be wrong in. Memoised like `readsClobbered`'s `seen`: a value its own
190
+ // consumer reads twice would otherwise double the work at every level.
191
+ function sameAtEntry(defs: Map<Value, Op>, a: Value, b: Value, entry: Map<Value, Value>, negated = false): boolean {
192
+ const memo = new Map<Value, Map<Value, boolean>>();
193
+ const sameOp = (da: Op, db: Op, opcodeOk: boolean): boolean =>
194
+ opcodeOk &&
195
+ da.operands.length === db.operands.length &&
196
+ JSON.stringify(da.attrs ?? null) === JSON.stringify(db.attrs ?? null) &&
197
+ da.operands.every((o, i) => same(o, db.operands[i]));
198
+ const same = (x0: Value, y0: Value): boolean => {
199
+ const x = fold(defs, entry.get(x0) ?? x0);
200
+ const y = fold(defs, y0);
201
+ if (x === y) {
202
+ return true;
203
+ }
204
+ let row = memo.get(x);
205
+ if (!row) {
206
+ memo.set(x, (row = new Map()));
207
+ }
208
+ const hit = row.get(y);
209
+ if (hit !== undefined) {
210
+ return hit;
211
+ }
212
+ const da = defs.get(x);
213
+ const db = defs.get(y);
214
+ const r = !!da && !!db && sameOp(da, db, da.opcode === db.opcode);
215
+ row.set(y, r);
216
+ return r;
217
+ };
218
+ if (!negated) {
219
+ return same(a, b);
220
+ }
221
+ const da = defs.get(fold(defs, entry.get(a) ?? a));
222
+ const db = defs.get(fold(defs, b));
223
+ return !!da && !!db && sameOp(da, db, NEGATED_ICMP[da.opcode] === db.opcode);
224
+ }
225
+
49
226
  export function makeLoopHazards(deps: LoopHazardDeps): LoopHazards {
50
- const { defs, varName, useSitesOf } = deps;
227
+ const { defs, varName, useSitesOf, liveIn, opBlock, materialize, respelledDefs } = deps;
228
+
229
+ // The names one loop iteration writes under its VARIABLES' names: the update copies, plus a
230
+ // loop-variable name a materialized body def writes IN PLACE. Adoption (seedLoopParams) makes
231
+ // that def's update copy an identity — elided, so `updateWriteSet(updates)` alone no longer
232
+ // carries the name — but the write still happens mid-body via sideEffects, and a
233
+ // pre-update-read check keyed on the write set is blind to it without this. Non-param
234
+ // materialized names stay out: a fresh temp is assigned once per iteration, so an
235
+ // out-of-position read of it is the current value, not a stale one (its zero-trip hazard is
236
+ // the kept-guard site's separate check).
237
+ const loopWriteSet = (updates: Stmt[], bodyBlocks: Iterable<Block>, header: Block): Set<string> => {
238
+ const writes = updateWriteSet(updates);
239
+ const paramNames = new Set(header.params.map((p) => varName.get(p)));
240
+ for (const bb of bodyBlocks) {
241
+ for (const op of bb.ops) {
242
+ const r = op.results[0];
243
+ const nm = r !== undefined && materialize.has(op) ? varName.get(r) : undefined;
244
+ if (nm !== undefined && paramNames.has(nm)) {
245
+ writes.add(nm);
246
+ }
247
+ }
248
+ }
249
+ return writes;
250
+ };
51
251
 
52
252
  // Does rendering `v` under `sub` read a variable that a pending loop update (`updateWrites`, the
53
253
  // names it assigns) overwrites, via a path OTHER than a `sub`-mapped back-edge arg? Such a read is a
@@ -86,12 +286,35 @@ export function makeLoopHazards(deps: LoopHazardDeps): LoopHazards {
86
286
  sub: Map<Value, string>,
87
287
  updateWrites: Set<string>,
88
288
  region: Set<Block> | null = null,
89
- loopParams: Set<Value> = new Set(),
90
289
  ): boolean => {
91
- // Body-block PARAMS escape too: a non-loop-carried param whose adopted name the update writes
92
- // reads post-loop as the clobbered value. canTakeName prevents that adoption, so this firing
93
- // means a naming bug decline loud, never emit. The loop's own carried params (`loopParams`)
94
- // are exempt: their post-loop read of the updated name is exactly the intended final value.
290
+ // Body-block PARAMS escape too, and ONE rule covers all of them: escaped, under a name the
291
+ // update writes. The loop's own carried params used to be exempt outright and are not special
292
+ // DELETED, not narrowed, and unnarrowable, because the only condition under the exemption
293
+ // was the rule's own. Anything implying NOT that is already what the rule returns; anything
294
+ // admitting it is the unconditional exemption back again. There being no third predicate is
295
+ // why `loopParams` left this file instead of being tightened inside it.
296
+ //
297
+ // Unconditional, it was a SILENT MISCOMPILE. "A post-loop read of the updated name is exactly
298
+ // the intended final value" holds for the BACK-EDGE ARG, which `sub` maps, and not for the
299
+ // PARAM, which is one update behind — and both emitters that passed a nonempty exemption set
300
+ // put the update at the BOTTOM of the body. The emitted C stored `i` where agbcc keeps a
301
+ // second register to store `i-1`; it compiled, it scored, and nothing in the tree noticed.
302
+ //
303
+ // ITS EVIDENCE IS A RIG, NOT THE CORPUS, which matters because the three disjuncts of
304
+ // `loopUpdateHazard` share one decline message. 121 generated loop shapes, each reference and
305
+ // each lift compiled natively and EXECUTED over an identical buffer: 76 wrong lifts became
306
+ // declines, 14 correct ones kept lifting with an identical hash. This clause then fires 0
307
+ // times over the klonoa checkout's 732 `.s` and over the 2737 candidates the 205 agbcc
308
+ // synthetic rows enumerate, where the predicate AROUND it fires 5 and 4 — on the condition, an
309
+ // exit arg, or an escaped op result (`synthetic:preupdate_escape` is the last of those). Those
310
+ // counts, and the rig, were taken at #113.
311
+ //
312
+ // AND IT DECLINES ONLY BECAUSE OF HOW THE VALUE IS SPELLED. `sinkablePreUpdateSlots` below
313
+ // REPAIRS this hazard, re-emitting the copy inside the body ahead of the update, whenever the
314
+ // pre-update value crosses the exit as an edge ARG; read from the header PARAM instead it
315
+ // arrives here, with no exit slot to sink. Both spellings, and the agbcc listing, are in
316
+ // test/loop-preupdate-escape.test.ts. Routing the param one into the sink is a structure.ts
317
+ // change with a fan effect on every row, not an edit to this predicate.
95
318
  const escaped = (v: Value): boolean => {
96
319
  for (const s of useSitesOf.get(v) ?? []) {
97
320
  if (region ? region.has(s.blk) : !body.has(s.blk)) {
@@ -102,10 +325,9 @@ export function makeLoopHazards(deps: LoopHazardDeps): LoopHazards {
102
325
  };
103
326
  for (const bb of body) {
104
327
  for (const pv of bb.params) {
105
- if (loopParams.has(pv)) {
106
- continue;
107
- }
108
- if (escaped(pv) && updateWrites.has(varName.get(pv)!)) {
328
+ const n = varName.get(pv); // absent while naming is still in progress: not a written name
329
+
330
+ if (n !== undefined && updateWrites.has(n) && escaped(pv)) {
109
331
  return true;
110
332
  }
111
333
  }
@@ -132,11 +354,180 @@ export function makeLoopHazards(deps: LoopHazardDeps): LoopHazards {
132
354
  sub: Map<Value, string>,
133
355
  updateWrites: Set<string>,
134
356
  region: Set<Block> | null,
135
- loopParams: Set<Value>,
136
357
  ): boolean =>
137
358
  readsClobbered(condV, sub, updateWrites) ||
138
359
  exitArgs.some((a) => readsClobbered(a, sub, updateWrites)) ||
139
- loopEscapeHazard(body, sub, updateWrites, region, loopParams);
360
+ loopEscapeHazard(body, sub, updateWrites, region);
361
+
362
+ // Which pre-update exit copies can be REPAIRED instead of declined. The exiting edge hands a
363
+ // loop variable's top-of-iteration value to a merge param, and post-loop that name has moved on
364
+ // one iteration; emitting the copy inside the body, AHEAD of the update, restores it — the
365
+ // trailing-pointer idiom (`for (fast = slow = head; ...; fast = fast->next) slow = fast;`).
366
+ // Returns the exit slots that may move; the caller emits them at the top of the body and drops
367
+ // them from the post-loop copies.
368
+ //
369
+ // The idiom reaches here at all because the compiler DID keep a second register for the trailing
370
+ // value and SSA construction folded the copy away, leaving the exit edge as the only place the
371
+ // value is still named. Where the compiler kept two loop-carried registers instead, the value is
372
+ // a back-edge arg and the un-rotation substitution already reads it — no repair needed.
373
+ //
374
+ // `PREUPDATE_SINK_GATES` holds the per-candidate refusals, ablatable one at a time. Two rules are
375
+ // properties of the EDGE rather than of a candidate and stay here: the exit edge is a PARALLEL
376
+ // copy, so splitting it across two program points must not let two slots claim one name, nor let
377
+ // a slot that stays behind read a name the body now writes first.
378
+ //
379
+ // The third parallel-copy question — one sunk slot's REBUILT expression reading another sunk
380
+ // slot's destination — needs no rule, and the copies are emitted sequentially because of it.
381
+ // Every leaf such an expression could read is already refused by a per-candidate gate: a header
382
+ // param makes the other slot fail `dest-not-loop-variable`; a body-defined value is
383
+ // `stale-name`; and a value defined outside the loop but read on the exit edge is live-in at the
384
+ // header (analysis.ts counts a successor arg as a use at the predecessor's end), which makes the
385
+ // other slot fail `dest-free-inside-loop`.
386
+ const sinkablePreUpdateSlots = (
387
+ header: Block,
388
+ exit: Block,
389
+ exitArgs: readonly Value[],
390
+ body: Set<Block>,
391
+ sub: Map<Value, string>,
392
+ updateWrites: Set<string>,
393
+ gates: readonly Gate<SinkCandidate>[] = PREUPDATE_SINK_GATES,
394
+ ): Set<number> => {
395
+ const none = new Set<number>();
396
+ const headerNames = new Set(header.params.map((p) => varName.get(p)));
397
+ // Is `v` defined by the loop body itself — an op in one of its blocks, or a block param?
398
+ // An op with no `opBlock` entry counts as INSIDE: the map is total over the function, and the
399
+ // safe direction for an absent one is the answer that refuses.
400
+ const definedInBody = (v: Value): boolean => {
401
+ const d = defs.get(v);
402
+ if (!d) {
403
+ return [...body].some((bb) => bb.params.includes(v));
404
+ }
405
+ const b = opBlock.get(d);
406
+ return b === undefined || body.has(b);
407
+ };
408
+ // Does any value OTHER than `self` under `name` live inside the loop? A value defined outside
409
+ // it and read anywhere in the body must be live-in at the header, the loop's only entry, so
410
+ // that check plus the body's own defs and params covers every way the name is still in use.
411
+ // The loop's OWN variables are left to `dest-not-loop-variable` — a header param is also a
412
+ // body param, so the two gates partition the names instead of overlapping.
413
+ const busyInLoop = (name: string, self: Value): boolean => {
414
+ for (const [v, n] of varName) {
415
+ if (n !== name || v === self || header.params.includes(v)) {
416
+ continue;
417
+ }
418
+ if (liveIn.get(header)!.has(v) || definedInBody(v)) {
419
+ return true;
420
+ }
421
+ }
422
+ return false;
423
+ };
424
+ // Everything that stops `a` from being REBUILT at the top of the body. Walks the def-tree
425
+ // where `exprWith(null)` will when the copy is spelled — stopping at a NAMED value, which
426
+ // renders as its name, and at a value with no reaching def, which renders as a gap.
427
+ //
428
+ // The walk OVER-approximates what renders, which is the safe direction: `lowerDef` recurses
429
+ // only through `e(d.operands[...])`, so every value it reaches is one this visited. Two things
430
+ // would break that. A lowering reaching for a value OUTSIDE its op's operands — none does
431
+ // today, and it is the change to watch for. And a lowering that DISCARDS the operand tree and
432
+ // spells something else: `respelledDefs` is that case, refused outright below rather than
433
+ // walked, because what it renders is a memory read this walk would never have seen.
434
+ //
435
+ // `undef` and `laddr` render a name from their own side maps rather than `varName`, and both
436
+ // are position-independent — an `undef` is never assigned, a `laddr` is an address.
437
+ const blockersOf = (a: Value): ReadonlySet<ArgBlocker> => {
438
+ const seen = new Set<Value>();
439
+ const found = new Set<ArgBlocker>();
440
+ const walk = (x: Value): void => {
441
+ if (seen.has(x)) {
442
+ return;
443
+ }
444
+ seen.add(x);
445
+ if (header.params.includes(x)) {
446
+ return; // a loop variable: at the top of the body its name holds exactly this
447
+ }
448
+ const n = varName.get(x);
449
+ if (n !== undefined) {
450
+ if (definedInBody(x) || headerNames.has(n) || busyInLoop(n, x)) {
451
+ found.add('stale-name');
452
+ }
453
+ return;
454
+ }
455
+ const d = defs.get(x);
456
+ if (!d) {
457
+ found.add('no-definition');
458
+ return;
459
+ }
460
+ if (REEVAL_UNSAFE_OPS.has(d.opcode) || respelledDefs.has(d)) {
461
+ found.add('order-sensitive');
462
+ }
463
+ d.operands.forEach(walk);
464
+ };
465
+ walk(a);
466
+ return found;
467
+ };
468
+ const dest = new Map<number, string>();
469
+ exitArgs.forEach((a, j) => {
470
+ if (!readsClobbered(a, sub, updateWrites)) {
471
+ return; // no hazard on this slot — nothing to repair
472
+ }
473
+ const destName = varName.get(exit.params[j]);
474
+ const c: SinkCandidate = {
475
+ argBlockers: blockersOf(a),
476
+ destName,
477
+ headerNames,
478
+ updateWrites,
479
+ destBusyInLoop: destName !== undefined && busyInLoop(destName, exit.params[j]),
480
+ };
481
+ if (firstRejection(gates, c) === null) {
482
+ dest.set(j, destName!);
483
+ }
484
+ });
485
+ const names = new Set(dest.values());
486
+ if (dest.size === 0 || names.size !== dest.size) {
487
+ return none;
488
+ }
489
+ return exitArgs.some((a, j) => !dest.has(j) && readsClobbered(a, sub, names)) ? none : new Set(dest.keys());
490
+ };
140
491
 
141
- return { readsClobbered, loopEscapeHazard, loopUpdateHazard };
492
+ return {
493
+ readsClobbered,
494
+ loopEscapeHazard,
495
+ loopUpdateHazard,
496
+ sinkablePreUpdateSlots,
497
+ sameAtEntry: (a, b, entry, negated = false) => sameAtEntry(defs, a, b, entry, negated),
498
+ loopWriteSet,
499
+ };
500
+ }
501
+
502
+ /** THE WRITE RELOCATION THE UNDEF EDGE-COPY ELISION CANNOT SEE, as a postcondition on a whole
503
+ * function. `undefCarriesNothing` (structure.ts) drops the copy into `name` on the edge out of
504
+ * `pred` after proving no value in `name`'s class has a definition able to run before that edge.
505
+ * It reads each such definition's home off `paramBlock`/`opBlock`, and a SUNK pre-update exit copy
506
+ * is written somewhere else than its home says: its destination is the loop EXIT's param, so the
507
+ * model homes it after the loop, while `preUpdateCopies` really writes it at the top of the body,
508
+ * on every iteration, ahead of any edge inside that body.
509
+ *
510
+ * Nothing today puts the two together — a merge inside the body that adopted the exit param's name
511
+ * is a block param `definedInBody` sees, so `dest-free-inside-loop` refuses the sink before it
512
+ * starts. But that gate carries its own KNOWN GAP (the natural-loop body excludes an early-return
513
+ * arm's blocks, so a name assigned only there is invisible to it), which makes the pair a
514
+ * conjecture rather than a proof. Checked here so that a widening of either — a broader sink, or a
515
+ * naming pass that lets a body merge adopt an exit param's name — DECLINES instead of silently
516
+ * substituting a defined value for the undefined one the machine leaves in place.
517
+ *
518
+ * `reaches` is the caller's reachability (structure.ts's `reachFrom`), passed in so this stays a
519
+ * pure function of the two records. Returns the colliding name, or null. */
520
+ export function sunkCopyOverDroppedUndef(
521
+ drops: ReadonlyArray<{ name: string; pred: Block }>,
522
+ sunkCopies: ReadonlyArray<{ name: string; home: Block }>,
523
+ reaches: (home: Block, pred: Block) => boolean,
524
+ ): string | null {
525
+ for (const d of drops) {
526
+ for (const s of sunkCopies) {
527
+ if (s.name === d.name && (s.home === d.pred || reaches(s.home, d.pred))) {
528
+ return d.name;
529
+ }
530
+ }
531
+ }
532
+ return null;
142
533
  }
@@ -6,8 +6,8 @@
6
6
  // owns. It has NOTHING to do with AST emission and is unit-testable on synthetic CFGs with no
7
7
  // emitter and no toolchain (test/loops.test.ts).
8
8
  //
9
- // Dominators live here too: loop discovery is their primary consumer, and keeping the analysis
10
- // self-contained keeps the module independently testable.
9
+ // Dominance itself is a substrate fact and lives in `ir/core.ts`; `analyzeLoops` takes the map as
10
+ // a parameter, so a caller passes `dominators(fn)` from there.
11
11
  import { Block, Fn, predecessors, successorsOf } from '../ir/core';
12
12
 
13
13
  /** One natural loop, keyed by its header. */
@@ -38,41 +38,6 @@ export interface LoopForest {
38
38
  parent: Map<Block, Block | null>;
39
39
  }
40
40
 
41
- /** Forward dominators (iterative data-flow). dom(b) = {b} ∪ ⋂ dom(preds). */
42
- export function dominators(fn: Fn): Map<Block, Set<Block>> {
43
- const preds = predecessors(fn);
44
- const all = new Set(fn.blocks);
45
- const dom = new Map<Block, Set<Block>>();
46
- fn.blocks.forEach((b, i) => dom.set(b, i === 0 ? new Set([b]) : new Set(all)));
47
- let changed = true;
48
- while (changed) {
49
- changed = false;
50
- for (const b of fn.blocks.slice(1)) {
51
- const ps = preds.get(b)!;
52
- let inter: Set<Block> | null = null;
53
- for (const p of ps) {
54
- const dp = dom.get(p)!;
55
- if (inter === null) {
56
- inter = new Set(dp);
57
- continue;
58
- }
59
- for (const x of inter) {
60
- if (!dp.has(x)) {
61
- inter.delete(x);
62
- }
63
- } // intersect in place (spec-safe delete-in-iter)
64
- }
65
- const next = new Set<Block>(inter ?? []);
66
- next.add(b);
67
- if (!setEq(next, dom.get(b)!)) {
68
- dom.set(b, next);
69
- changed = true;
70
- }
71
- }
72
- }
73
- return dom;
74
- }
75
-
76
41
  /** Discover every natural loop and the nesting forest. Pure over the CFG + dominators. */
77
42
  export function analyzeLoops(fn: Fn, dom: Map<Block, Set<Block>>): LoopForest {
78
43
  const preds = predecessors(fn);
@@ -155,15 +120,3 @@ export function analyzeLoops(fn: Fn, dom: Map<Block, Set<Block>>): LoopForest {
155
120
 
156
121
  return { byHeader, parent };
157
122
  }
158
-
159
- function setEq<X>(a: Set<X>, b: Set<X>): boolean {
160
- if (a.size !== b.size) {
161
- return false;
162
- }
163
- for (const x of a) {
164
- if (!b.has(x)) {
165
- return false;
166
- }
167
- }
168
- return true;
169
- }