@asmlift/core 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/package.json +14 -0
  4. package/src/backend/c.ts +20 -0
  5. package/src/backend/cfamily.ts +352 -0
  6. package/src/backend/cpp.ts +145 -0
  7. package/src/backend/pascal.ts +279 -0
  8. package/src/contracts.ts +131 -0
  9. package/src/detect.ts +12 -0
  10. package/src/frontend/asmdata.ts +170 -0
  11. package/src/frontend/disasm.ts +102 -0
  12. package/src/frontend/emit.ts +57 -0
  13. package/src/frontend/errors.ts +14 -0
  14. package/src/frontend/format.ts +47 -0
  15. package/src/frontend/frontend.ts +22 -0
  16. package/src/frontend/mips.ts +875 -0
  17. package/src/frontend/opaque.ts +82 -0
  18. package/src/frontend/ppc.ts +990 -0
  19. package/src/frontend/registry.ts +34 -0
  20. package/src/frontend/ssa.ts +214 -0
  21. package/src/frontend/thumb.ts +1419 -0
  22. package/src/ir/core.ts +104 -0
  23. package/src/ir/opcodes.ts +143 -0
  24. package/src/ir/parse.ts +221 -0
  25. package/src/ir/print.ts +77 -0
  26. package/src/ir/types.ts +106 -0
  27. package/src/ir/verify.ts +221 -0
  28. package/src/l3/ast.ts +301 -0
  29. package/src/l3/basecse.ts +218 -0
  30. package/src/l3/dce.ts +256 -0
  31. package/src/l3/regspell.ts +331 -0
  32. package/src/l3/reindex.ts +447 -0
  33. package/src/l3/typing.ts +145 -0
  34. package/src/mangle.ts +135 -0
  35. package/src/pattern/engine.ts +392 -0
  36. package/src/pipeline.ts +272 -0
  37. package/src/proto.ts +42 -0
  38. package/src/raise/arrays.ts +84 -0
  39. package/src/raise/const.ts +52 -0
  40. package/src/raise/errors.ts +10 -0
  41. package/src/raise/magicdiv.ts +386 -0
  42. package/src/raise/pre-recovery.ts +71 -0
  43. package/src/raise/recover.ts +215 -0
  44. package/src/raise/retsink.ts +72 -0
  45. package/src/raise/shortcircuit.ts +207 -0
  46. package/src/raise/softdiv.ts +62 -0
  47. package/src/raise/struct-arrays.ts +257 -0
  48. package/src/raise/structs.ts +223 -0
  49. package/src/rank.ts +208 -0
  50. package/src/structure/analysis.ts +410 -0
  51. package/src/structure/hazards.ts +142 -0
  52. package/src/structure/loops.ts +169 -0
  53. package/src/structure/structure.ts +1726 -0
  54. package/src/structure/switch-recover.ts +410 -0
  55. package/src/target.ts +140 -0
  56. package/src/trace.ts +233 -0
