@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.
- package/package.json +1 -1
- package/src/backend/cfamily.ts +5 -2
- package/src/contracts.ts +166 -2
- package/src/frontend/mips.ts +13 -6
- package/src/frontend/opaque.ts +31 -18
- package/src/frontend/ppc.ts +18 -7
- package/src/frontend/ssa.ts +249 -5
- package/src/frontend/thumb.ts +1082 -72
- package/src/ir/alias.ts +75 -0
- package/src/ir/opcodes.ts +24 -14
- package/src/l3/argbase.ts +6 -1
- package/src/l3/ast.ts +9 -1
- package/src/l3/basecse.ts +57 -24
- package/src/l3/coalesce.ts +107 -38
- package/src/l3/dce.ts +31 -18
- package/src/l3/gates.ts +67 -0
- package/src/l3/scopebase.ts +11 -7
- package/src/l3/tailmerge.ts +8 -4
- package/src/pipeline.ts +60 -4
- package/src/raise/divpow2.ts +2 -1
- package/src/raise/gvn.ts +16 -6
- package/src/raise/pre-recovery.ts +4 -2
- package/src/raise/retsink.ts +5 -4
- package/src/raise/shortcircuit.ts +3 -5
- package/src/raise/struct-arrays.ts +2 -1
- package/src/raise/structs.ts +29 -1
- package/src/rank.ts +26 -2
- package/src/structure/analysis.ts +168 -123
- package/src/structure/structure.ts +228 -63
- package/src/structure/switch-recover.ts +96 -27
package/src/ir/alias.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
|
|
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.
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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(
|
|
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
|
}
|
package/src/l3/coalesce.ts
CHANGED
|
@@ -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
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`).
|
|
19
|
-
//
|
|
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`
|
|
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
|
-
|
|
231
|
-
|
|
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
|
}
|