@asmlift/core 0.3.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/README.md +5 -3
- package/package.json +1 -1
- package/src/backend/cfamily.ts +130 -4
- package/src/backend/cpp.ts +3 -1
- package/src/backend/pascal.ts +11 -0
- package/src/contracts.ts +181 -4
- package/src/declare.ts +35 -9
- package/src/frontend/mips.ts +37 -29
- package/src/frontend/opaque.ts +70 -20
- package/src/frontend/ppc.ts +18 -7
- package/src/frontend/ssa.ts +279 -56
- package/src/frontend/thumb.ts +1372 -87
- package/src/ir/alias.ts +75 -0
- package/src/ir/opcodes.ts +57 -3
- package/src/ir/simplify.ts +72 -0
- package/src/l3/argbase.ts +221 -0
- package/src/l3/ast.ts +127 -5
- package/src/l3/basecse.ts +58 -62
- package/src/l3/coalesce.ts +215 -0
- package/src/l3/dce.ts +33 -41
- package/src/l3/gates.ts +67 -0
- package/src/l3/hoist.ts +65 -0
- package/src/l3/reindex.ts +7 -0
- package/src/l3/scopebase.ts +440 -0
- package/src/l3/tailmerge.ts +124 -0
- package/src/macros.ts +222 -13
- package/src/pattern/engine.ts +99 -6
- package/src/pipeline.ts +65 -6
- package/src/raise/divpow2.ts +227 -0
- package/src/raise/gvn.ts +151 -0
- package/src/raise/pre-recovery.ts +39 -3
- package/src/raise/recover.ts +24 -7
- package/src/raise/retsink.ts +37 -7
- package/src/raise/shortcircuit.ts +262 -22
- package/src/raise/struct-arrays.ts +2 -1
- package/src/raise/structs.ts +41 -3
- package/src/rank.ts +196 -20
- package/src/structure/analysis.ts +175 -89
- package/src/structure/structure.ts +588 -55
- package/src/structure/switch-recover.ts +117 -30
- package/src/symbols.ts +128 -13
- package/src/target.ts +4 -2
- package/src/trace.ts +9 -0
package/src/l3/basecse.ts
CHANGED
|
@@ -21,11 +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';
|
|
25
|
+
import { nameAllocator } from './hoist';
|
|
24
26
|
|
|
25
27
|
// A HOISTABLE base is a bare `addr` (a global address) or a bare `const` (a numeric pointer
|
|
26
28
|
// address). Both are relocation-invariant leaves whose value the compiler keeps in one register
|
|
27
29
|
// when it indexes them at 2+ sites. Anything else (a local var, a struct-element `p[a0]`, arbitrary
|
|
28
|
-
// 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).
|
|
29
34
|
type HoistableBase = Extract<Expr, { k: 'addr' } | { k: 'const' }>;
|
|
30
35
|
const isHoistableBase = (e: Expr): e is HoistableBase => e.k === 'addr' || e.k === 'const';
|
|
31
36
|
const baseId = (b: HoistableBase): string => (b.k === 'addr' ? `a:${b.name}` : `c:${b.value}`);
|
|
@@ -39,12 +44,11 @@ interface Collected {
|
|
|
39
44
|
meta: Map<string, { base: HoistableBase; width: number; signed: boolean }>;
|
|
40
45
|
/** keys with ANY use inside a loop — disqualified (see the loop note in `hoistReusedGlobalBases`). */
|
|
41
46
|
inLoop: Set<string>;
|
|
42
|
-
/** per key, how many times each CONSTANT offset was accessed
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* 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. */
|
|
48
52
|
constOffCount: Map<string, Map<number, number>>;
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -135,45 +139,62 @@ function rewriteStmt(s: Stmt, localFor: Map<string, string>): Stmt {
|
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
taken.add(nm);
|
|
146
|
-
return nm;
|
|
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;
|
|
147
149
|
}
|
|
148
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
|
+
|
|
149
180
|
export function hoistReusedGlobalBases(sfn: SFn): SFn {
|
|
150
181
|
const c: Collected = { count: new Map(), order: [], meta: new Map(), inLoop: new Set(), constOffCount: new Map() };
|
|
151
182
|
collect(sfn.body, c, false);
|
|
152
|
-
// A repeated CONSTANT offset means a scalar re-access at a fixed location (MMIO RMW / repeated
|
|
153
|
-
// `*p`) the compiler re-materializes — disqualify the whole base, even mixed with array uses.
|
|
154
|
-
const hasRepeatedConstOffset = (k: string): boolean => {
|
|
155
|
-
for (const n of c.constOffCount.get(k)?.values() ?? []) {
|
|
156
|
-
if (n >= 2) {
|
|
157
|
-
return true;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return false;
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
// Reuse 2+ and NOT used inside a loop. A loop-body base is loop-invariant, so the compiler ALSO
|
|
164
|
-
// keeps it in a register across the loop — but hoisting it to the function top forces a
|
|
165
|
-
// callee-saved register that can add prologue push/pop the original avoided, worsening the match
|
|
166
|
-
// (register-pressure matching, not a correctness issue). Straight-line / branch reuse is the safe
|
|
167
|
-
// win; a loop-body base is left inline for a future scope-aware hoist.
|
|
168
183
|
const { count, order, meta } = c;
|
|
169
|
-
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
|
+
);
|
|
170
193
|
if (hoisted.length === 0) {
|
|
171
194
|
return sfn;
|
|
172
195
|
}
|
|
173
196
|
|
|
174
|
-
const
|
|
175
|
-
// globals are referenced by name; a hoist name must not shadow one that appears in the body.
|
|
176
|
-
collectNames(sfn.body, taken);
|
|
197
|
+
const fresh = nameAllocator(sfn);
|
|
177
198
|
|
|
178
199
|
const localFor = new Map<string, string>();
|
|
179
200
|
const newLocals: { name: string; type: IrType }[] = [];
|
|
@@ -181,7 +202,7 @@ export function hoistReusedGlobalBases(sfn: SFn): SFn {
|
|
|
181
202
|
for (const k of hoisted) {
|
|
182
203
|
const m = meta.get(k)!;
|
|
183
204
|
const ptrType = T.ptr(scalarTypeForAccess(m.width, m.signed));
|
|
184
|
-
const nm =
|
|
205
|
+
const nm = fresh();
|
|
185
206
|
localFor.set(k, nm);
|
|
186
207
|
newLocals.push({ name: nm, type: ptrType });
|
|
187
208
|
// `p = (T *)base` — the cast makes the local the access's pointer type so each `p[i]` strides it.
|
|
@@ -191,28 +212,3 @@ export function hoistReusedGlobalBases(sfn: SFn): SFn {
|
|
|
191
212
|
const body = [...hoistStmts, ...sfn.body.map((s) => rewriteStmt(s, localFor))];
|
|
192
213
|
return { ...sfn, body, locals: [...sfn.locals, ...newLocals] };
|
|
193
214
|
}
|
|
194
|
-
|
|
195
|
-
/** Every `var`/`addr`/called-function name mentioned anywhere in `stmts` (so a hoist name collides
|
|
196
|
-
* with none — a global via `addr`, a local via `var`, OR a callee via `call.fn`). */
|
|
197
|
-
function collectNames(stmts: Stmt[], out: Set<string>): void {
|
|
198
|
-
const walk = (e: Expr): void => {
|
|
199
|
-
if (e.k === 'var' || e.k === 'addr') {
|
|
200
|
-
out.add(e.name);
|
|
201
|
-
}
|
|
202
|
-
if (e.k === 'call') {
|
|
203
|
-
out.add(e.fn); // a hoist local must not shadow a called function symbol
|
|
204
|
-
}
|
|
205
|
-
for (const c of exprChildrenOf(e)) {
|
|
206
|
-
walk(c);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
for (const s of stmts) {
|
|
210
|
-
if (s.k === 'assign') {
|
|
211
|
-
out.add(s.name);
|
|
212
|
-
}
|
|
213
|
-
for (const e of stmtExprs(s)) {
|
|
214
|
-
walk(e);
|
|
215
|
-
}
|
|
216
|
-
collectNames(stmtChildren(s), out);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { typeToString } from '../ir/types';
|
|
2
|
+
import type { Expr, SFn, Stmt } from './ast';
|
|
3
|
+
import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
|
|
4
|
+
import { type Gate, firstRejection } from './gates';
|
|
5
|
+
|
|
6
|
+
function namesIn(e: Expr, out: Set<string>): void {
|
|
7
|
+
// `addr` names a GLOBAL, never a local — collected anyway. A name reaching BOTH forms would
|
|
8
|
+
// otherwise get a span that ignores its `addr` mentions, and a SHORT span is a clobber while a
|
|
9
|
+
// long one is only a missed merge. `structure.ts` keeps locals to /^[vt]\d+$/ and excludes global
|
|
10
|
+
// names, so this cannot fire today; collecting is the direction that stays safe if that changes.
|
|
11
|
+
if (e.k === 'var' || e.k === 'addr') out.add(e.name);
|
|
12
|
+
for (const c of exprChildren(e)) namesIn(c, out);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Does `e` mention `n` anywhere? */
|
|
16
|
+
function mentions(e: Expr, n: string): boolean {
|
|
17
|
+
const seen = new Set<string>();
|
|
18
|
+
namesIn(e, seen);
|
|
19
|
+
return seen.has(n);
|
|
20
|
+
}
|
|
21
|
+
export interface Span {
|
|
22
|
+
first: number;
|
|
23
|
+
last: number;
|
|
24
|
+
inLoop: boolean;
|
|
25
|
+
constFed: boolean;
|
|
26
|
+
/** the local's FIRST mention is a write, not a read */
|
|
27
|
+
firstIsWrite: boolean;
|
|
28
|
+
}
|
|
29
|
+
function spans(body: Stmt[]): Map<string, Span> {
|
|
30
|
+
const out = new Map<string, Span>();
|
|
31
|
+
let at = 0;
|
|
32
|
+
const walk = (list: Stmt[], inLoop: boolean): void => {
|
|
33
|
+
for (const s of list) {
|
|
34
|
+
at++;
|
|
35
|
+
const here = new Set<string>();
|
|
36
|
+
if (s.k === 'assign') here.add(s.name);
|
|
37
|
+
for (const e of stmtExprs(s)) namesIn(e, here);
|
|
38
|
+
for (const n of here) {
|
|
39
|
+
const sp = out.get(n) ?? {
|
|
40
|
+
first: at,
|
|
41
|
+
last: at,
|
|
42
|
+
inLoop,
|
|
43
|
+
constFed: true,
|
|
44
|
+
// an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
|
|
45
|
+
// let `g` receive the absorbed value
|
|
46
|
+
firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
|
|
47
|
+
};
|
|
48
|
+
sp.last = at;
|
|
49
|
+
sp.inLoop ||= inLoop;
|
|
50
|
+
if (s.k === 'assign' && s.name === n && s.value.k !== 'const') sp.constFed = false;
|
|
51
|
+
out.set(n, sp);
|
|
52
|
+
}
|
|
53
|
+
walk(stmtChildren(s), inLoop || s.k === 'while' || s.k === 'dowhile' || s.k === 'for');
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
walk(body, false);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function rename(body: Stmt[], from: string, to: string): Stmt[] {
|
|
60
|
+
const inExpr = (e: Expr): Expr =>
|
|
61
|
+
e.k === 'var' && e.name === from ? { ...e, name: to } : mapExprChildren(e, inExpr);
|
|
62
|
+
const inStmt = (s: Stmt): Stmt => {
|
|
63
|
+
const r = { ...s } as Record<string, unknown>;
|
|
64
|
+
if (s.k === 'assign' && s.name === from) r.name = to;
|
|
65
|
+
for (const key of ['value', 'lval', 'cond', 'scrutinee'] as const) {
|
|
66
|
+
const v = (s as Record<string, unknown>)[key];
|
|
67
|
+
if (v !== undefined) r[key] = inExpr(v as Expr);
|
|
68
|
+
}
|
|
69
|
+
for (const key of ['then', 'else', 'body', 'default'] as const) {
|
|
70
|
+
const v = (s as Record<string, unknown>)[key];
|
|
71
|
+
if (Array.isArray(v)) r[key] = (v as Stmt[]).map(inStmt);
|
|
72
|
+
}
|
|
73
|
+
if (s.k === 'for') {
|
|
74
|
+
r.init = inStmt(s.init);
|
|
75
|
+
r.inc = inStmt(s.inc);
|
|
76
|
+
}
|
|
77
|
+
if (s.k === 'switch') r.cases = s.cases.map((c) => ({ ...c, body: c.body.map(inStmt) }));
|
|
78
|
+
return r as Stmt;
|
|
79
|
+
};
|
|
80
|
+
return body.map(inStmt);
|
|
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
|
+
|
|
147
|
+
/** Every legal single merge, each as its own tree — NOT one committed choice.
|
|
148
|
+
*
|
|
149
|
+
* Which pair a register allocator coalesced is not derivable from the L3 tree, and first-fit gets
|
|
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.
|
|
156
|
+
*
|
|
157
|
+
* ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
|
|
158
|
+
* the paths that skip it. The original read an uninitialized local there, so both spellings are
|
|
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. */
|
|
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>();
|
|
177
|
+
if (sfn.locals.length < 2) {
|
|
178
|
+
return { candidates: [], refusals };
|
|
179
|
+
}
|
|
180
|
+
const params = new Set(sfn.params.map((p) => p.name));
|
|
181
|
+
const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
|
|
182
|
+
const sp = spans(sfn.body);
|
|
183
|
+
const candidates: { merged: string; sfn: SFn }[] = [];
|
|
184
|
+
for (const a of sfn.locals.map((l) => l.name)) {
|
|
185
|
+
for (const b of sfn.locals.map((l) => l.name)) {
|
|
186
|
+
const x = sp.get(a);
|
|
187
|
+
const y = sp.get(b);
|
|
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) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
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);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Labelled by the PAIR, not by an index into enumeration order: an index silently re-points
|
|
206
|
+
// at a different merge if `sfn.locals` ordering ever changes, leaving a recorded provenance
|
|
207
|
+
// that is wrong but plausible.
|
|
208
|
+
candidates.push({
|
|
209
|
+
merged: `${a}-${b}`,
|
|
210
|
+
sfn: { ...sfn, body: rename(sfn.body, a, b), locals: sfn.locals.filter((l) => l.name !== a) },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { candidates, refusals };
|
|
215
|
+
}
|
package/src/l3/dce.ts
CHANGED
|
@@ -15,19 +15,23 @@
|
|
|
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
|
|
23
26
|
// keep is defense-in-depth for any future caller that skips that check.
|
|
24
27
|
import type { Expr, SFn, Stmt } from './ast';
|
|
25
|
-
import { exprChildren, stmtChildren, stmtExprs } from './ast';
|
|
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
|
|
@@ -70,33 +76,13 @@ function allReadsInto(stmts: Stmt[], out: Set<string>): void {
|
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
/** Negate a condition, flipping a relational operator directly (`!= → ==`, `< → >=`, …) so an
|
|
74
|
-
* empty-then flip reads cleanly; anything else wraps in `!( … )`. Both forms are semantically
|
|
75
|
-
* exact over C's total integer order. */
|
|
76
|
-
function negate(cond: Expr): Expr {
|
|
77
|
-
if (cond.k === 'bin') {
|
|
78
|
-
const table: Record<string, '==' | '!=' | '<' | '<=' | '>' | '>='> = {
|
|
79
|
-
'==': '!=',
|
|
80
|
-
'!=': '==',
|
|
81
|
-
'<': '>=',
|
|
82
|
-
'>=': '<',
|
|
83
|
-
'>': '<=',
|
|
84
|
-
'<=': '>',
|
|
85
|
-
};
|
|
86
|
-
const f = table[cond.op];
|
|
87
|
-
if (f) {
|
|
88
|
-
return { k: 'bin', op: f, l: cond.l, r: cond.r };
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return { k: 'un', op: '!', e: cond };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
79
|
/** Backward live-variable walk over one block. `liveOut` is the set of locals live on exit;
|
|
95
80
|
* returns the rewritten block and the set live on entry. */
|
|
96
81
|
function dceBlock(
|
|
97
82
|
stmts: Stmt[],
|
|
98
83
|
liveOut: ReadonlySet<string>,
|
|
99
84
|
locals: ReadonlySet<string>,
|
|
85
|
+
volatiles: ReadonlySet<string> = new Set(),
|
|
100
86
|
): { out: Stmt[]; liveIn: Set<string> } {
|
|
101
87
|
const live = new Set(liveOut);
|
|
102
88
|
const rev: Stmt[] = [];
|
|
@@ -110,7 +96,7 @@ function dceBlock(
|
|
|
110
96
|
const s = stmts[i];
|
|
111
97
|
switch (s.k) {
|
|
112
98
|
case 'assign': {
|
|
113
|
-
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)) {
|
|
114
100
|
continue; // dead local store — drop it; liveness is unchanged (it was a no-op)
|
|
115
101
|
}
|
|
116
102
|
live.delete(s.name); // the write kills the name for statements before it …
|
|
@@ -152,8 +138,8 @@ function dceBlock(
|
|
|
152
138
|
break;
|
|
153
139
|
}
|
|
154
140
|
case 'if': {
|
|
155
|
-
const t = dceBlock(s.then, live, locals);
|
|
156
|
-
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);
|
|
157
143
|
const nlive = new Set<string>();
|
|
158
144
|
for (const r of reads(s.cond)) {
|
|
159
145
|
nlive.add(r);
|
|
@@ -167,11 +153,11 @@ function dceBlock(
|
|
|
167
153
|
setLive(nlive);
|
|
168
154
|
if (t.out.length === 0 && e.out.length === 0) {
|
|
169
155
|
// both arms empty: keep only if the condition itself has a side effect
|
|
170
|
-
if (mustKeep(s.cond)) {
|
|
156
|
+
if (mustKeep(s.cond, volatiles)) {
|
|
171
157
|
rev.push({ k: 'exprstmt', value: s.cond });
|
|
172
158
|
}
|
|
173
159
|
} else if (t.out.length === 0) {
|
|
174
|
-
rev.push({ k: 'if', cond:
|
|
160
|
+
rev.push({ k: 'if', cond: negateCond(s.cond), then: e.out, else: [] });
|
|
175
161
|
} else {
|
|
176
162
|
rev.push({ k: 'if', cond: s.cond, then: t.out, else: e.out });
|
|
177
163
|
}
|
|
@@ -183,7 +169,7 @@ function dceBlock(
|
|
|
183
169
|
// loop-carried store is never cut. Body DCE removes only what is dead on EVERY path.
|
|
184
170
|
const loopLive = new Set(live);
|
|
185
171
|
allReadsInto([s], loopLive);
|
|
186
|
-
const b = dceBlock(s.body, loopLive, locals);
|
|
172
|
+
const b = dceBlock(s.body, loopLive, locals, volatiles);
|
|
187
173
|
const nlive = new Set(loopLive);
|
|
188
174
|
for (const r of b.liveIn) {
|
|
189
175
|
nlive.add(r);
|
|
@@ -195,7 +181,7 @@ function dceBlock(
|
|
|
195
181
|
case 'for': {
|
|
196
182
|
const loopLive = new Set(live);
|
|
197
183
|
allReadsInto([s], loopLive);
|
|
198
|
-
const b = dceBlock(s.body, loopLive, locals);
|
|
184
|
+
const b = dceBlock(s.body, loopLive, locals, volatiles);
|
|
199
185
|
const nlive = new Set(loopLive);
|
|
200
186
|
for (const r of b.liveIn) {
|
|
201
187
|
nlive.add(r);
|
|
@@ -209,8 +195,8 @@ function dceBlock(
|
|
|
209
195
|
// read anywhere in the switch as live throughout — no case-body store is ever cut.
|
|
210
196
|
const swLive = new Set(live);
|
|
211
197
|
allReadsInto([s], swLive);
|
|
212
|
-
const cases = s.cases.map((c) => ({ ...c, body: dceBlock(c.body, swLive, locals).out }));
|
|
213
|
-
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;
|
|
214
200
|
const nlive = new Set(swLive);
|
|
215
201
|
for (const r of reads(s.scrutinee)) {
|
|
216
202
|
nlive.add(r);
|
|
@@ -248,9 +234,15 @@ function referencedNames(stmts: Stmt[], out: Set<string>): void {
|
|
|
248
234
|
/** Remove dead local stores and simplify the branches they empty out, then drop any local
|
|
249
235
|
* declaration left unreferenced. Returns a new SFn; the input is not mutated. */
|
|
250
236
|
export function eliminateDeadStores(sfn: SFn): SFn {
|
|
251
|
-
|
|
252
|
-
|
|
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;
|
|
253
245
|
const used = new Set<string>();
|
|
254
246
|
referencedNames(body, used);
|
|
255
|
-
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) };
|
|
256
248
|
}
|
package/src/l3/gates.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// A pass's admission rules as DATA, so "does every sound gate have a test that fails without it?"
|
|
2
|
+
// is a query instead of an audit.
|
|
3
|
+
//
|
|
4
|
+
// Because the table is a value, a test can drop one entry and re-run the pass: the real predicate,
|
|
5
|
+
// on real input, with no test-only branch in the shipped path. That makes `sound` cost something to
|
|
6
|
+
// declare — see `gateTableDefects` and the contract test that pairs with it.
|
|
7
|
+
//
|
|
8
|
+
// `why` is a LABEL, one line. The argument for why the rule is correct belongs in the file header,
|
|
9
|
+
// which has room; duplicating it here is how a table stops paying for itself.
|
|
10
|
+
export interface Gate<Ctx> {
|
|
11
|
+
/** stable, kebab-case; appears in test names and in the contract report */
|
|
12
|
+
readonly id: string;
|
|
13
|
+
/** one line: the reason the rule exists */
|
|
14
|
+
readonly why: string;
|
|
15
|
+
/** Remove it and some candidate is WRONG, not merely worse. Everything else is a codegen
|
|
16
|
+
* heuristic the differ still referees. This flag is what makes `guardedBy` mandatory. */
|
|
17
|
+
readonly sound: boolean;
|
|
18
|
+
/** the test that fails when this gate is removed — required for a sound gate */
|
|
19
|
+
readonly guardedBy?: string;
|
|
20
|
+
/** true ⇒ REJECT this candidate */
|
|
21
|
+
readonly rejects: (c: Ctx) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The id of the first gate that rejects `c`, or null when every gate admits it. FIRST, not all:
|
|
25
|
+
* one decisive rule is what makes a refusal attributable, and it keeps the cost the same as the
|
|
26
|
+
* `||` chain this replaces — evaluation still short-circuits. */
|
|
27
|
+
export function firstRejection<Ctx>(gates: readonly Gate<Ctx>[], c: Ctx): string | null {
|
|
28
|
+
for (const g of gates) {
|
|
29
|
+
if (g.rejects(c)) {
|
|
30
|
+
return g.id;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A gate table with one entry removed — the ablation, as a value. Throws on an unknown id: a
|
|
37
|
+
* typo'd ablation that silently tests nothing is the failure this file exists to prevent. */
|
|
38
|
+
export function without<Ctx>(gates: readonly Gate<Ctx>[], id: string): readonly Gate<Ctx>[] {
|
|
39
|
+
if (!gates.some((g) => g.id === id)) {
|
|
40
|
+
throw new Error(`no gate '${id}' to ablate (have: ${gates.map((g) => g.id).join(', ')})`);
|
|
41
|
+
}
|
|
42
|
+
return gates.filter((g) => g.id !== id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Structural defects in a gate table — the part checkable without running the pass. Returns
|
|
46
|
+
* findings rather than throwing, so core stays free of a test-framework import. */
|
|
47
|
+
export function gateTableDefects<Ctx>(gates: readonly Gate<Ctx>[]): string[] {
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
for (const g of gates) {
|
|
51
|
+
if (seen.has(g.id)) {
|
|
52
|
+
out.push(`duplicate gate id '${g.id}'`);
|
|
53
|
+
}
|
|
54
|
+
seen.add(g.id);
|
|
55
|
+
if (!/^[a-z][a-z0-9-]*$/.test(g.id)) {
|
|
56
|
+
out.push(`gate id '${g.id}' is not kebab-case`);
|
|
57
|
+
}
|
|
58
|
+
if (g.why.trim().length < 12) {
|
|
59
|
+
out.push(`gate '${g.id}' has no usable \`why\``);
|
|
60
|
+
}
|
|
61
|
+
// the one rule that costs something to declare
|
|
62
|
+
if (g.sound && !g.guardedBy?.trim()) {
|
|
63
|
+
out.push(`gate '${g.id}' is marked sound but names no guard`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|