@asmlift/core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ // asmlift — memory DISJOINTNESS: the one place that answers "could this write change what that
2
+ // read sees". A pure query over L2 (typed SSA); no structuring or emission state.
3
+ //
4
+ // It exists because the answer was being given at three different strengths in three places, the
5
+ // weakest one governing the most common case (the materialization model's multi-render load rule,
6
+ // which barred on ANY write). A read that is barred by a store to an unrelated global is spelled
7
+ // as a named local the source never had — the "value home" defect the round-5 dogfood measured as
8
+ // its single highest cost. One predicate, one strength, one place to sharpen.
9
+ //
10
+ // The rule is deliberately NAME-based and deliberately narrow:
11
+ //
12
+ // • two DIFFERENT named globals are different objects, so a store through one can never change
13
+ // what a read of the other sees. That is a C guarantee about distinct declared objects, not a
14
+ // heuristic about what the compiler happened to do.
15
+ // • name comparison suffices because the pool promotion picks ONE canonical name per address,
16
+ // so a single cell cannot appear under two names within one function (frontend/thumb.ts).
17
+ // • anything that does not resolve to a name — a materialized base, a variable index, a pointer
18
+ // parameter — is unknown, and unknown BARS. A call or an `opaque` bars unconditionally: it may
19
+ // write anything.
20
+ //
21
+ // Being conservative here costs at most a match (an extra local the compiler would have folded);
22
+ // being wrong here is a silently wrong read. Every relaxation must keep that asymmetry.
23
+ import { type Op, type Value } from './core';
24
+
25
+ /** A byte cell of a named global: the symbol plus the byte offset within it. */
26
+ export interface GlobalCell {
27
+ name: string;
28
+ byte: number;
29
+ }
30
+
31
+ /**
32
+ * The named global cell an address value denotes, resolved through defs alone — `gaddr`, or
33
+ * `gaddr + const` in either operand order — plus the access's own `off`. Null when the address
34
+ * does not reduce to a name (a materialized base, a runtime index, a pointer): the caller must
35
+ * then treat it as unknown memory.
36
+ */
37
+ export function globalCellOf(defs: Map<Value, Op>, addr: Value, off: number): GlobalCell | null {
38
+ const d = defs.get(addr);
39
+ if (d?.opcode === 'gaddr') {
40
+ return { name: d.attrs.sym as string, byte: off };
41
+ }
42
+ if (d?.opcode === 'add' && d.operands.length === 2) {
43
+ for (const [x, y] of [
44
+ [d.operands[0], d.operands[1]],
45
+ [d.operands[1], d.operands[0]],
46
+ ] as const) {
47
+ const g = defs.get(x);
48
+ const c = defs.get(y);
49
+ if (g?.opcode === 'gaddr' && c?.opcode === 'const') {
50
+ return { name: g.attrs.sym as string, byte: (c.attrs.value as number) + off };
51
+ }
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * "May op `x` write the global named `sym`?" — the predicate a read of `sym` must clear on every
59
+ * path between its def and each of its render positions (analysis.ts `memWriteBetween`).
60
+ *
61
+ * Calls and opaques always may. A store/astore may unless its base resolves to a DIFFERENT named
62
+ * global. Everything else (pure arithmetic, loads) never writes.
63
+ */
64
+ export function mayWriteGlobal(defs: Map<Value, Op>, sym: string): (x: Op) => boolean {
65
+ return (x: Op): boolean => {
66
+ if (x.opcode === 'call' || x.opcode === 'opaque') {
67
+ return true;
68
+ }
69
+ if (x.opcode !== 'store' && x.opcode !== 'astore') {
70
+ return false;
71
+ }
72
+ const t = globalCellOf(defs, x.operands[0], 0);
73
+ return !(t && t.name !== sym);
74
+ };
75
+ }
package/src/ir/opcodes.ts CHANGED
@@ -108,8 +108,21 @@ export const OPCODES = {
108
108
  // - an indexed or non-zero-offset AGGREGATE access → the address-cast `((T *)&gSym)[i]`;
109
109
  // - any other use (e.g. `&gSym` passed to a call) → the `{k:'addr'}` L3 node, printed `&gSym`.
110
110
  gaddr: { operands: 0, results: 1, requiredAttrs: ['sym'] },
111
+ // The address of a FRAME-LOCAL object — gaddr's local twin, for the address-taken stack local
112
+ // (`mov rD, sp` feeding a DMA register or a callee). `off` is the byte offset inside the frame's
113
+ // reserved local area; the Thumb frontend's post-lift audit stamps `name`/`width`/`signed` after
114
+ // proving every access agrees, and the structurer declares the local and renders `&name` exactly
115
+ // as it renders a gaddr's `&sym`. Operand-free and pure, so GVN numbers it like gaddr and a dead
116
+ // one is reaped.
117
+ // `width`/`signed` are stamped by the frontend's frame-object AUDIT — requiring them makes
118
+ // "the audit ran" a verifier-checkable fact instead of a convention: a frontend that emits a
119
+ // laddr and skips the audit fails verify loudly instead of rendering `&undefined`.
120
+ laddr: { operands: 0, results: 1, requiredAttrs: ['off', 'width', 'signed'] },
111
121
  // --- black-box escape hatch (keeps lifting total) ---
112
- opaque: { operands: 'variadic', results: 1 },
122
+ // `effects: true`: an instruction asmlift could not model may do anything — write memory, trap,
123
+ // touch a system register — and `results[0]` is only the part we can name. So a dead `opaque` is
124
+ // no more reapable than a dead `call`.
125
+ opaque: { operands: 'variadic', results: 1, effects: true },
113
126
  // --- terminators ---
114
127
  ret: { operands: 'variadic', results: 0, terminator: true, successors: 0 },
115
128
  br: { operands: 0, results: 0, terminator: true, successors: 1 },
@@ -162,25 +175,22 @@ export const NEGATED_ICMP: Readonly<Record<string, Opcode>> = Object.fromEntries
162
175
  ]),
163
176
  );
164
177
 
165
- /** Ops with an observable side effect the derived view raise/shortcircuit.ts consumes. */
178
+ /** Ops with an observable side effect: the flag on the signature, derived rather than re-listed.
179
+ * Consumed by `isDceSafe`, by `HOIST_UNSAFE_OPS` below, and by structure.ts's `sideEffects` walk
180
+ * (an effectful op whose result nobody reads is still an execution). */
166
181
  export const EFFECTFUL_OPS: ReadonlySet<string> = new Set(
167
182
  (Object.keys(OPCODES) as Opcode[]).filter((k) => (OPCODES[k] as OpSig).effects),
168
183
  );
169
184
 
170
- /** Ops that may not be REORDERED across other code `EFFECTFUL_OPS` plus `opaque`.
171
- *
172
- * `effects` is overloaded on two axes, and `opaque` is exactly the op that separates them: a dead
173
- * `opaque` MUST stay deletable (`isDceSafe` below says so deliberately giving it `effects: true`
174
- * would strand dead opaques after every pattern rewrite, and they would surface as ASMLIFT_ERROR
175
- * gaps in functions that emit cleanly today), while a LIVE one is an instruction asmlift could not
176
- * model and must not be moved past anything. So "deletable when dead" and "movable when live" are
177
- * different questions and get different views, both derived here rather than re-spelled per
178
- * consumer — structure/analysis.ts and structure/structure.ts each carry their own inline copy of
179
- * this membership, which is how the two models drifted apart in the first place. */
180
- export const HOIST_UNSAFE_OPS: ReadonlySet<string> = new Set([...EFFECTFUL_OPS, 'opaque']);
185
+ /** Ops that may not be REORDERED across other code. Identical to `EFFECTFUL_OPS` one flag answers
186
+ * both "deletable when dead" and "movable when live" — and kept as its own name because the call
187
+ * sites ask the reordering question. Derived here rather than re-spelled per consumer:
188
+ * structure/analysis.ts and structure/structure.ts each carry their own inline copy of this
189
+ * membership, which is how the two models drifted apart in the first place. */
190
+ export const HOIST_UNSAFE_OPS: ReadonlySet<string> = EFFECTFUL_OPS;
181
191
 
182
192
  /** May a dead result of this opcode be deleted? Registered, no observable effects, not control
183
- * flow. Deliberately includes `opaque` — a dead opaque vanishing is designed behavior. */
193
+ * flow. `opaque` is excluded via its `effects` flag see the note on its signature. */
184
194
  export function isDceSafe(opcode: string): boolean {
185
195
  const sig = opSig(opcode);
186
196
  return !!sig && !sig.effects && !sig.terminator;
package/src/l3/argbase.ts CHANGED
@@ -42,7 +42,12 @@ import type { Expr, SFn, Stmt } from './ast';
42
42
  import { mapExprChildren, stmtExprs } from './ast';
43
43
  import { nameAllocator } from './hoist';
44
44
 
45
- /** A base this pass may evaluate early: pure, and not something a store can change under us. */
45
+ /** A base this pass may evaluate early: pure, and not something a store can change under us.
46
+ *
47
+ * NO shadowing filter, unlike scopebase.ts's `isLeaf`, and the asymmetry is deliberate: that pass
48
+ * re-spells the base as `&g`, which under a shadowing local names a different object, while this
49
+ * one keeps the base expression verbatim and places the assignment immediately before the same
50
+ * statement. Adding the filter here only loses hoists (test/addr-placement.test.ts). */
46
51
  function eligibleBase(base: Expr, globals: ReadonlySet<string>): boolean {
47
52
  return base.k === 'addr' || base.k === 'const' || (base.k === 'var' && globals.has(base.name));
48
53
  }
package/src/l3/ast.ts CHANGED
@@ -153,7 +153,7 @@ export interface SwitchCase {
153
153
  export interface SFn {
154
154
  name: string;
155
155
  params: { name: string; type: IrType }[];
156
- locals: { name: string; type: IrType }[]; // recovered locals, declared at function top
156
+ locals: { name: string; type: IrType; volatile?: true }[]; // recovered locals, declared at function top
157
157
  /** project globals referenced with a known declaration shape (symbol map) — typed for the
158
158
  * legalization env (exprCType) but NEVER declared by a backend: the project's own headers
159
159
  * declare them, exactly like every other global name asmlift emits. */
@@ -271,6 +271,14 @@ export function exprEquals(a: Expr, b: Expr): boolean {
271
271
  // recognizeForLoops) rightly keep their own switches.
272
272
 
273
273
  /** The direct sub-expressions of `e`, in syntactic order. */
274
+ /** THE spelling of an unmodelled instruction's gap reason, in one place: `structure.ts` writes it
275
+ * into the marker, `contracts.ts` matches on it to prove the gap was not dropped, and the benchmark
276
+ * classifies declines by it. Two spellings make that contract silently vacuous — enforced-looking
277
+ * and never firing. `?` when a frontend stamps no mnemonic. */
278
+ export function gapReasonFor(mnemonic: unknown): string {
279
+ return `unmodelled instruction '${typeof mnemonic === 'string' ? mnemonic : '?'}'`;
280
+ }
281
+
274
282
  export function exprChildren(e: Expr): Expr[] {
275
283
  switch (e.k) {
276
284
  case 'var':
package/src/l3/basecse.ts CHANGED
@@ -21,12 +21,16 @@
21
21
  import { type IrType, T, scalarTypeForAccess } from '../ir/types';
22
22
  import type { Expr, SFn, Stmt } from './ast';
23
23
  import { mapExprChildren, stmtChildren, stmtExprs } from './ast';
24
+ import { type Gate, firstRejection } from './gates';
24
25
  import { nameAllocator } from './hoist';
25
26
 
26
27
  // A HOISTABLE base is a bare `addr` (a global address) or a bare `const` (a numeric pointer
27
28
  // address). Both are relocation-invariant leaves whose value the compiler keeps in one register
28
29
  // when it indexes them at 2+ sites. Anything else (a local var, a struct-element `p[a0]`, arbitrary
29
- // arithmetic) is NOT — agbcc may re-derive it.
30
+ // arithmetic) is NOT — agbcc may re-derive it. Admitting the bare `var` that scopebase.ts and
31
+ // argbase.ts take is the obvious consolidation and it is wrong twice over: this pass has no `lead`
32
+ // handling, so a rank-aware `g[0][i]` comes out as `p[0][i]` through a scalar pointer, and it
33
+ // undoes raise/gvn.ts's hoist on exactly the rows a symbol map serves (test/addr-placement.test.ts).
30
34
  type HoistableBase = Extract<Expr, { k: 'addr' } | { k: 'const' }>;
31
35
  const isHoistableBase = (e: Expr): e is HoistableBase => e.k === 'addr' || e.k === 'const';
32
36
  const baseId = (b: HoistableBase): string => (b.k === 'addr' ? `a:${b.name}` : `c:${b.value}`);
@@ -40,12 +44,11 @@ interface Collected {
40
44
  meta: Map<string, { base: HoistableBase; width: number; signed: boolean }>;
41
45
  /** keys with ANY use inside a loop — disqualified (see the loop note in `hoistReusedGlobalBases`). */
42
46
  inLoop: Set<string>;
43
- /** per key, how many times each CONSTANT offset was accessed. A constant offset touched 2+ times
44
- * is a SCALAR access at one fixed location (a `*(T*)C |= x` MMIO read-modify-write, or repeated
45
- * `*p`), which the compiler re-materializes rather than register-holds hoisting it MISMATCHES
46
- * (it broke the ProcessHBlankWait match). A key with ANY repeated constant offset is therefore
47
- * disqualified, even if it ALSO has distinct-offset uses (a mixed scalar+array base). A genuine
48
- * reused array base touches each constant offset once, or uses a variable index (not tallied). */
47
+ /** per key, how many times each CONSTANT offset was accessed the input to the
48
+ * `repeated-const-offset` gate, which losing the ProcessHBlankWait match is what bought. A
49
+ * genuine reused array base touches each constant offset once, or indexes by a variable (not
50
+ * tallied); a repeat means a scalar re-access, and ONE is enough to disqualify the base even
51
+ * when it also has distinct-offset uses. */
49
52
  constOffCount: Map<string, Map<number, number>>;
50
53
  }
51
54
 
@@ -136,27 +139,57 @@ function rewriteStmt(s: Stmt, localFor: Map<string, string>): Stmt {
136
139
  }
137
140
  }
138
141
 
142
+ /** One base under consideration, keyed as `(base, width, signedness)`. */
143
+ export interface BaseKey {
144
+ key: string;
145
+ uses: number;
146
+ inLoop: boolean;
147
+ /** some CONSTANT offset through this base is touched 2+ times */
148
+ repeatedConstOffset: boolean;
149
+ }
150
+
151
+ /** The admission rules. NONE is sound, and that is a property of the pass rather than an oversight:
152
+ * a wrong hoist emits the same address held in a different place, so it costs bytes and a match,
153
+ * never meaning. The zero-lost benchmark gate is what referees them.
154
+ *
155
+ * The `loop` rule is the subtle one. A loop-body base is loop-invariant, so the compiler keeps it
156
+ * in a register across the loop too — but hoisting to the FUNCTION TOP forces a callee-saved
157
+ * register, which can add the prologue push/pop the original avoided. `l3/scopebase.ts` is the
158
+ * scope-aware hoist that serves those instead. */
159
+ export const BASECSE_GATES: readonly Gate<BaseKey>[] = [
160
+ {
161
+ id: 'single-use',
162
+ why: 'one access re-materializes as cheaply as a named local',
163
+ sound: false,
164
+ rejects: (c) => c.uses < 2,
165
+ },
166
+ {
167
+ id: 'loop',
168
+ why: 'a function-top hoist of a loop base forces a callee-saved register the original avoided',
169
+ sound: false,
170
+ rejects: (c) => c.inLoop,
171
+ },
172
+ {
173
+ id: 'repeated-const-offset',
174
+ why: 'a fixed offset touched twice is a scalar RMW, which the compiler re-materializes',
175
+ sound: false,
176
+ rejects: (c) => c.repeatedConstOffset,
177
+ },
178
+ ];
179
+
139
180
  export function hoistReusedGlobalBases(sfn: SFn): SFn {
140
181
  const c: Collected = { count: new Map(), order: [], meta: new Map(), inLoop: new Set(), constOffCount: new Map() };
141
182
  collect(sfn.body, c, false);
142
- // A repeated CONSTANT offset means a scalar re-access at a fixed location (MMIO RMW / repeated
143
- // `*p`) the compiler re-materializes — disqualify the whole base, even mixed with array uses.
144
- const hasRepeatedConstOffset = (k: string): boolean => {
145
- for (const n of c.constOffCount.get(k)?.values() ?? []) {
146
- if (n >= 2) {
147
- return true;
148
- }
149
- }
150
- return false;
151
- };
152
-
153
- // Reuse 2+ and NOT used inside a loop. A loop-body base is loop-invariant, so the compiler ALSO
154
- // keeps it in a register across the loop — but hoisting it to the function top forces a
155
- // callee-saved register that can add prologue push/pop the original avoided, worsening the match
156
- // (register-pressure matching, not a correctness issue). Straight-line / branch reuse is the safe
157
- // win; a loop-body base is left inline for a future scope-aware hoist.
158
183
  const { count, order, meta } = c;
159
- const hoisted = order.filter((k) => (count.get(k) ?? 0) >= 2 && !c.inLoop.has(k) && !hasRepeatedConstOffset(k));
184
+ const hoisted = order.filter(
185
+ (k) =>
186
+ firstRejection(BASECSE_GATES, {
187
+ key: k,
188
+ uses: count.get(k) ?? 0,
189
+ inLoop: c.inLoop.has(k),
190
+ repeatedConstOffset: [...(c.constOffCount.get(k)?.values() ?? [])].some((n) => n >= 2),
191
+ }) === null,
192
+ );
160
193
  if (hoisted.length === 0) {
161
194
  return sfn;
162
195
  }
@@ -1,6 +1,7 @@
1
1
  import { typeToString } from '../ir/types';
2
2
  import type { Expr, SFn, Stmt } from './ast';
3
3
  import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
4
+ import { type Gate, firstRejection } from './gates';
4
5
 
5
6
  function namesIn(e: Expr, out: Set<string>): void {
6
7
  // `addr` names a GLOBAL, never a local — collected anyway. A name reaching BOTH forms would
@@ -17,7 +18,7 @@ function mentions(e: Expr, n: string): boolean {
17
18
  namesIn(e, seen);
18
19
  return seen.has(n);
19
20
  }
20
- interface Span {
21
+ export interface Span {
21
22
  first: number;
22
23
  last: number;
23
24
  inLoop: boolean;
@@ -78,69 +79,137 @@ function rename(body: Stmt[], from: string, to: string): Stmt[] {
78
79
  };
79
80
  return body.map(inStmt);
80
81
  }
82
+ /** One candidate merge under consideration: absorb `a` into `b`. */
83
+ export interface MergePair {
84
+ a: string;
85
+ b: string;
86
+ /** `a`'s span */
87
+ x: Span;
88
+ /** `b`'s span — the SURVIVOR's, which is why the asymmetric gates read `y` */
89
+ y: Span;
90
+ sameType: boolean;
91
+ eitherIsParam: boolean;
92
+ }
93
+
94
+ /** The admission rules, in evaluation order. Two arguments the `why` fields have no room for:
95
+ *
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.
101
+ *
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. */
106
+ export const COALESCE_GATES: readonly Gate<MergePair>[] = [
107
+ {
108
+ id: 'param',
109
+ why: 'a param is the function’s own signature, not a recovered local',
110
+ sound: false,
111
+ rejects: (c) => c.eitherIsParam,
112
+ },
113
+ {
114
+ id: 'type',
115
+ why: 'the survivor keeps its own declared type, so the two must agree',
116
+ sound: false,
117
+ rejects: (c) => !c.sameType,
118
+ },
119
+ {
120
+ id: 'loop',
121
+ why: 'a back edge can run a later statement first, so preorder stops implying disjoint liveness',
122
+ sound: true,
123
+ guardedBy: 'coalesce-fuzz.test.ts: dropping it clobbers a defined read',
124
+ rejects: (c) => c.x.inLoop || c.y.inLoop,
125
+ },
126
+ {
127
+ id: 'const-fed',
128
+ why: 'a load-fed local is one the compiler had a reason to keep where it was',
129
+ sound: false,
130
+ rejects: (c) => !c.x.constFed || !c.y.constFed,
131
+ },
132
+ {
133
+ id: 'overlap',
134
+ why: 'the ranges must not overlap — the survivor would absorb a value still live',
135
+ sound: true,
136
+ guardedBy: 'coalesce.test.ts: OVERLAPPING ranges never merge',
137
+ rejects: (c) => c.x.last >= c.y.first,
138
+ },
139
+ {
140
+ id: 'first-is-write',
141
+ why: 'a survivor first MENTIONED by a read would see the absorbed value there',
142
+ sound: false,
143
+ rejects: (c) => !c.y.firstIsWrite,
144
+ },
145
+ ];
146
+
81
147
  /** Every legal single merge, each as its own tree — NOT one committed choice.
82
148
  *
83
149
  * Which pair a register allocator coalesced is not derivable from the L3 tree, and first-fit gets
84
- * it wrong: on kleod:UpdateHUDCounterDisplay the two legal merges score 18 and 40 against a
85
- * no-merge baseline of 21, and declaration order picks the 40. `rank.ts` already has the idiom for
86
- * exactly this `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" so
87
- * every candidate is emitted and the differ referees.
88
- *
89
- * GATES:
90
- * - a local mentioned inside a loop BODY is excluded. SOUND-critical: it is what makes preorder
91
- * statement order a sufficient approximation of liveness. Preorder is a topological order of the
92
- * CFG except where a later-indexed statement can run before an earlier one, and the positions
93
- * that do that — a `for`'s `init`/`inc`, and everything in any loop body — are inside a loop, so
94
- * the gate covers them. A loop's own CONDITION is NOT covered: it is visited at the loop
95
- * statement's own index with the ENCLOSING loop flag. That is safe only because a condition
96
- * cannot WRITE, so it can extend a read range but never reorder a definition — an earlier
97
- * version of this comment claimed the gate covered conditions too, which it does not.
98
- * Differential fuzzing supports this: removing the gate produces clobbers immediately, leaving
99
- * it on produces none. No such harness is committed, so nothing here re-checks it.
100
- * - both must be CONSTANT-fed. A codegen heuristic, not soundness — removing it stayed
101
- * clobber-free under the same (uncommitted) fuzz and simply scored worse, because a load-fed
102
- * local is one the compiler had a reason to keep where it was. It is also what currently BOUNDS
103
- * candidate growth: merges are `L(L-1)/2` in the local count, each a distinct source and so a
104
- * distinct compile, and nothing else caps that. Corpus-wide today: 2 rows, 13 kept sources.
105
- * - the survivor's first mention must be an ASSIGN THAT DOES NOT ALSO READ IT. `b = g(b)` is a
106
- * write and a read in one statement; counting it as a pure write let `g` receive the absorbed
107
- * value. These two gates are NOT independent: `constFed` also rejects a self-reading assign
108
- * (its value is not a literal), so it masks this one. No committed test isolates it — this is
109
- * defence-in-depth for the day `constFed` is relaxed, which the note above makes plausible.
150
+ * it wrong. Run kleod:UpdateHUDCounterDisplay's published repro script (results.json carries it)
151
+ * and read the candidate table: of its two legal merges, one scores WORSE than not merging at all
152
+ * and declaration order is the one that picks it. Emitting no merges at all costs that row its
153
+ * match, which is what guards this file. `rank.ts` already has the idiom for exactly this —
154
+ * `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" — so every candidate is
155
+ * emitted and the differ referees.
110
156
  *
111
157
  * ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
112
158
  * the paths that skip it. The original read an uninitialized local there, so both spellings are
113
- * ill-defined rather than one being wrong — but this is a real difference and the differ, not this
114
- * gate, is what keeps it from faking a match. */
159
+ * ill-defined rather than one being wrong — but this is a real difference and the differ, not any
160
+ * gate, is what keeps it from faking a match. The fuzz asserts it stays reachable, so the carve-out
161
+ * that excuses it cannot quietly become dead. */
115
162
  export function coalesceCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
163
+ return coalesceUnder(COALESCE_GATES, sfn).candidates;
164
+ }
165
+
166
+ /** `coalesceCandidates` with the gate table supplied, plus which gate refused each pair.
167
+ *
168
+ * The parameter exists so a test can run the pass with one gate DROPPED — the ablation as a value,
169
+ * rather than as a flag compiled into the shipped path or an input rewritten to dodge a predicate.
170
+ * `refusals` is what makes a gate's reachability checkable: a rule nothing ever reaches is a rule
171
+ * no test can be failing on purpose. */
172
+ export function coalesceUnder(
173
+ gates: readonly Gate<MergePair>[],
174
+ sfn: SFn,
175
+ ): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
176
+ const refusals = new Map<string, number>();
116
177
  if (sfn.locals.length < 2) {
117
- return [];
178
+ return { candidates: [], refusals };
118
179
  }
119
180
  const params = new Set(sfn.params.map((p) => p.name));
120
181
  const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
121
182
  const sp = spans(sfn.body);
122
- const out: { merged: string; sfn: SFn }[] = [];
183
+ const candidates: { merged: string; sfn: SFn }[] = [];
123
184
  for (const a of sfn.locals.map((l) => l.name)) {
124
185
  for (const b of sfn.locals.map((l) => l.name)) {
125
186
  const x = sp.get(a);
126
187
  const y = sp.get(b);
127
- if (a === b || !x || !y || params.has(a) || params.has(b)) {
128
- continue;
129
- }
130
- if (typeOf.get(a) !== typeOf.get(b) || x.inLoop || y.inLoop || !x.constFed || !y.constFed) {
188
+ // Not a gate: this is what makes the pair a pair at all. A name with no span is one the body
189
+ // never mentions, so there is no range to reason about.
190
+ if (a === b || !x || !y) {
131
191
  continue;
132
192
  }
133
- if (x.last >= y.first || !y.firstIsWrite) {
193
+ const refused = firstRejection(gates, {
194
+ a,
195
+ b,
196
+ x,
197
+ y,
198
+ sameType: typeOf.get(a) === typeOf.get(b),
199
+ eitherIsParam: params.has(a) || params.has(b),
200
+ });
201
+ if (refused !== null) {
202
+ refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
134
203
  continue;
135
204
  }
136
205
  // Labelled by the PAIR, not by an index into enumeration order: an index silently re-points
137
206
  // at a different merge if `sfn.locals` ordering ever changes, leaving a recorded provenance
138
207
  // that is wrong but plausible.
139
- out.push({
208
+ candidates.push({
140
209
  merged: `${a}-${b}`,
141
210
  sfn: { ...sfn, body: rename(sfn.body, a, b), locals: sfn.locals.filter((l) => l.name !== a) },
142
211
  });
143
212
  }
144
213
  }
145
- return out;
214
+ return { candidates, refusals };
146
215
  }
package/src/l3/dce.ts CHANGED
@@ -15,8 +15,11 @@
15
15
  // read as live throughout), so a removal only happens when the local is provably dead. Only
16
16
  // names in `locals` are eligible — globals (side effects, referenced by name from headers) and
17
17
  // params are never touched — and a value carrying a side effect / gap signal / memory load is
18
- // never dropped (see `mustKeep`). Locals are never address-taken in L3 (`addr` names a global),
19
- // so `var` reads are a COMPLETE account of a local's uses.
18
+ // never dropped (see `mustKeep`). An `addr` node can now name a LOCAL as well as a global (the
19
+ // frame-local object a Thumb `laddr` declares structure.ts), and an address-taken local's stores
20
+ // are observable through the escaped pointer whether or not any `var` read follows — so an `addr`
21
+ // name counts as a READ below, which pins the local and every store to it. For globals that is a
22
+ // no-op (they were never eligible), so the single rule covers both.
20
23
  //
21
24
  // Ordering: structureChecked runs `assertResolved` BEFORE this pass, so in strict mode an
22
25
  // unresolved `?` value trips the contract first and never reaches DCE; `mustKeep` treating `?` as
@@ -25,9 +28,10 @@ import type { Expr, SFn, Stmt } from './ast';
25
28
  import { exprChildren, negateCond, stmtChildren, stmtExprs } from './ast';
26
29
 
27
30
  /** Accumulate every LOCAL-eligible `var` name read anywhere in `e` (recurses all sub-exprs). An
28
- * `addr` node names a global, not a local, so it is not a local read. */
31
+ * `addr` name counts too: taking a local's address makes every store to it observable through the
32
+ * escaped pointer, so an address-taken local is never dead here. */
29
33
  function readsInto(e: Expr, out: Set<string>): void {
30
- if (e.k === 'var') {
34
+ if (e.k === 'var' || e.k === 'addr') {
31
35
  out.add(e.name);
32
36
  }
33
37
  for (const c of exprChildren(e)) {
@@ -48,15 +52,17 @@ function reads(e: Expr): Set<string> {
48
52
  * value asmlift could NOT lift slip past `assertResolved`, silently downgrading a loud gap;
49
53
  * - a memory load (`index`/`field`) — asmlift models no `volatile`, so a possibly-effectful read
50
54
  * is never deleted (this pass never removes a memory access).
55
+ * - a read of a VOLATILE local (the frame object whose address escaped) — a volatile read is an
56
+ * observable access the machine performed; deleting the dead assignment would delete the read.
51
57
  * A dead assignment whose value contains any of these is kept. */
52
- function mustKeep(e: Expr): boolean {
58
+ function mustKeep(e: Expr, volatiles: ReadonlySet<string> = new Set()): boolean {
53
59
  if (e.k === 'call' || e.k === 'marker' || e.k === 'index' || e.k === 'field') {
54
60
  return true;
55
61
  }
56
- if (e.k === 'var' && e.name === '?') {
62
+ if (e.k === 'var' && (e.name === '?' || volatiles.has(e.name))) {
57
63
  return true;
58
64
  }
59
- return exprChildren(e).some(mustKeep);
65
+ return exprChildren(e).some((c) => mustKeep(c, volatiles));
60
66
  }
61
67
 
62
68
  /** Every local read anywhere within these statements (exprs + nested statements). Used to give
@@ -76,6 +82,7 @@ function dceBlock(
76
82
  stmts: Stmt[],
77
83
  liveOut: ReadonlySet<string>,
78
84
  locals: ReadonlySet<string>,
85
+ volatiles: ReadonlySet<string> = new Set(),
79
86
  ): { out: Stmt[]; liveIn: Set<string> } {
80
87
  const live = new Set(liveOut);
81
88
  const rev: Stmt[] = [];
@@ -89,7 +96,7 @@ function dceBlock(
89
96
  const s = stmts[i];
90
97
  switch (s.k) {
91
98
  case 'assign': {
92
- if (locals.has(s.name) && !live.has(s.name) && !mustKeep(s.value)) {
99
+ if (locals.has(s.name) && !live.has(s.name) && !mustKeep(s.value, volatiles)) {
93
100
  continue; // dead local store — drop it; liveness is unchanged (it was a no-op)
94
101
  }
95
102
  live.delete(s.name); // the write kills the name for statements before it …
@@ -131,8 +138,8 @@ function dceBlock(
131
138
  break;
132
139
  }
133
140
  case 'if': {
134
- const t = dceBlock(s.then, live, locals);
135
- const e = dceBlock(s.else, live, locals);
141
+ const t = dceBlock(s.then, live, locals, volatiles);
142
+ const e = dceBlock(s.else, live, locals, volatiles);
136
143
  const nlive = new Set<string>();
137
144
  for (const r of reads(s.cond)) {
138
145
  nlive.add(r);
@@ -146,7 +153,7 @@ function dceBlock(
146
153
  setLive(nlive);
147
154
  if (t.out.length === 0 && e.out.length === 0) {
148
155
  // both arms empty: keep only if the condition itself has a side effect
149
- if (mustKeep(s.cond)) {
156
+ if (mustKeep(s.cond, volatiles)) {
150
157
  rev.push({ k: 'exprstmt', value: s.cond });
151
158
  }
152
159
  } else if (t.out.length === 0) {
@@ -162,7 +169,7 @@ function dceBlock(
162
169
  // loop-carried store is never cut. Body DCE removes only what is dead on EVERY path.
163
170
  const loopLive = new Set(live);
164
171
  allReadsInto([s], loopLive);
165
- const b = dceBlock(s.body, loopLive, locals);
172
+ const b = dceBlock(s.body, loopLive, locals, volatiles);
166
173
  const nlive = new Set(loopLive);
167
174
  for (const r of b.liveIn) {
168
175
  nlive.add(r);
@@ -174,7 +181,7 @@ function dceBlock(
174
181
  case 'for': {
175
182
  const loopLive = new Set(live);
176
183
  allReadsInto([s], loopLive);
177
- const b = dceBlock(s.body, loopLive, locals);
184
+ const b = dceBlock(s.body, loopLive, locals, volatiles);
178
185
  const nlive = new Set(loopLive);
179
186
  for (const r of b.liveIn) {
180
187
  nlive.add(r);
@@ -188,8 +195,8 @@ function dceBlock(
188
195
  // read anywhere in the switch as live throughout — no case-body store is ever cut.
189
196
  const swLive = new Set(live);
190
197
  allReadsInto([s], swLive);
191
- const cases = s.cases.map((c) => ({ ...c, body: dceBlock(c.body, swLive, locals).out }));
192
- const def = s.default ? dceBlock(s.default, swLive, locals).out : s.default;
198
+ const cases = s.cases.map((c) => ({ ...c, body: dceBlock(c.body, swLive, locals, volatiles).out }));
199
+ const def = s.default ? dceBlock(s.default, swLive, locals, volatiles).out : s.default;
193
200
  const nlive = new Set(swLive);
194
201
  for (const r of reads(s.scrutinee)) {
195
202
  nlive.add(r);
@@ -227,9 +234,15 @@ function referencedNames(stmts: Stmt[], out: Set<string>): void {
227
234
  /** Remove dead local stores and simplify the branches they empty out, then drop any local
228
235
  * declaration left unreferenced. Returns a new SFn; the input is not mutated. */
229
236
  export function eliminateDeadStores(sfn: SFn): SFn {
230
- const locals = new Set(sfn.locals.map((l) => l.name));
231
- const body = dceBlock(sfn.body, new Set<string>(), locals).out;
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.
242
+ 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));
244
+ const body = dceBlock(sfn.body, new Set<string>(), locals, volatiles).out;
232
245
  const used = new Set<string>();
233
246
  referencedNames(body, used);
234
- return { ...sfn, body, locals: sfn.locals.filter((l) => used.has(l.name)) };
247
+ return { ...sfn, body, locals: sfn.locals.filter((l) => used.has(l.name) || l.volatile) };
235
248
  }