@@ -0,0 +1,410 @@
1
+ // asmlift structurer — the ANALYSIS phase. Pure derivation over the lifted fn — nothing here
2
+ // mutates the IR or depends on naming/emission state:
3
+ // • use-site registry — every use of a value, POSITIONED (op + block + index);
4
+ // • per-block SSA value liveness (backward dataflow) — consumed by the coalescing
5
+ // interference check in structure.ts;
6
+ // • the effect-ordering model — which call/load defs must MATERIALIZE as named temps at
7
+ // their own program position instead of inlining at their use.
8
+ import { Block, Fn, Op, Value, successorsOf } from '../ir/core';
9
+
10
+ export interface UseSite {
11
+ blk: Block;
12
+ idx: number;
13
+ op: Op;
14
+ }
15
+
16
+ export interface StructureAnalysis {
17
+ /** every positioned use of a value; a value absent here is dead */
18
+ useSitesOf: Map<Value, UseSite[]>;
19
+ opIndex: Map<Op, number>;
20
+ opBlock: Map<Op, Block>;
21
+ /** SSA values live at each block's entry */
22
+ liveIn: Map<Block, Set<Value>>;
23
+ /** call/load defs that must emit as named temps at their own position */
24
+ materialize: Set<Op>;
25
+ /** cached forward reachability (successors-transitive, excluding the start block itself) */
26
+ reachFrom: (b: Block) => Set<Block>;
27
+ }
28
+
29
+ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
30
+ // ── use registry ────────────────────────────────────────────────────────────────────────
31
+ // Every use of a value, POSITIONED: the consuming op and its block/index. Successor args are
32
+ // uses AT the terminator (they render in argAssigns at block end). A void function's `ret`
33
+ // operand is a phantom, not a real use — skipping it lets a call whose result ONLY flows into
34
+ // the suppressed return read as a dead (side-effect) call, so `sideEffects()` emits it.
35
+ // One operand SLOT = one entry (an op reading a value twice records two uses — that count is
36
+ // what decides whether an inlined call would EXECUTE twice).
37
+ const useSitesOf = new Map<Value, UseSite[]>();
38
+ const opIndex = new Map<Op, number>();
39
+ const opBlock = new Map<Op, Block>();
40
+ const blockPos = new Map<Block, number>();
41
+ for (const b of fn.blocks) {
42
+ blockPos.set(b, blockPos.size);
43
+ b.ops.forEach((op, i) => {
44
+ opIndex.set(op, i);
45
+ opBlock.set(op, b);
46
+ });
47
+ }
48
+ // Linear program position (block order, then op order): a call "between" a def and a use is one
49
+ // whose position lies strictly between them. This is a PROXY for "a call on a def→use path", not
50
+ // the real dataflow: it checks position order only, not reachability. It can therefore FALSE-
51
+ // POSITIVE — a call in a forward SIBLING branch (never traversed on the def→use path) still sits
52
+ // between them by position (e.g. `def; if(c){call;ret} else {…use…use}`), and back-edges are not
53
+ // modelled either. That is SAFE ONLY BECAUSE the caller materializes exactly `const` ops: a const
54
+ // is a relocation-invariant leaf whose def dominates every use, so binding it to a local is
55
+ // UNCONDITIONALLY semantics-preserving on every path — a false positive costs at most a match (an
56
+ // extra `v =` the compiler would have re-inlined), caught by the zero-lost gate, never wrong C.
57
+ // RE-VERIFY this before widening the whitelist to any value that is not path-independent or that
58
+ // carries a use-site cast (an address computation `&g + i`), for which a false positive is unsound.
59
+ const linPos = (op: Op): number => blockPos.get(opBlock.get(op)!)! * 1e6 + opIndex.get(op)!;
60
+ const callPos: number[] = [];
61
+ for (const b of fn.blocks) {
62
+ for (const op of b.ops) {
63
+ if (op.opcode === 'call') {
64
+ callPos.push(linPos(op));
65
+ }
66
+ }
67
+ }
68
+ /** True if `def`'s value is still needed after a call — a call lies strictly between the def and
69
+ * one of its `consumers`. Such a value survives in a callee-saved register (a local), which is
70
+ * what materializing it reproduces. */
71
+ const liveAcrossCall = (def: Op, consumers: Op[]): boolean => {
72
+ const dp = linPos(def);
73
+ const usePos = consumers.map(linPos);
74
+ return callPos.some((c) => c > dp && usePos.some((u) => u > c));
75
+ };
76
+ for (const b of fn.blocks) {
77
+ b.ops.forEach((op, i) => {
78
+ if (returnsVoid && op.opcode === 'ret') {
79
+ return;
80
+ }
81
+ const site: UseSite = { blk: b, idx: i, op };
82
+ const add = (v: Value) => {
83
+ const arr = useSitesOf.get(v);
84
+ if (arr) {
85
+ arr.push(site);
86
+ } else {
87
+ useSitesOf.set(v, [site]);
88
+ }
89
+ };
90
+ for (const u of op.operands) {
91
+ add(u);
92
+ }
93
+ for (const s of op.successors) {
94
+ for (const a of s.args) {
95
+ add(a);
96
+ }
97
+ }
98
+ });
99
+ }
100
+
101
+ // ── per-block liveness of SSA values ──────────────────────────────────────────────────────
102
+ // Backward dataflow. Successor args count as uses at the END of the predecessor (they render
103
+ // in the predecessor's argAssigns), so liveIn(B) means precisely "read at-or-after B's entry".
104
+ // Consumed by the coalescing interference check: merging two values that are ever
105
+ // simultaneously live into one variable name is the textbook silent clobber.
106
+ const liveIn = new Map<Block, Set<Value>>();
107
+ for (const b of fn.blocks) {
108
+ liveIn.set(b, new Set());
109
+ }
110
+ for (let liveChanged = true; liveChanged;) {
111
+ liveChanged = false;
112
+ for (let bi = fn.blocks.length - 1; bi >= 0; bi--) {
113
+ const b = fn.blocks[bi];
114
+ const live = new Set<Value>();
115
+ for (const s of successorsOf(b)) {
116
+ for (const v of liveIn.get(s)!) {
117
+ live.add(v);
118
+ }
119
+ }
120
+ for (let oi = b.ops.length - 1; oi >= 0; oi--) {
121
+ const op = b.ops[oi];
122
+ for (const r of op.results) {
123
+ live.delete(r);
124
+ }
125
+ for (const s of op.successors) {
126
+ for (const a of s.args) {
127
+ live.add(a);
128
+ }
129
+ }
130
+ if (!(returnsVoid && op.opcode === 'ret')) {
131
+ for (const u of op.operands) {
132
+ live.add(u);
133
+ }
134
+ }
135
+ }
136
+ for (const p of b.params) {
137
+ live.delete(p);
138
+ }
139
+ const cur = liveIn.get(b)!;
140
+ if (live.size !== cur.size || ![...live].every((v) => cur.has(v))) {
141
+ liveIn.set(b, live);
142
+ liveChanged = true;
143
+ }
144
+ }
145
+ }
146
+
147
+ // ── the effect-ordering model — inline-at-use barriers ────────────────────────────────────
148
+ // `expr()` renders a def's computation AT ITS USE, which silently MOVES it: a call executes
149
+ // once per rendered copy (`foo(a0)+foo(a0)`), a load reads memory at the render point (it can
150
+ // textually sink past an aliasing store). The model: a call/load/aload def may inline ONLY
151
+ // when rendering cannot change behavior — exactly one render position, and the program-order
152
+ // gap between def and render crosses no memory write (loads) / no memory access at all (calls,
153
+ // whose own reads+writes must not reorder against anything). Every other case gets a NAMED
154
+ // TEMP assigned at the def's own program position (sideEffects) — which is precisely the
155
+ // register the compiler used.
156
+ const materialize = new Set<Op>();
157
+ const reachCache = new Map<Block, Set<Block>>();
158
+ const reachFrom = (b: Block): Set<Block> => {
159
+ let r = reachCache.get(b);
160
+ if (r) {
161
+ return r;
162
+ }
163
+ r = new Set<Block>();
164
+ const stack = [...successorsOf(b)];
165
+ while (stack.length) {
166
+ const x = stack.pop()!;
167
+ if (r.has(x)) {
168
+ continue;
169
+ }
170
+ r.add(x);
171
+ stack.push(...successorsOf(x));
172
+ }
173
+ reachCache.set(b, r);
174
+ return r;
175
+ };
176
+ // Reachability that never passes THROUGH `avoid` — the def-block-avoiding variant for
177
+ // per-iteration path checks: a path that re-enters the def's block re-executes the def, so
178
+ // writes on it belong to the NEXT dynamic instance (which re-renders anyway) and must not
179
+ // count against this one. Uncached (per-decision graphs are small).
180
+ const reachAvoiding = (from: Block, avoid: Block): Set<Block> => {
181
+ const r = new Set<Block>();
182
+ const stack = successorsOf(from).filter((s) => s !== avoid);
183
+ while (stack.length) {
184
+ const x = stack.pop()!;
185
+ if (r.has(x)) {
186
+ continue;
187
+ }
188
+ r.add(x);
189
+ for (const s of successorsOf(x)) {
190
+ if (s !== avoid && !r.has(s)) {
191
+ stack.push(s);
192
+ }
193
+ }
194
+ }
195
+ return r;
196
+ };
197
+ // Where a value's expression is ultimately EMITTED: the anchored consumer (statement op,
198
+ // terminator, materialized def) it inlines into, transitively through single-use pure ops.
199
+ // null = renders in several places / unresolvable (treated conservatively by the caller).
200
+ const emitPosCache = new Map<Op, { blk: Block; idx: number } | null>();
201
+ const emitPos = (op: Op): { blk: Block; idx: number } | null => {
202
+ if (emitPosCache.has(op)) {
203
+ return emitPosCache.get(op)!;
204
+ }
205
+ const own = { blk: opBlock.get(op)!, idx: opIndex.get(op)! };
206
+ let res: { blk: Block; idx: number } | null;
207
+ if (
208
+ op.successors.length ||
209
+ op.opcode === 'ret' ||
210
+ op.opcode === 'store' ||
211
+ op.opcode === 'astore' ||
212
+ materialize.has(op) ||
213
+ !op.results.length ||
214
+ !useSitesOf.has(op.results[0])
215
+ ) {
216
+ res = own; // statements, terminators, materialized/dead defs
217
+ } else {
218
+ const consumers = [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))];
219
+ res = consumers.length === 1 ? emitPos(consumers[0]) : null;
220
+ }
221
+ emitPosCache.set(op, res);
222
+ return res;
223
+ };
224
+ // Decide in REVERSE program order so a consumer's own materialization is settled before any
225
+ // producer asks for its emit position (SSA: uses follow defs in dominance/layout order) — and
226
+ // iterate to a fixpoint for IR whose block layout does not follow dominance (hand-built IR):
227
+ // materialize only GROWS, and growing it only moves render positions closer / adds barriers,
228
+ // so the loop is monotone and converges.
229
+ for (let sizeBefore = -1; sizeBefore !== materialize.size;) {
230
+ sizeBefore = materialize.size;
231
+ emitPosCache.clear();
232
+ for (let bi = fn.blocks.length - 1; bi >= 0; bi--) {
233
+ const b = fn.blocks[bi];
234
+ for (let oi = b.ops.length - 1; oi >= 0; oi--) {
235
+ const op = b.ops[oi];
236
+ if (materialize.has(op)) {
237
+ continue;
238
+ }
239
+ if (op.opcode !== 'call' && op.opcode !== 'load' && op.opcode !== 'aload') {
240
+ // PURE value-producing op (a constant, an address computation, arithmetic — NOT a
241
+ // memory access). A value with ≥2 distinct-STATEMENT uses in the SSA is one the compiler
242
+ // kept in a register and reused: the frontend never dedups, so multi-use exists ONLY
243
+ // because the asm loaded/computed the value once and read the same register again.
244
+ // Inlining it re-derives the value at each use (a fresh pool load / repeated address
245
+ // arithmetic) — which the compiler did NOT do — so materialize it into a local instead,
246
+ // reproducing that register. Pure ⇒ every render is value-identical, so (unlike a load)
247
+ // no intervening memory write can invalidate a later render: multi-consumer suffices, no
248
+ // barrier scan. Scope: a `const` that is LIVE ACROSS A CALL. A value the compiler needs
249
+ // after a call must survive in a CALLEE-SAVED register — i.e. a local — because the call
250
+ // clobbers the caller-saved ones; the compiler therefore loads it ONCE and keeps it,
251
+ // exactly what materializing into a local reproduces (the base of `((s32 *)C)[i]` reused
252
+ // across `foo(...)` calls). WITHOUT a call in its live range the const is instead cheaply
253
+ // re-materialized at each use (a bare `movs r, #0` per init), so materializing it would
254
+ // ADD pointless copies and MISS — hence the call gate (the small-constant regression).
255
+ // Cheap deref casts still land on the `index` node at the use, preserving byte strides;
256
+ // NON-const pure ops are excluded (an address computation `&g + i` rendered standalone
257
+ // loses the memAccess's inline `(u8 *)` cast — cast-aware base materialization is separate).
258
+ const pr = op.results[0];
259
+ if (op.opcode === 'const' && pr && useSitesOf.has(pr)) {
260
+ const cons = [...new Set((useSitesOf.get(pr) ?? []).map((s) => s.op))];
261
+ if (cons.length > 1 && liveAcrossCall(op, cons)) {
262
+ materialize.add(op);
263
+ }
264
+ }
265
+ continue;
266
+ }
267
+ const r = op.results[0];
268
+ if (!r || !useSitesOf.has(r)) {
269
+ continue;
270
+ } // dead call → exprstmt (unchanged)
271
+ const sites = useSitesOf.get(r)!;
272
+ const consumers = [...new Set(sites.map((s) => s.op))];
273
+ const isCall = op.opcode === 'call';
274
+ // A call must EXECUTE once — any second operand slot duplicates it → named temp.
275
+ if (isCall && sites.length > 1) {
276
+ materialize.add(op);
277
+ continue;
278
+ }
279
+ // A MULTI-RENDER load re-reads memory at each render — which is exactly what the original
280
+ // per-use source spelling did (`while (*s != EOS) *d = *s;` reads *s twice per iteration),
281
+ // so it is sound iff every render still sees the def-time memory: NO write anywhere
282
+ // between the def and ANY render (cycle-aware, conservative write set). Otherwise a temp.
283
+ if (!isCall && consumers.length > 1) {
284
+ const MW = new Set(['store', 'astore', 'call', 'opaque']);
285
+ const wDirty = (list: Op[], from: number, to: number) => {
286
+ for (let k = from; k < to; k++) {
287
+ if (MW.has(list[k].opcode)) {
288
+ return true;
289
+ }
290
+ }
291
+ return false;
292
+ };
293
+ const defToRenderDirty = (q: { blk: Block; idx: number }): boolean => {
294
+ // Same block: the only def-avoiding path is the straight line between the two indices
295
+ // (leaving and re-entering the block re-crosses the def).
296
+ if (q.blk === b && oi < q.idx) {
297
+ return wDirty(b.ops, oi + 1, q.idx);
298
+ }
299
+ if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(q.blk.ops, 0, q.idx)) {
300
+ return true;
301
+ }
302
+ const between = reachAvoiding(b, b);
303
+ for (const x of between) {
304
+ if (x === q.blk && !reachAvoiding(q.blk, b).has(q.blk)) {
305
+ continue;
306
+ } // acyclic render blk: head checked
307
+ if (x !== q.blk && !reachAvoiding(x, b).has(q.blk)) {
308
+ continue;
309
+ } // not on a def→render path
310
+ if (wDirty(x.ops, 0, x.ops.length)) {
311
+ return true;
312
+ }
313
+ }
314
+ return false;
315
+ };
316
+ const poss = consumers.map((c) => emitPos(c));
317
+ if (poss.some((p) => p === null) || poss.some((p) => defToRenderDirty(p!))) {
318
+ materialize.add(op);
319
+ }
320
+ continue;
321
+ }
322
+ const pos = emitPos(consumers[0]);
323
+ if (!pos) {
324
+ materialize.add(op);
325
+ continue;
326
+ }
327
+ // A between-op is a BARRIER when it renders as a sequenced statement the def would cross:
328
+ // stores/opaque always; a call/load that is dead (statement), materialized (statement), or
329
+ // inlined into a DIFFERENT statement. A sibling effect inlined into the SAME statement is
330
+ // not a reorder — the recompiling compiler orders unsequenced operands of one expression
331
+ // exactly as it originally chose to. Loads never bar a load (reads don't conflict).
332
+ const samePos = (q: { blk: Block; idx: number } | null) => q !== null && q.blk === pos.blk && q.idx === pos.idx;
333
+ const isBarrier = (x: Op): boolean => {
334
+ if (x.opcode === 'store') {
335
+ // A store to a PROVABLY-DISJOINT slot of the same base never aliases the load: same
336
+ // base SSA value, both constant offset+width, ranges non-overlapping (the everyday
337
+ // struct interleave `… = p->field_0; p->field_4 = …`). Anything less certain bars.
338
+ if (!isCall && op.opcode === 'load' && x.operands[0] === op.operands[0]) {
339
+ const lo = op.attrs.off as number,
340
+ lw = op.attrs.width as number;
341
+ const so = x.attrs.off as number,
342
+ sw = x.attrs.width as number;
343
+ if (so + sw <= lo || lo + lw <= so) {
344
+ return false;
345
+ }
346
+ }
347
+ return true;
348
+ }
349
+ if (x.opcode === 'astore' || x.opcode === 'opaque') {
350
+ return true;
351
+ }
352
+ if (x.opcode === 'call') {
353
+ return !x.results.length || !useSitesOf.has(x.results[0]) || materialize.has(x) || !samePos(emitPos(x));
354
+ }
355
+ if (!isCall) {
356
+ return false;
357
+ } // a load never bars a load
358
+ if (x.opcode === 'load' || x.opcode === 'aload') {
359
+ return !x.results.length || !useSitesOf.has(x.results[0])
360
+ ? false // dead load: never emitted at all
361
+ : materialize.has(x) || !samePos(emitPos(x));
362
+ }
363
+ return false;
364
+ };
365
+ const gapDirty = (list: Op[], from: number, to: number) => {
366
+ for (let k = from; k < to; k++) {
367
+ if (isBarrier(list[k])) {
368
+ return true;
369
+ }
370
+ }
371
+ return false;
372
+ };
373
+ if (pos.blk === b) {
374
+ if (gapDirty(b.ops, oi + 1, pos.idx)) {
375
+ materialize.add(op);
376
+ }
377
+ continue;
378
+ }
379
+ // Cross-block: a call's execution would become path-dependent — always materialize. A
380
+ // load may inline only if NO write exists on any DEF-AVOIDING def→render path (a path
381
+ // re-crossing the def is the next dynamic instance): the def block's tail, the render
382
+ // block's head, and every block between; a render block cyclic WITHOUT passing the def
383
+ // (an inner loop around the render) is checked in full.
384
+ if (isCall) {
385
+ materialize.add(op);
386
+ continue;
387
+ }
388
+ let dirty = gapDirty(b.ops, oi + 1, b.ops.length) || gapDirty(pos.blk.ops, 0, pos.idx);
389
+ if (!dirty) {
390
+ for (const x of reachAvoiding(b, b)) {
391
+ if (x === pos.blk && !reachAvoiding(pos.blk, b).has(pos.blk)) {
392
+ continue;
393
+ } // acyclic render block: head checked
394
+ if (x !== pos.blk && !reachAvoiding(x, b).has(pos.blk)) {
395
+ continue;
396
+ } // not on a def→render path
397
+ if (gapDirty(x.ops, 0, x.ops.length)) {
398
+ dirty = true;
399
+ break;
400
+ }
401
+ }
402
+ }
403
+ if (dirty) {
404
+ materialize.add(op);
405
+ }
406
+ }
407
+ }
408
+ }
409
+ return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom };
410
+ }
@@ -0,0 +1,142 @@
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.
6
+ //
7
+ // The factory takes its dependencies EXPLICITLY (`LoopHazardDeps`), the switch-recover pattern.
8
+ // The maps are captured as LIVE REFERENCES, deliberately: `varName` is still being populated by
9
+ // the naming pipeline when the factory is created, and each hazard check reads whatever names
10
+ // exist at CALL time (emission runs after naming completes). Snapshotting them would break this.
11
+ import { Block, Op, Value } from '../ir/core';
12
+ import { Stmt } from '../l3/ast';
13
+ import type { UseSite } from './analysis';
14
+
15
+ export interface LoopHazardDeps {
16
+ /** value → defining op (defOpMap) */
17
+ defs: Map<Value, Op>;
18
+ /** value → adopted variable name — LIVE: populated by the naming pipeline, read at call time */
19
+ varName: Map<Value, string>;
20
+ /** every positioned use of a value (analysis.ts) */
21
+ useSitesOf: Map<Value, UseSite[]>;
22
+ }
23
+
24
+ export interface LoopHazards {
25
+ readsClobbered(v: Value, sub: Map<Value, string>, updateWrites: Set<string>): boolean;
26
+ loopEscapeHazard(
27
+ body: Set<Block>,
28
+ sub: Map<Value, string>,
29
+ updateWrites: Set<string>,
30
+ region?: Set<Block> | null,
31
+ loopParams?: Set<Value>,
32
+ ): boolean;
33
+ loopUpdateHazard(
34
+ condV: Value,
35
+ exitArgs: Value[],
36
+ body: Set<Block>,
37
+ sub: Map<Value, string>,
38
+ updateWrites: Set<string>,
39
+ region: Set<Block> | null,
40
+ loopParams: Set<Value>,
41
+ ): boolean;
42
+ }
43
+
44
+ /** The names a loop update assigns (its non-identity copies) — the write set every loop-emission
45
+ * hazard check tests against. Dependency-free, so a plain function, not a factory member. */
46
+ export const updateWriteSet = (updates: Stmt[]): Set<string> =>
47
+ new Set(updates.filter((st): st is Extract<Stmt, { k: 'assign' }> => st.k === 'assign').map((st) => st.name));
48
+
49
+ export function makeLoopHazards(deps: LoopHazardDeps): LoopHazards {
50
+ const { defs, varName, useSitesOf } = deps;
51
+
52
+ // Does rendering `v` under `sub` read a variable that a pending loop update (`updateWrites`, the
53
+ // names it assigns) overwrites, via a path OTHER than a `sub`-mapped back-edge arg? Such a read is a
54
+ // PRE-update value the update clobbers → a read-after-write hazard when the update is emitted first.
55
+ // Walks the def-tree exactly like `exprWith`, stopping at `sub` values (intended post-update → safe)
56
+ // and named values (a var: hazard iff its name is a write-target). Pure (no mutation), so it is safe
57
+ // to call before emitting.
58
+ const readsClobbered = (v: Value, sub: Map<Value, string>, updateWrites: Set<string>): boolean => {
59
+ const seen = new Set<Value>();
60
+ const walk = (x: Value): boolean => {
61
+ if (seen.has(x)) {
62
+ return false;
63
+ }
64
+ seen.add(x);
65
+ if (sub.has(x)) {
66
+ return false;
67
+ } // sub-mapped → post-update, safe
68
+ if (varName.has(x)) {
69
+ return updateWrites.has(varName.get(x)!);
70
+ } // a named var: hazard iff clobbered
71
+ const d = defs.get(x);
72
+ return d ? d.operands.some(walk) : false; // inline (mirrors exprWith's recursion)
73
+ };
74
+ return walk(v);
75
+ };
76
+
77
+ // A value computed INSIDE a loop and used after it renders post-loop under `sub`, where each
78
+ // updated loop variable already holds its FINAL value. That is only correct when every
79
+ // loop-variable read goes through a sub-mapped back-edge arg (the intended post-update read); a
80
+ // direct read of an updated variable meant the LAST-ITERATION PRE-update value, which the
81
+ // post-loop name no longer holds. Scans every value defined in `body` for a use outside it (or,
82
+ // when `region` is given, inside that specific post-loop region) whose rendering readsClobbered
83
+ // flags. Same hazard test the early-exit path applies to its condition and edge args.
84
+ const loopEscapeHazard = (
85
+ body: Set<Block>,
86
+ sub: Map<Value, string>,
87
+ updateWrites: Set<string>,
88
+ region: Set<Block> | null = null,
89
+ loopParams: Set<Value> = new Set(),
90
+ ): 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.
95
+ const escaped = (v: Value): boolean => {
96
+ for (const s of useSitesOf.get(v) ?? []) {
97
+ if (region ? region.has(s.blk) : !body.has(s.blk)) {
98
+ return true;
99
+ }
100
+ }
101
+ return false;
102
+ };
103
+ for (const bb of body) {
104
+ for (const pv of bb.params) {
105
+ if (loopParams.has(pv)) {
106
+ continue;
107
+ }
108
+ if (escaped(pv) && updateWrites.has(varName.get(pv)!)) {
109
+ return true;
110
+ }
111
+ }
112
+ for (const op of bb.ops) {
113
+ for (const r of op.results) {
114
+ if (escaped(r) && readsClobbered(r, sub, updateWrites)) {
115
+ return true;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return false;
121
+ };
122
+
123
+ // The loop-emission hazard check, in ONE place (shared by the guard-fused, early-exit, and
124
+ // do-while sites): the loop condition, the exit-edge args, and every escaped body value must
125
+ // read loop variables ONLY through sub-mapped back-edge args (post-update); any direct read of
126
+ // an updated name is a pre-update value the emitted C no longer holds. Callers keep their
127
+ // distinct decline behavior.
128
+ const loopUpdateHazard = (
129
+ condV: Value,
130
+ exitArgs: Value[],
131
+ body: Set<Block>,
132
+ sub: Map<Value, string>,
133
+ updateWrites: Set<string>,
134
+ region: Set<Block> | null,
135
+ loopParams: Set<Value>,
136
+ ): boolean =>
137
+ readsClobbered(condV, sub, updateWrites) ||
138
+ exitArgs.some((a) => readsClobbered(a, sub, updateWrites)) ||
139
+ loopEscapeHazard(body, sub, updateWrites, region, loopParams);
140
+
141
+ return { readsClobbered, loopEscapeHazard, loopUpdateHazard };
142
+ }