@asmlift/core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -16
- package/package.json +1 -1
- package/src/backend/c.ts +1 -0
- package/src/backend/cfamily.ts +238 -164
- package/src/backend/cpp.ts +1 -0
- package/src/backend/pascal.ts +26 -12
- package/src/contracts.ts +341 -22
- package/src/declare.ts +41 -4
- package/src/frontend/mips.ts +24 -6
- package/src/frontend/opaque.ts +31 -18
- package/src/frontend/ppc.ts +54 -7
- package/src/frontend/ssa.ts +632 -13
- package/src/frontend/thumb.ts +2786 -286
- package/src/ir/alias.ts +129 -0
- package/src/ir/bits.ts +75 -0
- package/src/ir/core.ts +337 -2
- package/src/ir/opcodes.ts +156 -27
- package/src/ir/parse.ts +19 -2
- package/src/ir/print.ts +27 -2
- package/src/ir/simplify.ts +190 -3
- package/src/ir/struct-names.ts +42 -0
- package/src/ir/verify.ts +43 -49
- package/src/l3/address.ts +62 -0
- package/src/l3/argbase.ts +8 -2
- package/src/l3/ast.ts +464 -49
- package/src/l3/basecse.ts +709 -88
- package/src/l3/coalesce.ts +521 -66
- package/src/l3/dce.ts +54 -19
- package/src/l3/gates.ts +88 -0
- package/src/l3/hoist.ts +293 -14
- package/src/l3/homesplit.ts +285 -0
- package/src/l3/initfirst.ts +301 -0
- package/src/l3/inlinebase.ts +193 -0
- package/src/l3/mentions.ts +113 -0
- package/src/l3/mulfirst.ts +42 -0
- package/src/l3/nearbase.ts +152 -0
- package/src/l3/offmember.ts +371 -0
- package/src/l3/parkfirst.ts +96 -0
- package/src/l3/pollguard.ts +154 -0
- package/src/l3/ptrfield.ts +227 -0
- package/src/l3/regspell.ts +110 -85
- package/src/l3/reindex.ts +715 -78
- package/src/l3/scopebase.ts +649 -219
- package/src/l3/sinkinit.ts +40 -0
- package/src/l3/slotorder.ts +123 -0
- package/src/l3/storage.ts +48 -0
- package/src/l3/symbol-refs.ts +41 -8
- package/src/l3/tailmerge.ts +23 -4
- package/src/l3/typing.ts +198 -9
- package/src/l3/unmerge.ts +263 -0
- package/src/l3/unreduce.ts +971 -0
- package/src/l3/volatileptr.ts +207 -0
- package/src/l3/volatileval.ts +130 -0
- package/src/l3/volstore.ts +229 -0
- package/src/l3/zerosub.ts +62 -0
- package/src/pattern/engine.ts +236 -13
- package/src/pipeline.ts +206 -49
- package/src/proto.ts +112 -14
- package/src/raise/arrays.ts +6 -1
- package/src/raise/divpow2.ts +4 -3
- package/src/raise/globalshape.ts +1038 -0
- package/src/raise/gvn.ts +44 -19
- package/src/raise/latch.ts +126 -0
- package/src/raise/memberarrays.ts +594 -0
- package/src/raise/narrow.ts +124 -0
- package/src/raise/narrowlocal.ts +556 -0
- package/src/raise/paramwidth.ts +179 -0
- package/src/raise/pre-recovery.ts +101 -16
- package/src/raise/recover.ts +56 -23
- package/src/raise/retsink.ts +215 -14
- package/src/raise/shortcircuit.ts +477 -79
- package/src/raise/struct-arrays.ts +21 -3
- package/src/raise/structs.ts +61 -3
- package/src/rank-axes.ts +630 -0
- package/src/rank-declare.ts +256 -0
- package/src/rank.ts +1726 -251
- package/src/structure/analysis.ts +1516 -220
- package/src/structure/bitfields.ts +332 -0
- package/src/structure/globalaccess.ts +274 -0
- package/src/structure/hazards.ts +411 -20
- package/src/structure/loops.ts +2 -49
- package/src/structure/namecoalesce.ts +435 -0
- package/src/structure/structure.ts +2850 -533
- package/src/structure/switch-recover.ts +688 -147
- package/src/symbols.ts +62 -1
- package/src/target.ts +367 -24
- package/src/trace.ts +111 -32
package/src/l3/coalesce.ts
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
|
+
// THE OTHER COALESCER is `structure/namecoalesce.ts`, and the dividing line is worth stating: it
|
|
2
|
+
// merges the SOURCE AND DESTINATION OF A COPY, which is copy coalescing; this one merges two
|
|
3
|
+
// UNRELATED locals whose spans are disjoint, which is register reuse. Neither subsumes the other —
|
|
4
|
+
// a copy pair overlaps by construction, so `overlap` below rejects every candidate that one takes.
|
|
5
|
+
//
|
|
6
|
+
// TWO admission paths live here, each with its own gate table and its own reading of loops. The
|
|
7
|
+
// SPAN path (COALESCE_GATES) proves disjoint liveness from preorder position, so it asks which
|
|
8
|
+
// loops RE-RUN a mention of each local and refuses a pair only when one loop holds both. The
|
|
9
|
+
// ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one `if` picks
|
|
10
|
+
// between them, so it asks only whether ANY loop encloses that `if` — a second entry breaks the
|
|
11
|
+
// argument however the arms' own loops relate. `coalesceCandidates` offers both.
|
|
1
12
|
import { typeToString } from '../ir/types';
|
|
2
13
|
import type { Expr, SFn, Stmt } from './ast';
|
|
3
14
|
import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
|
|
15
|
+
import { type Gate, firstRejection } from './gates';
|
|
16
|
+
|
|
17
|
+
/** THE loop-kind test, shared by both admission paths in this file — the span model's enclosure
|
|
18
|
+
* walk and the arm path's `visit`. */
|
|
19
|
+
const isLoop = (s: Stmt): boolean => s.k === 'while' || s.k === 'dowhile' || s.k === 'for';
|
|
4
20
|
|
|
5
21
|
function namesIn(e: Expr, out: Set<string>): void {
|
|
6
|
-
// `addr` names a GLOBAL
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
22
|
+
// `addr` names a GLOBAL (`&gSym`) or a LOCAL — the structurer renders an `laddr` frame object
|
|
23
|
+
// as `&sp0`, an addr node over a name that IS in `sfn.locals`. Both are collected, because a
|
|
24
|
+
// name mentioned only through `&` still has a live range: a span that ignored its `addr`
|
|
25
|
+
// mentions would be SHORT, and a short span is a clobber where a long one is only a missed
|
|
26
|
+
// merge. Collecting a global name costs nothing — it is not in `sfn.locals`, so no pair is
|
|
27
|
+
// ever built for it.
|
|
28
|
+
//
|
|
29
|
+
// `rename` DISAGREES WITH THIS, and knowingly: it rewrites only `var` leaves, so a local
|
|
30
|
+
// absorbed while mentioned through `&` leaves that mention standing against a declaration the
|
|
31
|
+
// merge deleted. Reconciling the two changes which candidates compile — a measured change, not a
|
|
32
|
+
// cleanup — so today's behaviour is pinned exactly in coalesce.test.ts ('rename') rather than
|
|
33
|
+
// repaired here.
|
|
10
34
|
if (e.k === 'var' || e.k === 'addr') out.add(e.name);
|
|
11
35
|
for (const c of exprChildren(e)) namesIn(c, out);
|
|
12
36
|
}
|
|
@@ -17,42 +41,119 @@ function mentions(e: Expr, n: string): boolean {
|
|
|
17
41
|
namesIn(e, seen);
|
|
18
42
|
return seen.has(n);
|
|
19
43
|
}
|
|
20
|
-
interface Span {
|
|
44
|
+
export interface Span {
|
|
21
45
|
first: number;
|
|
22
46
|
last: number;
|
|
23
|
-
|
|
47
|
+
/** every loop that RE-RUNS a mention of the local — the whole ancestor chain, not just the
|
|
48
|
+
* innermost, because an outer loop re-runs an inner one's statements too. A `for`'s init is
|
|
49
|
+
* not re-run by its own loop, so it contributes only the enclosing ones. */
|
|
50
|
+
loops: Set<Stmt>;
|
|
24
51
|
constFed: boolean;
|
|
25
52
|
/** the local's FIRST mention is a write, not a read */
|
|
26
53
|
firstIsWrite: boolean;
|
|
27
54
|
}
|
|
55
|
+
/** The INDUCTION VARIABLE a `for` drives: the local its init writes and whose step computes the
|
|
56
|
+
* next value from the CURRENT one, arithmetically. Those two writes are the variable's own
|
|
57
|
+
* definition and its own history — not a feed from somewhere the compiler had a reason to
|
|
58
|
+
* respect, which is what `const-fed` reads every non-const assign as.
|
|
59
|
+
*
|
|
60
|
+
* Both halves of the step test are load-bearing, and neither is what the pipeline's `for`
|
|
61
|
+
* producers check — `recognizeForLoops` (structure/structure.ts) admits ANY self-referencing step,
|
|
62
|
+
* and l3/reindex.ts's two walk rewrites mint `iv = iv + 1`, so this predicate is implied by all
|
|
63
|
+
* three rather than trusting any of them; a `for` reaching here may be a walk, not a count. `a = *p` (no self-read) and `p = p->next` / `a = tab[a]` (a self-read
|
|
64
|
+
* that is still a MEMORY read every iteration) are both locals `const-fed` exists to refuse; only
|
|
65
|
+
* arithmetic over the variable is its own history. The read must be a `var`: `mentions` counts an
|
|
66
|
+
* `addr` too, and `&a` is not a read of `a`. */
|
|
67
|
+
const readsVarArithmetically = (e: Expr, n: string): boolean => {
|
|
68
|
+
if (e.k === 'index' || e.k === 'field' || e.k === 'call' || e.k === 'marker' || e.k === 'addr') {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return (e.k === 'var' && e.name === n) || exprChildren(e).some((c) => readsVarArithmetically(c, n));
|
|
72
|
+
};
|
|
73
|
+
const stepIsArithmetic = (e: Expr, n: string): boolean => readsVarArithmetically(e, n) && !hasMemoryRead(e);
|
|
74
|
+
const hasMemoryRead = (e: Expr): boolean =>
|
|
75
|
+
e.k === 'index' || e.k === 'field' || e.k === 'call' || e.k === 'marker' || exprChildren(e).some(hasMemoryRead);
|
|
76
|
+
const forInductionVar = (s: Extract<Stmt, { k: 'for' }>): string | null =>
|
|
77
|
+
s.init.k === 'assign' &&
|
|
78
|
+
s.inc.k === 'assign' &&
|
|
79
|
+
s.init.name === s.inc.name &&
|
|
80
|
+
stepIsArithmetic(s.inc.value, s.inc.name)
|
|
81
|
+
? s.init.name
|
|
82
|
+
: null;
|
|
83
|
+
|
|
84
|
+
/** Is this local's declaration carrying a `volatile` qualifier of either kind — the object itself,
|
|
85
|
+
* or its pointee? THE one spelling of the question both gate tables' `volatile` rule asks, kept
|
|
86
|
+
* shared while the two rules stay separate objects: `typeToString` spells neither qualifier, so a
|
|
87
|
+
* path that asked only about one would let a qualified local absorb into a plain one. */
|
|
88
|
+
const isVolatileLocal = (l: SFn['locals'][number]): boolean => l.volatile === true || l.pointeeVolatile === true;
|
|
89
|
+
|
|
28
90
|
function spans(body: Stmt[]): Map<string, Span> {
|
|
29
91
|
const out = new Map<string, Span>();
|
|
30
92
|
let at = 0;
|
|
31
|
-
|
|
93
|
+
/** the statement's OWN mentions — an assign target, a condition, a scrutinee — at its own
|
|
94
|
+
* position, inside the loops it runs under */
|
|
95
|
+
const record = (s: Stmt, loops: readonly Stmt[], inductionVar: string | null = null): void => {
|
|
96
|
+
at++;
|
|
97
|
+
const here = new Set<string>();
|
|
98
|
+
if (s.k === 'assign') here.add(s.name);
|
|
99
|
+
for (const e of stmtExprs(s)) namesIn(e, here);
|
|
100
|
+
for (const n of here) {
|
|
101
|
+
const sp = out.get(n) ?? {
|
|
102
|
+
first: at,
|
|
103
|
+
last: at,
|
|
104
|
+
loops: new Set<Stmt>(),
|
|
105
|
+
constFed: true,
|
|
106
|
+
// an assign that ALSO READS the name (`b = g(b)`) is not a pure write; treating it as one
|
|
107
|
+
// let `g` receive the absorbed value
|
|
108
|
+
firstIsWrite: s.k === 'assign' && s.name === n && !stmtExprs(s).some((e) => mentions(e, n)),
|
|
109
|
+
};
|
|
110
|
+
sp.last = at;
|
|
111
|
+
for (const l of loops) {
|
|
112
|
+
sp.loops.add(l);
|
|
113
|
+
}
|
|
114
|
+
if (s.k === 'assign' && s.name === n && s.value.k !== 'const' && n !== inductionVar) sp.constFed = false;
|
|
115
|
+
out.set(n, sp);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
/** the loop set a while/do-while's own condition and children run under — its own. A `for` is
|
|
119
|
+
* routed separately: `stmtChildren` hands back its init too, and the init is the one child its
|
|
120
|
+
* loop does not re-run. */
|
|
121
|
+
const under = (s: Stmt, loops: readonly Stmt[]): readonly Stmt[] => (isLoop(s) ? [...loops, s] : loops);
|
|
122
|
+
const walk = (list: Stmt[], loops: readonly Stmt[]): void => {
|
|
32
123
|
for (const s of list) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
124
|
+
// A `for`'s INIT runs ONCE, ahead of the condition and outside the loop; its cond, inc and
|
|
125
|
+
// body run per iteration. Walking the init first is what makes `for (i = *p; …)` read as a
|
|
126
|
+
// local whose first mention is a WRITE, which is what it is.
|
|
127
|
+
if (s.k === 'for') {
|
|
128
|
+
const inductionVar = forInductionVar(s);
|
|
129
|
+
// The INIT runs once, so the `for` does not re-run it — but only an ASSIGN there is a
|
|
130
|
+
// shape this case places directly. Anything else (a loop, an `if`) goes through the generic
|
|
131
|
+
// walk, which places it and its children by its own kind; `forInductionVar` requires an
|
|
132
|
+
// assign init, so `counter` is null on that path anyway. The INC mirrors it, where the
|
|
133
|
+
// routing is uniformity rather than coverage: an inc's statements sit inside the `for`,
|
|
134
|
+
// which is already in every span the pair could share.
|
|
135
|
+
if (s.init.k === 'assign') {
|
|
136
|
+
record(s.init, loops, inductionVar);
|
|
137
|
+
} else {
|
|
138
|
+
walk([s.init], loops);
|
|
139
|
+
}
|
|
140
|
+
const inner = [...loops, s];
|
|
141
|
+
record(s, inner);
|
|
142
|
+
walk(s.body, inner);
|
|
143
|
+
if (s.inc.k === 'assign') {
|
|
144
|
+
record(s.inc, inner, inductionVar);
|
|
145
|
+
} else {
|
|
146
|
+
walk([s.inc], inner);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
51
149
|
}
|
|
52
|
-
|
|
150
|
+
// a while/do-while CONDITION re-runs with the body, so it counts as inside its own loop
|
|
151
|
+
const inner = under(s, loops);
|
|
152
|
+
record(s, inner);
|
|
153
|
+
walk(stmtChildren(s), inner);
|
|
53
154
|
}
|
|
54
155
|
};
|
|
55
|
-
walk(body,
|
|
156
|
+
walk(body, []);
|
|
56
157
|
return out;
|
|
57
158
|
}
|
|
58
159
|
function rename(body: Stmt[], from: string, to: string): Stmt[] {
|
|
@@ -78,69 +179,423 @@ function rename(body: Stmt[], from: string, to: string): Stmt[] {
|
|
|
78
179
|
};
|
|
79
180
|
return body.map(inStmt);
|
|
80
181
|
}
|
|
182
|
+
/** One candidate merge under consideration: absorb `a` into `b`. */
|
|
183
|
+
export interface MergePair {
|
|
184
|
+
a: string;
|
|
185
|
+
b: string;
|
|
186
|
+
/** `a`'s span */
|
|
187
|
+
x: Span;
|
|
188
|
+
/** `b`'s span — the SURVIVOR's, which is why the asymmetric gates read `y` */
|
|
189
|
+
y: Span;
|
|
190
|
+
sameType: boolean;
|
|
191
|
+
eitherIsParam: boolean;
|
|
192
|
+
/** either local is object-volatile or carries a pointee-volatile qualifier — typeToString
|
|
193
|
+
* spells neither, so `sameType` alone lets a qualified local absorb into a plain one */
|
|
194
|
+
eitherIsVolatile: boolean;
|
|
195
|
+
/** some loop holds a mention of BOTH locals */
|
|
196
|
+
sharesLoop: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The admission rules, in evaluation order. Two arguments the `why` fields have no room for:
|
|
200
|
+
*
|
|
201
|
+
* WHAT `shared-loop` BUYS is the right to read preorder statement order as liveness. Preorder is a
|
|
202
|
+
* topological order of the CFG except where a back edge runs a later-indexed statement before an
|
|
203
|
+
* earlier one — and a back edge returns only to the head of its OWN loop, whose body is one
|
|
204
|
+
* contiguous preorder range. So the reordering can reach a PAIR only when some loop holds a
|
|
205
|
+
* mention of both locals: there the survivor's write can be followed, on the next iteration, by
|
|
206
|
+
* the absorbed local's read. Where no loop holds both — two sibling loops, or one local living
|
|
207
|
+
* before the loop the other lives in — no back edge connects the two ranges and preorder IS
|
|
208
|
+
* execution order. The ancestor chain is what the span records, not the innermost loop: an outer
|
|
209
|
+
* loop re-runs an inner one's statements, so it can reorder a pair that no inner loop shares.
|
|
210
|
+
*
|
|
211
|
+
* `first-is-write` AND `const-fed` ARE INDEPENDENT, and neither masks the other: a survivor whose
|
|
212
|
+
* every feed is a constant can still be first MENTIONED by a read, and ablating `first-is-write`
|
|
213
|
+
* alone then offers the merge (coalesce.test.ts pins exactly that program). What `const-fed`
|
|
214
|
+
* bounds is candidate GROWTH: merges go as `L(L-1)/2` in the local count, each a distinct compile.
|
|
215
|
+
* On a loop-heavy function it is what bounds them, because `shared-loop` refuses only pairs a back
|
|
216
|
+
* edge can reorder: klonoa's LoadBGTilemapData declares 43 locals — 1806 ordered pairs — and
|
|
217
|
+
* `const-fed` is what keeps three of them (ablate it and the span path offers 273). A rule
|
|
218
|
+
* refusing every in-loop local would make that bound redundant; this one does not, so any further
|
|
219
|
+
* relaxation of `const-fed` is a multiplier, and two call sites pay it (`/coalesce` and
|
|
220
|
+
* `/scopebase-coalesce`; the `/livebase` pairings enumerate the ARM path and pay
|
|
221
|
+
* ARM_DISJOINT_GATES' `arm-init` instead). */
|
|
222
|
+
export const COALESCE_GATES: readonly Gate<MergePair>[] = [
|
|
223
|
+
{
|
|
224
|
+
id: 'param',
|
|
225
|
+
why: 'a param is the function’s own signature, not a recovered local',
|
|
226
|
+
sound: false,
|
|
227
|
+
rejects: (c) => c.eitherIsParam,
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'type',
|
|
231
|
+
why: 'the survivor keeps its own declared type, so the two must agree',
|
|
232
|
+
sound: false,
|
|
233
|
+
rejects: (c) => !c.sameType,
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: 'volatile',
|
|
237
|
+
why: 'a volatile qualifier (object or pointee) is observable and typeToString does not spell it — merging strips or adds it',
|
|
238
|
+
sound: true,
|
|
239
|
+
guardedBy: 'coalesce.test.ts: a volatile pair never merges',
|
|
240
|
+
rejects: (c) => c.eitherIsVolatile,
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: 'shared-loop',
|
|
244
|
+
why: 'a back edge of a loop holding both locals re-runs the absorbed read after the survivor is written',
|
|
245
|
+
sound: true,
|
|
246
|
+
// The ablation sweep proves this gate is load-bearing WHILE it is in the table; it is blind to
|
|
247
|
+
// the two ways the rule can go wrong from here — a relaxation, and an outright deletion, which
|
|
248
|
+
// would simply drop the gate from the table the sweep iterates. Both land on the arm named
|
|
249
|
+
// below, which checks what the pass still emits.
|
|
250
|
+
guardedBy: 'coalesce-fuzz.test.ts: no candidate the pass emits changes a DEFINED read',
|
|
251
|
+
rejects: (c) => c.sharesLoop,
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'const-fed',
|
|
255
|
+
why: 'a load-fed local — other than a for induction variable, whose feeds are its own — is one the compiler had a reason to keep where it was',
|
|
256
|
+
sound: false,
|
|
257
|
+
rejects: (c) => !c.x.constFed || !c.y.constFed,
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'overlap',
|
|
261
|
+
why: 'the ranges must not overlap — the survivor would absorb a value still live',
|
|
262
|
+
sound: true,
|
|
263
|
+
guardedBy: 'coalesce.test.ts: OVERLAPPING ranges never merge',
|
|
264
|
+
rejects: (c) => c.x.last >= c.y.first,
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: 'first-is-write',
|
|
268
|
+
why: 'a survivor first MENTIONED by a read would see the absorbed value there',
|
|
269
|
+
sound: false,
|
|
270
|
+
rejects: (c) => !c.y.firstIsWrite,
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
|
|
81
274
|
/** Every legal single merge, each as its own tree — NOT one committed choice.
|
|
82
275
|
*
|
|
83
276
|
* 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.
|
|
277
|
+
* it wrong. Run kleod:UpdateHUDCounterDisplay's published repro script (results.json carries it)
|
|
278
|
+
* and read the candidate table: of its two legal merges, one scores WORSE than not merging at all
|
|
279
|
+
* and declaration order is the one that picks it. Emitting no merges at all costs that row its
|
|
280
|
+
* match, which is what guards this file. `rank.ts` already has the idiom for exactly this —
|
|
281
|
+
* `/regcopy`'s "the tail choice is allocator-ambiguous, so both are ranked" — so every candidate is
|
|
282
|
+
* emitted and the differ referees.
|
|
110
283
|
*
|
|
111
284
|
* ACCEPTED, NOT FIXED: a survivor assigned only on SOME paths still absorbs the other's value on
|
|
112
|
-
* 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.
|
|
285
|
+
* the paths that skip it — a loop that runs zero iterations is one such path. The original read an uninitialized local there, so both spellings are
|
|
286
|
+
* ill-defined rather than one being wrong — but this is a real difference and the differ, not any
|
|
287
|
+
* gate, is what keeps it from faking a match. The fuzz asserts it stays reachable, so the carve-out
|
|
288
|
+
* that excuses it cannot quietly become dead. */
|
|
115
289
|
export function coalesceCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
|
|
290
|
+
const { candidates } = coalesceUnder(COALESCE_GATES, sfn);
|
|
291
|
+
const seen = new Set(candidates.map((c) => c.merged));
|
|
292
|
+
for (const c of armDisjointCandidates(sfn)) {
|
|
293
|
+
if (!seen.has(c.merged)) {
|
|
294
|
+
candidates.push(c);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return candidates;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The survivor's declaration list after `gone` is absorbed into `kept`.
|
|
301
|
+
*
|
|
302
|
+
* The merged local's declaration is dropped, so any attribute on it would be lost — and one of
|
|
303
|
+
* them is load-bearing: `slots`, the `[sp,#k]`s the machine homed it at (ir/core.ts `SlotHomes`).
|
|
304
|
+
* The survivor takes the UNION and chooses nothing: a merged pair can reproduce at most one slot,
|
|
305
|
+
* but WHICH of the two is the earlier declaration rank depends on the frame's direction, and this
|
|
306
|
+
* function is handed a locals list with no target in it. `l3/slotorder.ts` reduces, once, where
|
|
307
|
+
* the direction is in hand. Neither homed ⇒ no slots: the merge invents nothing. */
|
|
308
|
+
function localsAfterMerge(locals: SFn['locals'], gone: string, kept: string): SFn['locals'] {
|
|
309
|
+
const goneSlots = locals.find((l) => l.name === gone)?.slots;
|
|
310
|
+
return locals
|
|
311
|
+
.filter((l) => l.name !== gone)
|
|
312
|
+
.map((l) => {
|
|
313
|
+
if (l.name !== kept || goneSlots === undefined) {
|
|
314
|
+
return l;
|
|
315
|
+
}
|
|
316
|
+
return { ...l, slots: [...new Set([...(l.slots ?? []), ...goneSlots])].sort((x, y) => x - y) };
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** One candidate ARM-DISJOINT merge: every mention of `a` inside one arm of a single `if`, every
|
|
321
|
+
* mention of `b` inside the other. There is no param gate to mirror from the span table because
|
|
322
|
+
* the enumeration refuses params structurally: pair members come from `sfn.locals` only, and a
|
|
323
|
+
* local that SHADOWS a param name is excluded too (see `confined`) — rename() must never touch
|
|
324
|
+
* a param's mentions. */
|
|
325
|
+
export interface ArmPair {
|
|
326
|
+
a: string;
|
|
327
|
+
b: string;
|
|
328
|
+
/** the confining `if` has a loop ancestor, so it can run more than once */
|
|
329
|
+
ifInLoop: boolean;
|
|
330
|
+
sameType: boolean;
|
|
331
|
+
/** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
|
|
332
|
+
eitherIsVolatile: boolean;
|
|
333
|
+
/** each local's FIRST preorder mention inside its arm is a pure const write */
|
|
334
|
+
bothArmConstInit: boolean;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The arm-disjoint admission — the SECOND way a pair can merge, and what it uniquely buys is the
|
|
338
|
+
* DIRECTION: the span path's survivor is always the later RANGE, because `overlap` orders the pair
|
|
339
|
+
* by position, while this path's is the earlier DECLARATION — the two disagree exactly when
|
|
340
|
+
* declaration order disagrees with range order. It also admits a pair the span gates refuse for a
|
|
341
|
+
* different reason: `arm-init` is a FIRST-MENTION rule where `const-fed` is an every-assign one,
|
|
342
|
+
* so arms that open with a const write and then compute (`x = 0; x = x + 1;`) merge here and not
|
|
343
|
+
* there. Two locals confined to
|
|
344
|
+
* OPPOSITE arms of one `if` never coexist at runtime: the `if` picks one arm, so no read of either
|
|
345
|
+
* can observe the other's write — no liveness reasoning needed. That argument is exactly what the
|
|
346
|
+
* `loop` gate here protects: a loop ancestor re-enters the `if`, later entries can take the other
|
|
347
|
+
* arm, and a value written on one visit becomes readable on the next. Note this gate wants ANY
|
|
348
|
+
* enclosing loop, not the span model's shared-loop rule: never-coexisting is a claim about one
|
|
349
|
+
* entry, so a second entry breaks it however the two arms' loops relate. */
|
|
350
|
+
export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
|
|
351
|
+
{
|
|
352
|
+
id: 'type',
|
|
353
|
+
why: 'the survivor keeps its own declared type, so the two must agree',
|
|
354
|
+
sound: false,
|
|
355
|
+
rejects: (c) => !c.sameType,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: 'volatile',
|
|
359
|
+
why: 'a volatile qualifier (object or pointee) is observable — merging strips or adds it',
|
|
360
|
+
sound: true,
|
|
361
|
+
guardedBy: 'coalesce.test.ts: a volatile pair never merges',
|
|
362
|
+
rejects: (c) => c.eitherIsVolatile,
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: 'loop',
|
|
366
|
+
why: 'a loop ancestor re-enters the if, so opposite arms both run and a value could cross',
|
|
367
|
+
sound: true,
|
|
368
|
+
guardedBy: 'coalesce.test.ts: an in-loop if never admits its arm pair',
|
|
369
|
+
rejects: (c) => c.ifInLoop,
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: 'arm-init',
|
|
373
|
+
why: 'a local not const-initialized at its arm’s first mention is one the compiler had a reason to keep — the growth bound const-fed gives the span table',
|
|
374
|
+
sound: false,
|
|
375
|
+
rejects: (c) => !c.bothArmConstInit,
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
/** The arm-disjoint merges alone — the class the livebase pairings enumerate (rank.ts): the
|
|
380
|
+
* demanding row's shared counter is arm-disjoint, and the span-model merges already ride the
|
|
381
|
+
* plain /coalesce label, so pairing them too would multiply candidates with no row behind it. */
|
|
382
|
+
export function armDisjointCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
|
|
383
|
+
return armDisjointUnder(ARM_DISJOINT_GATES, sfn).candidates;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Every name each statement of `list` MENTIONS, and how many statements mention it — an assign
|
|
387
|
+
* target counts, and so does every name in the statement's own expressions. Nested statements are
|
|
388
|
+
* counted too, so this is the whole subtree's census. */
|
|
389
|
+
function countMentions(list: Stmt[]): Map<string, number> {
|
|
390
|
+
const out = new Map<string, number>();
|
|
391
|
+
const walk = (stmts: Stmt[]): void => {
|
|
392
|
+
for (const st of stmts) {
|
|
393
|
+
const here = new Set<string>();
|
|
394
|
+
if (st.k === 'assign') here.add(st.name);
|
|
395
|
+
for (const e of stmtExprs(st)) namesIn(e, here);
|
|
396
|
+
for (const n of here) out.set(n, (out.get(n) ?? 0) + 1);
|
|
397
|
+
walk(stmtChildren(st));
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
walk(list);
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The three mention queries the arm-disjoint path asks, sharing ONE set of memos.
|
|
405
|
+
*
|
|
406
|
+
* MEMOISED ON NODE IDENTITY, so the index belongs to one tree and one call: build it per
|
|
407
|
+
* `armDisjointUnder` invocation and never hold it across a rewrite. Sound because nothing on this
|
|
408
|
+
* path mutates the tree — the only rewrite is `rename`, which rebuilds every statement it touches
|
|
409
|
+
* and leaves the input alone (structure-purity.test.ts pins the same promise one level up). The
|
|
410
|
+
* memos earn their keep because `firstMention` walks a statement's whole subtree once per
|
|
411
|
+
* statement it scans, and the a×b loop's two calls each depend on only ONE of a and b.
|
|
412
|
+
*
|
|
413
|
+
* `firstMentionIn` and `firstMention` are mutually recursive and stay inside for that reason —
|
|
414
|
+
* the recursion runs back through the memo, not around it. */
|
|
415
|
+
function mentionIndex(): {
|
|
416
|
+
mentionsOf: (list: Stmt[]) => Map<string, number>;
|
|
417
|
+
mentionsUnder: (st: Stmt) => Map<string, number>;
|
|
418
|
+
firstMention: (list: Stmt[], n: string) => 'const-write' | 'other' | null;
|
|
419
|
+
} {
|
|
420
|
+
const listMentions = new Map<Stmt[], Map<string, number>>();
|
|
421
|
+
const mentionsOf = (list: Stmt[]): Map<string, number> => {
|
|
422
|
+
let m = listMentions.get(list);
|
|
423
|
+
if (m === undefined) {
|
|
424
|
+
m = countMentions(list);
|
|
425
|
+
listMentions.set(list, m);
|
|
426
|
+
}
|
|
427
|
+
return m;
|
|
428
|
+
};
|
|
429
|
+
// `stmtChildren` builds a FRESH array every call, so a statement's subtree counts are keyed on
|
|
430
|
+
// the statement rather than on the list `mentionsOf` would see.
|
|
431
|
+
const childMentions = new Map<Stmt, Map<string, number>>();
|
|
432
|
+
const mentionsUnder = (st: Stmt): Map<string, number> => {
|
|
433
|
+
let m = childMentions.get(st);
|
|
434
|
+
if (m === undefined) {
|
|
435
|
+
m = countMentions(stmtChildren(st));
|
|
436
|
+
childMentions.set(st, m);
|
|
437
|
+
}
|
|
438
|
+
return m;
|
|
439
|
+
};
|
|
440
|
+
// The first PREORDER mention of `n` in an arm, looked for through if statements whose own
|
|
441
|
+
// condition does not read it (an if's cond evaluates before either arm). 'const-write' is a
|
|
442
|
+
// pure `n = K`; anything else mentioning n first — a read, a computed assign, a loop — refuses.
|
|
443
|
+
const firstMentionIn = (list: Stmt[], n: string): 'const-write' | 'other' | null => {
|
|
444
|
+
for (const st of list) {
|
|
445
|
+
const here = new Set<string>();
|
|
446
|
+
if (st.k === 'assign') here.add(st.name);
|
|
447
|
+
for (const e of stmtExprs(st)) namesIn(e, here);
|
|
448
|
+
const inChildren = mentionsUnder(st).has(n);
|
|
449
|
+
if (!here.has(n) && !inChildren) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (st.k === 'assign' && st.name === n && st.value.k === 'const' && !mentions(st.value, n)) {
|
|
453
|
+
return 'const-write';
|
|
454
|
+
}
|
|
455
|
+
if (st.k === 'if' && !here.has(n)) {
|
|
456
|
+
const arm = mentionsOf(st.then).has(n) ? st.then : st.else;
|
|
457
|
+
return firstMention(arm, n);
|
|
458
|
+
}
|
|
459
|
+
return 'other';
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
};
|
|
463
|
+
// Per (arm, NAME): the answer depends on both, and the a×b loop asks for each `a` once per `b`
|
|
464
|
+
// and each `b` once per `a`.
|
|
465
|
+
const firstMentions = new Map<Stmt[], Map<string, 'const-write' | 'other' | null>>();
|
|
466
|
+
const firstMention = (list: Stmt[], n: string): 'const-write' | 'other' | null => {
|
|
467
|
+
let per = firstMentions.get(list);
|
|
468
|
+
if (per === undefined) {
|
|
469
|
+
per = new Map();
|
|
470
|
+
firstMentions.set(list, per);
|
|
471
|
+
}
|
|
472
|
+
if (!per.has(n)) {
|
|
473
|
+
per.set(n, firstMentionIn(list, n));
|
|
474
|
+
}
|
|
475
|
+
return per.get(n)!;
|
|
476
|
+
};
|
|
477
|
+
return { mentionsOf, mentionsUnder, firstMention };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** `armDisjointCandidates` with the gate table supplied plus which gate refused each pair — the
|
|
481
|
+
* same ablation-as-a-value seam `coalesceUnder` provides for the span table. */
|
|
482
|
+
export function armDisjointUnder(
|
|
483
|
+
gates: readonly Gate<ArmPair>[],
|
|
484
|
+
sfn: SFn,
|
|
485
|
+
): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
|
|
486
|
+
const refusals = new Map<string, number>();
|
|
116
487
|
if (sfn.locals.length < 2) {
|
|
117
|
-
return [];
|
|
488
|
+
return { candidates: [], refusals };
|
|
118
489
|
}
|
|
490
|
+
const { mentionsOf, firstMention } = mentionIndex();
|
|
491
|
+
const total = mentionsOf(sfn.body);
|
|
119
492
|
const params = new Set(sfn.params.map((p) => p.name));
|
|
493
|
+
const locals = new Map(sfn.locals.map((l) => [l.name, l]));
|
|
120
494
|
const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
|
|
121
|
-
const sp = spans(sfn.body);
|
|
122
495
|
const out: { merged: string; sfn: SFn }[] = [];
|
|
496
|
+
const declIdx = new Map(sfn.locals.map((l, i) => [l.name, i]));
|
|
497
|
+
const isVolatile = (n: string): boolean => {
|
|
498
|
+
const l = locals.get(n);
|
|
499
|
+
return l !== undefined && isVolatileLocal(l);
|
|
500
|
+
};
|
|
501
|
+
const visit = (stmts: Stmt[], inLoop: boolean): void => {
|
|
502
|
+
for (const st of stmts) {
|
|
503
|
+
if (st.k === 'if' && st.then.length && st.else.length) {
|
|
504
|
+
const thenM = mentionsOf(st.then);
|
|
505
|
+
const elseM = mentionsOf(st.else);
|
|
506
|
+
// locals only, and never a name that is ALSO a param — the span path holds the same
|
|
507
|
+
// belief as a gate, and a local shadowing a param would let rename() rewrite the param's
|
|
508
|
+
// own mentions
|
|
509
|
+
const confined = (m: Map<string, number>): string[] =>
|
|
510
|
+
[...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
|
|
511
|
+
for (const a of confined(thenM)) {
|
|
512
|
+
for (const b of confined(elseM)) {
|
|
513
|
+
// the survivor is the earlier declaration, matching how a shared source local reads.
|
|
514
|
+
//
|
|
515
|
+
// THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into the
|
|
516
|
+
// target's frame order at EMIT time (l3/slotorder.ts), after this pass, so `declIdx`
|
|
517
|
+
// is the naming walk's order and the choice means "the earlier declaration in the
|
|
518
|
+
// source asmlift recovered". Ordering the list any earlier would silently change which
|
|
519
|
+
// local survives every arm-disjoint merge on a function whose frame order disagrees
|
|
520
|
+
// with its declaration order — exactly the population the ordering exists for.
|
|
521
|
+
const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
|
|
522
|
+
const refused = firstRejection(gates, {
|
|
523
|
+
a: gone,
|
|
524
|
+
b: kept,
|
|
525
|
+
ifInLoop: inLoop,
|
|
526
|
+
sameType: typeOf.get(a) === typeOf.get(b),
|
|
527
|
+
eitherIsVolatile: isVolatile(a) || isVolatile(b),
|
|
528
|
+
bothArmConstInit:
|
|
529
|
+
firstMention(st.then, a) === 'const-write' && firstMention(st.else, b) === 'const-write',
|
|
530
|
+
});
|
|
531
|
+
if (refused !== null) {
|
|
532
|
+
refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
out.push({
|
|
536
|
+
merged: `${gone}-${kept}`,
|
|
537
|
+
sfn: { ...sfn, body: rename(sfn.body, gone, kept), locals: localsAfterMerge(sfn.locals, gone, kept) },
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
visit(stmtChildren(st), inLoop || isLoop(st));
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
visit(sfn.body, false);
|
|
546
|
+
return { candidates: out, refusals };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** `coalesceCandidates` with the gate table supplied, plus which gate refused each pair.
|
|
550
|
+
*
|
|
551
|
+
* The parameter exists so a test can run the pass with one gate DROPPED — the ablation as a value,
|
|
552
|
+
* rather than as a flag compiled into the shipped path or an input rewritten to dodge a predicate.
|
|
553
|
+
* `refusals` is what makes a gate's reachability checkable: a rule nothing ever reaches is a rule
|
|
554
|
+
* no test can be failing on purpose. */
|
|
555
|
+
export function coalesceUnder(
|
|
556
|
+
gates: readonly Gate<MergePair>[],
|
|
557
|
+
sfn: SFn,
|
|
558
|
+
): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
|
|
559
|
+
const refusals = new Map<string, number>();
|
|
560
|
+
if (sfn.locals.length < 2) {
|
|
561
|
+
return { candidates: [], refusals };
|
|
562
|
+
}
|
|
563
|
+
const params = new Set(sfn.params.map((p) => p.name));
|
|
564
|
+
const typeOf = new Map(sfn.locals.map((l) => [l.name, typeToString(l.type)]));
|
|
565
|
+
const volatiles = new Set(sfn.locals.filter(isVolatileLocal).map((l) => l.name));
|
|
566
|
+
const sp = spans(sfn.body);
|
|
567
|
+
const candidates: { merged: string; sfn: SFn }[] = [];
|
|
123
568
|
for (const a of sfn.locals.map((l) => l.name)) {
|
|
124
569
|
for (const b of sfn.locals.map((l) => l.name)) {
|
|
125
570
|
const x = sp.get(a);
|
|
126
571
|
const y = sp.get(b);
|
|
127
|
-
|
|
572
|
+
// Not a gate: this is what makes the pair a pair at all. A name with no span is one the body
|
|
573
|
+
// never mentions, so there is no range to reason about.
|
|
574
|
+
if (a === b || !x || !y) {
|
|
128
575
|
continue;
|
|
129
576
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
577
|
+
const refused = firstRejection(gates, {
|
|
578
|
+
a,
|
|
579
|
+
b,
|
|
580
|
+
x,
|
|
581
|
+
y,
|
|
582
|
+
sameType: typeOf.get(a) === typeOf.get(b),
|
|
583
|
+
eitherIsParam: params.has(a) || params.has(b),
|
|
584
|
+
eitherIsVolatile: volatiles.has(a) || volatiles.has(b),
|
|
585
|
+
sharesLoop: [...x.loops].some((l) => y.loops.has(l)),
|
|
586
|
+
});
|
|
587
|
+
if (refused !== null) {
|
|
588
|
+
refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
|
|
134
589
|
continue;
|
|
135
590
|
}
|
|
136
591
|
// Labelled by the PAIR, not by an index into enumeration order: an index silently re-points
|
|
137
592
|
// at a different merge if `sfn.locals` ordering ever changes, leaving a recorded provenance
|
|
138
593
|
// that is wrong but plausible.
|
|
139
|
-
|
|
594
|
+
candidates.push({
|
|
140
595
|
merged: `${a}-${b}`,
|
|
141
|
-
sfn: { ...sfn, body: rename(sfn.body, a, b), locals: sfn.locals
|
|
596
|
+
sfn: { ...sfn, body: rename(sfn.body, a, b), locals: localsAfterMerge(sfn.locals, a, b) },
|
|
142
597
|
});
|
|
143
598
|
}
|
|
144
599
|
}
|
|
145
|
-
return
|
|
600
|
+
return { candidates, refusals };
|
|
146
601
|
}
|