@asmlift/core 0.7.0 → 0.8.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 +48 -24
- package/package.json +1 -1
- package/src/backend/pascal.ts +2 -2
- package/src/codegen-flags.ts +640 -0
- package/src/frontend/disasm.ts +141 -11
- package/src/frontend/high-half.ts +149 -0
- package/src/frontend/mips.ts +458 -209
- package/src/frontend/ppc.ts +332 -67
- package/src/frontend/reloc-symbol.ts +109 -0
- package/src/frontend/splat.ts +56 -18
- package/src/frontend/ssa.ts +126 -29
- package/src/frontend/stackargs.ts +420 -0
- package/src/frontend/thumb.ts +207 -230
- package/src/ir/core.ts +62 -3
- package/src/ir/opcodes.ts +9 -0
- package/src/ir/parse.ts +7 -1
- package/src/l3/advance.ts +2 -2
- package/src/l3/argbase.ts +2 -2
- package/src/l3/argcopy.ts +269 -0
- package/src/l3/ast.ts +45 -1
- package/src/l3/basecse.ts +2 -2
- package/src/l3/coalesce.ts +109 -52
- package/src/l3/scopebase.ts +4 -4
- package/src/l3/tailret.ts +70 -0
- package/src/l3/unmerge.ts +2 -2
- package/src/l3/unreduce.ts +2 -1
- package/src/mangle.ts +49 -0
- package/src/pattern/engine.ts +128 -13
- package/src/pipeline.ts +22 -11
- package/src/raise/extscale.ts +5 -2
- package/src/raise/paramwidth.ts +111 -3
- package/src/raise/pre-recovery.ts +11 -1
- package/src/raise/retsink.ts +8 -4
- package/src/raise/tailsink.ts +17 -2
- package/src/rank-declare.ts +17 -9
- package/src/rank.ts +45 -19
- package/src/structure/retspell.ts +95 -0
- package/src/structure/structure.ts +12 -3
- package/src/structure/switch-recover.ts +1 -1
- package/src/target.ts +224 -14
- package/src/trace.ts +27 -18
- package/src/variation-definitions.ts +52 -2
- package/src/variation-gates.ts +3 -0
- package/src/variation-tokens.ts +1 -0
package/src/l3/advance.ts
CHANGED
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
// the promoted form.
|
|
82
82
|
import { type IrType, scalarTypeForAccess } from '../ir/types';
|
|
83
83
|
import { cellAddress } from './address';
|
|
84
|
-
import { type Expr, type SFn, type Stmt, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
|
|
84
|
+
import { type Expr, type SFn, type Stmt, isLoop, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
|
|
85
85
|
import { type Gate, firstRejection } from './gates';
|
|
86
86
|
import type { BaseInit } from './hoist';
|
|
87
87
|
import { nameAllocator, placeBaseLocals } from './hoist';
|
|
@@ -230,7 +230,7 @@ function collectSites(body: readonly Stmt[]): { sites: Site[]; nestedAddrs: Set<
|
|
|
230
230
|
};
|
|
231
231
|
const walk = (stmts: readonly Stmt[], stmt: number, nested: boolean): void => {
|
|
232
232
|
for (const s of stmts) {
|
|
233
|
-
const repeats = s
|
|
233
|
+
const repeats = isLoop(s);
|
|
234
234
|
for (const e of stmtExprs(s)) {
|
|
235
235
|
visit(e, stmt, nested || repeats);
|
|
236
236
|
}
|
package/src/l3/argbase.ts
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
// leaves the diff at 2; both together take it to 0.)
|
|
40
40
|
import { type IrType, T, scalarTypeForAccess } from '../ir/types';
|
|
41
41
|
import type { Expr, SFn, Stmt } from './ast';
|
|
42
|
-
import { mapExprChildren, stmtExprs } from './ast';
|
|
42
|
+
import { isLoop, mapExprChildren, stmtExprs } from './ast';
|
|
43
43
|
import { nameAllocator } from './hoist';
|
|
44
44
|
import { declaredGlobals } from './storage';
|
|
45
45
|
|
|
@@ -133,7 +133,7 @@ export function materializeArgBases(sfn: SFn): SFn | null {
|
|
|
133
133
|
// had. That is the register-pressure failure basecse.ts's `inLoop` gate exists to refuse, and
|
|
134
134
|
// it would contradict this pass's own placement rule two comments down. So a loop's condition
|
|
135
135
|
// is left alone; only its body (via the recursion) is eligible.
|
|
136
|
-
const ownExprs = s
|
|
136
|
+
const ownExprs = isLoop(s) ? [] : stmtExprs(s);
|
|
137
137
|
for (const e of ownExprs) {
|
|
138
138
|
const scan = (x: Expr): void => {
|
|
139
139
|
if (x.k === 'call') {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// L3 respell variation: copy a pointer PARAMETER into a local for ONE REGION.
|
|
2
|
+
//
|
|
3
|
+
// A pointer parameter that a whole function reads pins its incoming register for the whole body.
|
|
4
|
+
// A source that instead copies it into a local — `u8 *b = a0;` at the head of the block that uses
|
|
5
|
+
// it — hands the allocator a SECOND name for the same address, which it may home in a different
|
|
6
|
+
// register, freeing the parameter's for something else. On
|
|
7
|
+
// pokeemerald:SetMauvilleOldManLanguage:agbcc that something else is the arm's loop counter: the
|
|
8
|
+
// target copies the base with `adds r6, r5, #0` and then counts in r5.
|
|
9
|
+
//
|
|
10
|
+
// WHY THIS IS NOT ANY OF ITS NEIGHBOURS. `l3/scopebase.ts` hoists one base per region too, but the
|
|
11
|
+
// value it hoists is the ADDRESS OF A GLOBAL (`(T *)&gSym`) and its `shadowed-or-nonarray-base`
|
|
12
|
+
// rule refuses a `var` base precisely because `&local` names a different object; a parameter
|
|
13
|
+
// already HOLDS the pointer, so nothing is addressed and that rule's argument does not reach here.
|
|
14
|
+
// `l3/argbase.ts` names a call argument's fixed addresses, `l3/inlinebase.ts` DELETES a
|
|
15
|
+
// constant-address local, and `l3/parkfirst.ts` only reorders a copy the tree already has. None of
|
|
16
|
+
// them mints `b = a0`.
|
|
17
|
+
//
|
|
18
|
+
// THE COPY IS A PLAIN LOCAL, NOT A SCOPED DECLARATION. Both spellings — a braced region
|
|
19
|
+
// declaration, and a function-top local assigned at the head of the region — were taken through
|
|
20
|
+
// this row's own agbcc and produce the same bytes, so this pass emits the one L3 already spells
|
|
21
|
+
// and adds no block-scope representation to carry a distinction no object shows.
|
|
22
|
+
//
|
|
23
|
+
// WHAT IT GIVES UP, NAMED. A pointer parameter read only inside a loop that sits at FUNCTION TOP
|
|
24
|
+
// LEVEL gets no candidate at all: `regions()` does not offer the function's own list, so the only
|
|
25
|
+
// region over those reads is the loop body, and `loop-region` refuses it. That population — a base
|
|
26
|
+
// live across a top-level loop — is `/livebase` and `/hipress`'s, and the decline is deliberate.
|
|
27
|
+
// The spelling that shape wants is a copy placed BEFORE the loop with only the loop's reads
|
|
28
|
+
// repointed: a copy SITE separate from its region, which nothing here represents. Relaxing
|
|
29
|
+
// `loop-region` does not reach it — it mints the per-iteration copy that rule exists to refuse.
|
|
30
|
+
//
|
|
31
|
+
// WHAT IS ENUMERATED, AND WHY. Which region the source copied in is not derivable from the tree,
|
|
32
|
+
// so every legal region is offered as its own candidate and the differ referees — the `/regcopy`
|
|
33
|
+
// idiom this file shares with `l3/coalesce.ts`. Uses OUTSIDE the chosen region keep naming the
|
|
34
|
+
// parameter, which is the point: the copy is what makes the two live ranges separable.
|
|
35
|
+
//
|
|
36
|
+
// IT KEEPS ITS OWN REGION WALK, AND THAT IS A SECOND REGION MODEL. `l3/scopebase.ts` ships one
|
|
37
|
+
// already, and this is a narrower duplicate; merging them is the right end state. What blocks that
|
|
38
|
+
// is the SAFETY OBLIGATION, not the region question. scopebase hoists a pure ADDRESS and
|
|
39
|
+
// discharges its obligation by PLACEMENT — `assertHoistsDominate` re-walks the emitted tree to
|
|
40
|
+
// check the hoist dominates every use it repointed. This pass hoists a VALUE the caller passed:
|
|
41
|
+
// its obligation is a whole-function invariance fact (`assigned`, `addressed`) that no placement
|
|
42
|
+
// check can see, and it needs no dominance check at all, because uses outside the region are not
|
|
43
|
+
// repointed. One table over both would need scopebase's `collect()` to admit a plain `var` read
|
|
44
|
+
// as a site and `AccessCtx`/`keyOf` to key a parameter — a widening of a file four shipped
|
|
45
|
+
// variations rest on. Until then every walk HERE goes through `ast.ts`'s shared ones, so the two
|
|
46
|
+
// models at least cannot disagree about what a statement contains.
|
|
47
|
+
import type { Expr, SFn, Stmt } from './ast';
|
|
48
|
+
import { isLoop, mapExprChildren, mapStmtExprs, mapStmtLists, stmtChildren, stmtLists, walkExprs } from './ast';
|
|
49
|
+
import { type Gate, firstRejection } from './gates';
|
|
50
|
+
import { nameAllocator } from './hoist';
|
|
51
|
+
|
|
52
|
+
/** One candidate copy, as the gates see it. */
|
|
53
|
+
export interface ArgCopyCtx {
|
|
54
|
+
/** the parameter being copied */
|
|
55
|
+
readonly param: string;
|
|
56
|
+
/** the parameter's declared type is a pointer — the register this variation is about is a base */
|
|
57
|
+
readonly isPointer: boolean;
|
|
58
|
+
/** the function assigns the parameter somewhere, so a copy taken earlier can go stale */
|
|
59
|
+
readonly assigned: boolean;
|
|
60
|
+
/** `¶m` occurs, so the copy is a DIFFERENT object and any write through the address misses it */
|
|
61
|
+
readonly addressed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The admission rules. The two SOUND ones are the whole soundness argument: with the parameter
|
|
65
|
+
* never assigned and never addressed, the copy holds the parameter's value at every point the
|
|
66
|
+
* region can reach, so repointing the region's reads at it renames a value rather than changing
|
|
67
|
+
* one. Drop either and the rewrite names different memory — C that compiles and scores.
|
|
68
|
+
*
|
|
69
|
+
* Both are decided over the WHOLE tree — see `countReads` on why every walk in this file goes
|
|
70
|
+
* through `stmtChildren`/`walkExprs`. A gate that called itself sound while judging a subset of the
|
|
71
|
+
* statements `repoint` rewrites would be sound about a function nobody compiles. */
|
|
72
|
+
export const ARGCOPY_GATES: readonly Gate<ArgCopyCtx>[] = [
|
|
73
|
+
{
|
|
74
|
+
id: 'non-pointer',
|
|
75
|
+
why: 'the freed register is a base register, and a scalar parameter does not hold one',
|
|
76
|
+
sound: false,
|
|
77
|
+
rejects: (c) => !c.isPointer,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'assigned',
|
|
81
|
+
why: 'the function assigns the parameter, so the copy would hold a value the parameter no longer has',
|
|
82
|
+
sound: true,
|
|
83
|
+
guardedBy: 'argcopy.test.ts: a parameter a `for` header ADVANCES is never copied',
|
|
84
|
+
rejects: (c) => c.assigned,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'addressed',
|
|
88
|
+
why: 'taking the parameter’s address names its own cell, and the copy is a different cell',
|
|
89
|
+
sound: true,
|
|
90
|
+
guardedBy: 'argcopy.test.ts: a parameter whose ADDRESS is taken in a `for` header is never copied',
|
|
91
|
+
rejects: (c) => c.addressed,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** One candidate REGION, as the region rules see it. Separate from {@link ARGCOPY_GATES} for the
|
|
96
|
+
* reason `scopebase` keeps two tables: those rules judge the PARAMETER once, these judge each
|
|
97
|
+
* place it could be copied, and a refusal tally that mixed the two would count a parameter's
|
|
98
|
+
* single verdict once per region. */
|
|
99
|
+
export interface ArgCopyRegionCtx {
|
|
100
|
+
/** reads of the parameter inside this region */
|
|
101
|
+
readonly reads: number;
|
|
102
|
+
/** a loop encloses the region — its own body, or any list nested below one */
|
|
103
|
+
readonly underLoop: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Which regions are worth offering. NEITHER is sound — each refuses a spelling that is correct
|
|
107
|
+
* but models nothing, and what they buy is the candidate count: every one of these is a COMPILE,
|
|
108
|
+
* and this variation multiplies with `/coalesce`. */
|
|
109
|
+
export const ARGCOPY_REGION_GATES: readonly Gate<ArgCopyRegionCtx>[] = [
|
|
110
|
+
{
|
|
111
|
+
id: 'single-read',
|
|
112
|
+
why: 'a region reading the parameter once gives the allocator no live range to shorten, so the copy buys nothing',
|
|
113
|
+
sound: false,
|
|
114
|
+
rejects: (c) => c.reads < 2,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'loop-region',
|
|
118
|
+
why: 'a copy anywhere inside a loop re-runs every iteration, so it belongs to a region that holds the loop instead — and no region does when the loop is at function top level, where this variation offers nothing at all',
|
|
119
|
+
sound: false,
|
|
120
|
+
// the second clause has its own test, which `guardedBy` cannot also name (one title per gate):
|
|
121
|
+
// `argcopy.test.ts: a TOP-LEVEL loop is offered NOTHING`
|
|
122
|
+
guardedBy: 'argcopy.test.ts: an arm NESTED inside a loop body is refused too',
|
|
123
|
+
rejects: (c) => c.underLoop,
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
/** How many `var n` leaves the region holds, nested statements included — the region rules'
|
|
128
|
+
* yardstick for whether a copy has a range to shorten.
|
|
129
|
+
*
|
|
130
|
+
* `walkExprs` (ast.ts) is the whole-tree walk, which descends through `stmtChildren` and so sees a
|
|
131
|
+
* `for`'s `init` and `inc`. That is the walk this file must use everywhere: `repoint` rewrites
|
|
132
|
+
* those two statements (`mapStmtExprs` recurses into them), so a count taken with `stmtLists` —
|
|
133
|
+
* which deliberately omits them, being a walk over the SCOPES a statement opens — would report
|
|
134
|
+
* fewer reads than the rewrite touches. */
|
|
135
|
+
function countReads(list: Stmt[], n: string): number {
|
|
136
|
+
let reads = 0;
|
|
137
|
+
for (const e of walkExprs(list)) {
|
|
138
|
+
if (e.k === 'var' && e.name === n) {
|
|
139
|
+
reads++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return reads;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every nested statement list in `body`, each with the path that reaches it — a REGION is any
|
|
146
|
+
* list a statement contains, which is what a `case` body, an `if` arm and a loop body all are.
|
|
147
|
+
* The function's own top-level list is NOT among them: repointing it renames the parameter over
|
|
148
|
+
* the whole function, which is a PARK — the shape `l3/parkfirst.ts` reorders — and not the two
|
|
149
|
+
* separable live ranges this variation is about. */
|
|
150
|
+
function regions(body: Stmt[]): { at: number[]; list: Stmt[]; underLoop: boolean }[] {
|
|
151
|
+
const out: { at: number[]; list: Stmt[]; underLoop: boolean }[] = [];
|
|
152
|
+
// carried DOWN rather than read off the immediate parent: an `if` arm two levels inside a loop
|
|
153
|
+
// body re-runs every iteration exactly as the body does, which is what `loop-region` refuses
|
|
154
|
+
const walk = (list: Stmt[], path: number[], underLoop: boolean): void => {
|
|
155
|
+
list.forEach((st, i) => {
|
|
156
|
+
const inside = underLoop || isLoop(st);
|
|
157
|
+
stmtLists(st).forEach((inner, j) => {
|
|
158
|
+
const here = [...path, i, j];
|
|
159
|
+
out.push({ at: here, list: inner, underLoop: inside });
|
|
160
|
+
walk(inner, here, inside);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
walk(body, [], false);
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `list` with every `var n` leaf rewritten to `var to`, nested statements included. Both kind
|
|
169
|
+
* switches are ast.ts's, so a new statement or expression kind is that file's problem, not this
|
|
170
|
+
* one's.
|
|
171
|
+
*
|
|
172
|
+
* ONE walk: `mapStmtExprs` already recurses into every nested statement — a `for`'s `init`/`inc`
|
|
173
|
+
* included, which is what makes this symmetric with `countReads` — so a single `map` over the list
|
|
174
|
+
* reaches the whole subtree. Driving it through `mapStmtLists` as well would visit a statement at
|
|
175
|
+
* depth d once per level above it: 40 expression visits against 13 on a five-level tree (switch >
|
|
176
|
+
* for > do-while > if > store). This runs once per region per parameter per candidate, and each
|
|
177
|
+
* candidate is a compile. */
|
|
178
|
+
function repoint(list: Stmt[], n: string, to: string): Stmt[] {
|
|
179
|
+
const inExpr = (e: Expr): Expr => (e.k === 'var' && e.name === n ? { ...e, name: to } : mapExprChildren(e, inExpr));
|
|
180
|
+
return list.map((s) => mapStmtExprs(s, inExpr));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Replace the region at `at` with `next`. */
|
|
184
|
+
function replaceRegion(body: Stmt[], at: number[], next: Stmt[]): Stmt[] {
|
|
185
|
+
const [i, j, ...rest] = at;
|
|
186
|
+
return body.map((st, k) => {
|
|
187
|
+
if (k !== i) {
|
|
188
|
+
return st;
|
|
189
|
+
}
|
|
190
|
+
let seen = -1;
|
|
191
|
+
return mapStmtLists(st, (inner) => {
|
|
192
|
+
seen++;
|
|
193
|
+
if (seen !== j) {
|
|
194
|
+
return inner;
|
|
195
|
+
}
|
|
196
|
+
return rest.length ? replaceRegion(inner, rest, next) : next;
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `argCopyCandidates` with the gate table supplied plus which gate refused each parameter — the
|
|
202
|
+
* same ablation-as-a-value seam the coalescer provides. */
|
|
203
|
+
export function argCopyUnder(
|
|
204
|
+
gates: readonly Gate<ArgCopyCtx>[],
|
|
205
|
+
sfn: SFn,
|
|
206
|
+
regionGates: readonly Gate<ArgCopyRegionCtx>[] = ARGCOPY_REGION_GATES,
|
|
207
|
+
): { candidates: { merged: string; sfn: SFn }[]; refusals: Map<string, number> } {
|
|
208
|
+
const refusals = new Map<string, number>();
|
|
209
|
+
const out: { merged: string; sfn: SFn }[] = [];
|
|
210
|
+
/** `&n` anywhere in the function — every expression node, `for` header included. */
|
|
211
|
+
const addressed = (n: string): boolean => {
|
|
212
|
+
for (const e of walkExprs(sfn.body)) {
|
|
213
|
+
if (e.k === 'addr' && e.name === n) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
};
|
|
219
|
+
/** The function assigns `n` anywhere. `stmtChildren`, never `stmtLists`: a `for`'s `init` and
|
|
220
|
+
* `inc` are STATEMENTS rather than lists, and an induction step that advances the parameter is
|
|
221
|
+
* exactly one of them — the shape `structure/structure.ts`'s `recognizeForLoops` mints. */
|
|
222
|
+
const assignsTo = (n: string, list: Stmt[] = sfn.body): boolean =>
|
|
223
|
+
list.some((st) => (st.k === 'assign' && st.name === n) || assignsTo(n, stmtChildren(st)));
|
|
224
|
+
for (const p of sfn.params) {
|
|
225
|
+
const refused = firstRejection(gates, {
|
|
226
|
+
param: p.name,
|
|
227
|
+
isPointer: p.type.kind === 'ptr',
|
|
228
|
+
assigned: assignsTo(p.name),
|
|
229
|
+
addressed: addressed(p.name),
|
|
230
|
+
});
|
|
231
|
+
if (refused !== null) {
|
|
232
|
+
refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
for (const r of regions(sfn.body)) {
|
|
236
|
+
// not a gate: a region with no read of the parameter has nothing to repoint
|
|
237
|
+
const reads = countReads(r.list, p.name);
|
|
238
|
+
if (reads === 0) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const regionRefused = firstRejection(regionGates, { reads, underLoop: r.underLoop });
|
|
242
|
+
if (regionRefused !== null) {
|
|
243
|
+
refusals.set(regionRefused, (refusals.get(regionRefused) ?? 0) + 1);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// allocated against the ORIGINAL function, so every candidate names its copy identically —
|
|
247
|
+
// each is a separate tree and none of them ever meets another
|
|
248
|
+
const name = nameAllocator(sfn)();
|
|
249
|
+
const copied = [
|
|
250
|
+
{ k: 'assign', name, value: { k: 'var', name: p.name } } as Stmt,
|
|
251
|
+
...repoint(r.list, p.name, name),
|
|
252
|
+
];
|
|
253
|
+
out.push({
|
|
254
|
+
merged: `${p.name}@${r.at.join('.')}`,
|
|
255
|
+
sfn: {
|
|
256
|
+
...sfn,
|
|
257
|
+
body: replaceRegion(sfn.body, r.at, copied),
|
|
258
|
+
locals: [...sfn.locals, { name, type: p.type }],
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { candidates: out, refusals };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Every legal region copy, each as its own tree. */
|
|
267
|
+
export function argCopyCandidates(sfn: SFn): { merged: string; sfn: SFn }[] {
|
|
268
|
+
return argCopyUnder(ARGCOPY_GATES, sfn).candidates;
|
|
269
|
+
}
|
package/src/l3/ast.ts
CHANGED
|
@@ -266,7 +266,13 @@ export type Stmt =
|
|
|
266
266
|
// by instrumenting the printer. Both regimes produce them: the jump table spells `case 4:` of
|
|
267
267
|
// `kleod:UpdateWorldMapNodeAnim`, the comparison tree `synthetic:sw_fallmem:agbcc`.
|
|
268
268
|
| { k: 'switch'; scrutinee: Expr; cases: SwitchCase[]; default?: Stmt[]; defaultAt?: number }
|
|
269
|
-
|
|
269
|
+
// `unspelled` — the assembly shows no `return` STATEMENT behind this node: the machine reached
|
|
270
|
+
// the epilogue here by falling into it, or on a conditional branch's own edge, never by the
|
|
271
|
+
// `b <epilogue>` a source `return;` compiles to (structure.ts sets it; the reading is stated
|
|
272
|
+
// there). A void return is then a no-op wherever nothing follows it, and `l3/tailret.ts` drops
|
|
273
|
+
// it — which is not cosmetic: unoptimised code gives every source `return;` a branch of its own,
|
|
274
|
+
// so the redundant spelling costs bytes. Advice about SPELLING; nothing may read it as semantics.
|
|
275
|
+
| { k: 'return'; value?: Expr; unspelled?: true };
|
|
270
276
|
|
|
271
277
|
/** One arm of a `switch`. `values` stacks multiple `case K:` labels onto one body (`case 1: case 2:`).
|
|
272
278
|
* `fallsThrough` true ⇒ the body flows into the NEXT arm (no `break;`); see the non-neutrality note. */
|
|
@@ -725,6 +731,44 @@ export function stmtChildren(s: Stmt): Stmt[] {
|
|
|
725
731
|
}
|
|
726
732
|
}
|
|
727
733
|
|
|
734
|
+
/** A statement that REPEATS what it contains — what {@link isLoop} narrows to.
|
|
735
|
+
*
|
|
736
|
+
* Derived STRUCTURALLY, not from a second list of kinds: a repeating statement is exactly one that
|
|
737
|
+
* carries both a `cond` and a `body`, and no other `Stmt` carries both (`if` has `cond` without
|
|
738
|
+
* `body`, `switch` has neither). A new repeating kind therefore joins this type by construction,
|
|
739
|
+
* where a hand-written kind list falls out of step SILENTLY — TypeScript does not check a
|
|
740
|
+
* predicate's body against the type it asserts. */
|
|
741
|
+
export type Loop = Extract<Stmt, { cond: Expr; body: Stmt[] }>;
|
|
742
|
+
|
|
743
|
+
/** Does this statement REPEAT what it contains? Its body and its own condition run once per
|
|
744
|
+
* iteration, which is the fact half of `l3/` asks about: a hoist out of one is loop-invariant
|
|
745
|
+
* code motion, a copy inside one re-runs, and a live range that crosses one is under pressure.
|
|
746
|
+
*
|
|
747
|
+
* It lives here, exhaustive and with no `default`, for the reason {@link stmtLists} does: a new
|
|
748
|
+
* `Stmt` kind that repeats is then a compile error at ONE site rather than a silent `false` in
|
|
749
|
+
* each caller's own hand-spelled kind test. It NARROWS to {@link Loop} rather than returning
|
|
750
|
+
* `boolean`, because a caller that reaches `body` afterwards cannot use a `boolean` one and spells
|
|
751
|
+
* the kind test out again instead. `backend/pascal.ts` is where that costs correctness: its
|
|
752
|
+
* `hasReturn` walk decides a LOUD failure — Pascal's `case-of` cannot spell an early `return` — so
|
|
753
|
+
* a repeating kind read as a non-loop turns the refusal into an emitted miscompile. */
|
|
754
|
+
export function isLoop(s: Stmt): s is Loop {
|
|
755
|
+
switch (s.k) {
|
|
756
|
+
case 'while':
|
|
757
|
+
case 'dowhile':
|
|
758
|
+
case 'for':
|
|
759
|
+
return true;
|
|
760
|
+
case 'if':
|
|
761
|
+
case 'switch':
|
|
762
|
+
case 'assign':
|
|
763
|
+
case 'store':
|
|
764
|
+
case 'exprstmt':
|
|
765
|
+
case 'return':
|
|
766
|
+
case 'break':
|
|
767
|
+
case 'continue':
|
|
768
|
+
return false;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
728
772
|
/** The nested statement LISTS of a statement — the SCOPES it opens.
|
|
729
773
|
*
|
|
730
774
|
* Deliberately not `stmtChildren`, which flattens a `for`'s `init`/`inc` in with its body: those
|
package/src/l3/basecse.ts
CHANGED
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
import { assertHoistsDominate } from '../contracts';
|
|
109
109
|
import { type IrType, T, scalarTypeForAccess, typeToString } from '../ir/types';
|
|
110
110
|
import type { Expr, SFn, Stmt } from './ast';
|
|
111
|
-
import { exprChildren, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
|
|
111
|
+
import { exprChildren, isLoop, mapExprChildren, mapStmtExprs, stmtChildren, stmtExprs } from './ast';
|
|
112
112
|
import { type Gate, ablateHeuristic, firstRejection } from './gates';
|
|
113
113
|
import { type BaseInit, type HoistPlacement, nameAllocator, placeBaseLocals } from './hoist';
|
|
114
114
|
|
|
@@ -240,7 +240,7 @@ function collect(stmts: Stmt[], c: Collected, loop: boolean): void {
|
|
|
240
240
|
for (const s of stmts) {
|
|
241
241
|
// A loop's OWN condition (`stmtExprs` of a while/do-while/for) runs every iteration, so a base
|
|
242
242
|
// there is loop-invariant just like a body use — visit it with `nested`, not the outer flag.
|
|
243
|
-
const nested = loop || s
|
|
243
|
+
const nested = loop || isLoop(s);
|
|
244
244
|
for (const e of stmtExprs(s)) {
|
|
245
245
|
visitExpr(e, nested);
|
|
246
246
|
}
|
package/src/l3/coalesce.ts
CHANGED
|
@@ -6,18 +6,15 @@
|
|
|
6
6
|
// TWO admission paths live here, each with its own gate table and its own reading of loops. The
|
|
7
7
|
// SPAN path (COALESCE_GATES) proves disjoint liveness from preorder position, so it asks which
|
|
8
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
|
|
10
|
-
// between them
|
|
11
|
-
// argument however the arms' own loops relate
|
|
9
|
+
// ARM-DISJOINT path (ARM_DISJOINT_GATES) proves the two never coexist because one BRANCH picks
|
|
10
|
+
// between them — an `if`'s two arms or a `switch`'s case bodies alike — so it asks only whether ANY
|
|
11
|
+
// loop encloses that branch (a second entry breaks the argument however the arms' own loops relate)
|
|
12
|
+
// and whether fall-through joins the two arms onto one path. `coalesceCandidates` offers both.
|
|
12
13
|
import { typeToString } from '../ir/types';
|
|
13
14
|
import type { Expr, SFn, Stmt } from './ast';
|
|
14
|
-
import { exprChildren, mapExprChildren, stmtChildren, stmtExprs } from './ast';
|
|
15
|
+
import { exprChildren, isLoop, mapExprChildren, stmtChildren, stmtExprs } from './ast';
|
|
15
16
|
import { type Gate, firstRejection } from './gates';
|
|
16
17
|
|
|
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';
|
|
20
|
-
|
|
21
18
|
function namesIn(e: Expr, out: Set<string>): void {
|
|
22
19
|
// `addr` names a GLOBAL (`&gSym`) or a LOCAL — the structurer renders an `laddr` frame object
|
|
23
20
|
// as `&sp0`, an addr node over a name that IS in `sfn.locals`. Both are collected, because a
|
|
@@ -325,8 +322,11 @@ function localsAfterMerge(locals: SFn['locals'], gone: string, kept: string): SF
|
|
|
325
322
|
export interface ArmPair {
|
|
326
323
|
a: string;
|
|
327
324
|
b: string;
|
|
328
|
-
/** the confining
|
|
325
|
+
/** the confining branch has a loop ancestor, so it can run more than once */
|
|
329
326
|
ifInLoop: boolean;
|
|
327
|
+
/** control can run from one of the two arms INTO the other — `switch` fall-through, the one
|
|
328
|
+
* way two arms of a branch land on a single path. Always false for an `if`. */
|
|
329
|
+
armsJoined: boolean;
|
|
330
330
|
sameType: boolean;
|
|
331
331
|
/** either local is object-volatile or carries a pointee-volatile qualifier (see MergePair) */
|
|
332
332
|
eitherIsVolatile: boolean;
|
|
@@ -341,12 +341,13 @@ export interface ArmPair {
|
|
|
341
341
|
* different reason: `arm-init` is a FIRST-MENTION rule where `const-fed` is an every-assign one,
|
|
342
342
|
* so arms that open with a const write and then compute (`x = 0; x = x + 1;`) merge here and not
|
|
343
343
|
* there. Two locals confined to
|
|
344
|
-
*
|
|
345
|
-
* can observe the other's write — no liveness reasoning needed.
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
344
|
+
* DIFFERENT arms of one branch never coexist at runtime: the branch picks one arm, so no read of
|
|
345
|
+
* either can observe the other's write — no liveness reasoning needed. `branchArms` says what an
|
|
346
|
+
* arm is, and the argument holds for a `switch`'s case bodies exactly as it does for an `if`'s two.
|
|
347
|
+
*
|
|
348
|
+
* Two gates protect it, one per way it fails. `loop` covers a SECOND ENTRY, which is why it wants
|
|
349
|
+
* ANY enclosing loop rather than the span model's shared-loop rule: never-coexisting is a claim
|
|
350
|
+
* about one entry. `fall-through` covers the failure WITHIN one entry. */
|
|
350
351
|
export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
|
|
351
352
|
{
|
|
352
353
|
id: 'type',
|
|
@@ -363,11 +364,18 @@ export const ARM_DISJOINT_GATES: readonly Gate<ArmPair>[] = [
|
|
|
363
364
|
},
|
|
364
365
|
{
|
|
365
366
|
id: 'loop',
|
|
366
|
-
why: 'a loop ancestor re-enters the
|
|
367
|
+
why: 'a loop ancestor re-enters the branch, so different arms both run and a value could cross',
|
|
367
368
|
sound: true,
|
|
368
|
-
guardedBy: 'coalesce.test.ts:
|
|
369
|
+
guardedBy: 'coalesce.test.ts: never admits its arm pair',
|
|
369
370
|
rejects: (c) => c.ifInLoop,
|
|
370
371
|
},
|
|
372
|
+
{
|
|
373
|
+
id: 'fall-through',
|
|
374
|
+
why: 'a case that runs on into the other arm puts both locals on one path, so they coexist',
|
|
375
|
+
sound: true,
|
|
376
|
+
guardedBy: 'coalesce.test.ts: two arms joined by fall-through never merge',
|
|
377
|
+
rejects: (c) => c.armsJoined,
|
|
378
|
+
},
|
|
371
379
|
{
|
|
372
380
|
id: 'arm-init',
|
|
373
381
|
why: 'a local its arm does not first set to a constant is one the compiler had a reason to keep apart',
|
|
@@ -477,6 +485,44 @@ function mentionIndex(): {
|
|
|
477
485
|
return { mentionsOf, mentionsUnder, firstMention };
|
|
478
486
|
}
|
|
479
487
|
|
|
488
|
+
/** The MUTUALLY EXCLUSIVE arms of a branching statement, and which pairs of them control can
|
|
489
|
+
* nevertheless run through together — the one shape both admission sites read, so `if` and
|
|
490
|
+
* `switch` are one rule here rather than two walkers.
|
|
491
|
+
*
|
|
492
|
+
* An `if` has exactly two arms and no way to reach one from the other. A `switch`'s arms are its
|
|
493
|
+
* `case` bodies, and FALL-THROUGH is the one way two of them land on a single path: arm `i` runs
|
|
494
|
+
* into arm `j` exactly when every arm from `i` up to `j` falls through, so the reach is the
|
|
495
|
+
* TRANSITIVE chain and not merely the adjacent pair.
|
|
496
|
+
*
|
|
497
|
+
* ITS REACH, MEASURED, because a soundness argument with no corpus witness should say so. Counted
|
|
498
|
+
* 2026-09-19 with a throwaway counter in this walk, over `pnpm bench sweep --fan --map-modes
|
|
499
|
+
* harness,nomap` — 2,396 records over the 1,198-row corpus: the switch half ADMITS 192 pairs, all
|
|
500
|
+
* on `pokeemerald:SetMauvilleOldManLanguage:agbcc` and none on any other function. Across all five
|
|
501
|
+
* gates, `arm-init` refuses 4,024 and `type` 1,346, while `volatile`, `loop` and `fall-through`
|
|
502
|
+
* refuse **zero** — so what bounds this extension corpus-wide is a COST gate, while both SOUND
|
|
503
|
+
* rules are witnessed only by the fixtures in `coalesce.test.ts`. Re-take the count rather than
|
|
504
|
+
* quoting it: a corpus that grows falsifies the number, not the argument.
|
|
505
|
+
*
|
|
506
|
+
* A `switch`'s `default` body is deliberately NOT an arm. `defaultAt` may place the label between
|
|
507
|
+
* case labels, where the body is reachable both by dispatch and by running on into the arm below
|
|
508
|
+
* it — a path `fallsThrough` does not describe, because the flag indexes the `cases` array the
|
|
509
|
+
* label does not sit in. Rather than reason about a position this file cannot see, no pair
|
|
510
|
+
* involving the default is offered: a merge withheld is a candidate missed, a merge admitted on a
|
|
511
|
+
* path that exists is a wrong answer. */
|
|
512
|
+
const branchArms = (st: Stmt): { arms: Stmt[][]; joined: (i: number, j: number) => boolean } | null => {
|
|
513
|
+
if (st.k === 'if' && st.then.length && st.else.length) {
|
|
514
|
+
return { arms: [st.then, st.else], joined: () => false };
|
|
515
|
+
}
|
|
516
|
+
if (st.k === 'switch') {
|
|
517
|
+
const { cases } = st;
|
|
518
|
+
return {
|
|
519
|
+
arms: cases.map((c) => c.body),
|
|
520
|
+
joined: (i, j) => cases.slice(Math.min(i, j), Math.max(i, j)).every((c) => c.fallsThrough),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
};
|
|
525
|
+
|
|
480
526
|
/** `armDisjointCandidates` with the gate table supplied plus which gate refused each pair — the
|
|
481
527
|
* same ablation-as-a-value seam `coalesceUnder` provides for the span table. */
|
|
482
528
|
export function armDisjointUnder(
|
|
@@ -498,44 +544,55 @@ export function armDisjointUnder(
|
|
|
498
544
|
const l = locals.get(n);
|
|
499
545
|
return l !== undefined && isVolatileLocal(l);
|
|
500
546
|
};
|
|
547
|
+
// locals only, and never a name that is ALSO a param — the span path holds the same belief as a
|
|
548
|
+
// gate, and a local shadowing a param would let rename() rewrite the param's own mentions
|
|
549
|
+
const confined = (m: Map<string, number>): string[] =>
|
|
550
|
+
[...m.entries()].filter(([n, k]) => locals.has(n) && !params.has(n) && total.get(n) === k).map(([n]) => n);
|
|
501
551
|
const visit = (stmts: Stmt[], inLoop: boolean): void => {
|
|
502
552
|
for (const st of stmts) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
553
|
+
const branch = branchArms(st);
|
|
554
|
+
if (branch !== null) {
|
|
555
|
+
const { arms, joined } = branch;
|
|
556
|
+
const confinedIn = arms.map((body) => confined(mentionsOf(body)));
|
|
557
|
+
for (let i = 0; i < arms.length; i++) {
|
|
558
|
+
for (let j = i + 1; j < arms.length; j++) {
|
|
559
|
+
for (const a of confinedIn[i]) {
|
|
560
|
+
for (const b of confinedIn[j]) {
|
|
561
|
+
// the survivor is the earlier declaration, matching how a shared source local
|
|
562
|
+
// reads.
|
|
563
|
+
//
|
|
564
|
+
// THIS READS THE STRUCTURER'S ORDER, AND MUST. The declaration list is put into
|
|
565
|
+
// the target's frame order at EMIT time (l3/slotorder.ts), after this pass, so
|
|
566
|
+
// `declIdx` is the naming walk's order and the choice means "the earlier
|
|
567
|
+
// declaration in the source asmlift recovered". Ordering the list any earlier
|
|
568
|
+
// would silently change which local survives every arm-disjoint merge on a
|
|
569
|
+
// function whose frame order disagrees with its declaration order — exactly the
|
|
570
|
+
// population the ordering exists for.
|
|
571
|
+
const [gone, kept] = (declIdx.get(a) ?? 0) <= (declIdx.get(b) ?? 0) ? [b, a] : [a, b];
|
|
572
|
+
const refused = firstRejection(gates, {
|
|
573
|
+
a: gone,
|
|
574
|
+
b: kept,
|
|
575
|
+
ifInLoop: inLoop,
|
|
576
|
+
armsJoined: joined(i, j),
|
|
577
|
+
sameType: typeOf.get(a) === typeOf.get(b),
|
|
578
|
+
eitherIsVolatile: isVolatile(a) || isVolatile(b),
|
|
579
|
+
bothArmConstInit:
|
|
580
|
+
firstMention(arms[i], a) === 'const-write' && firstMention(arms[j], b) === 'const-write',
|
|
581
|
+
});
|
|
582
|
+
if (refused !== null) {
|
|
583
|
+
refusals.set(refused, (refusals.get(refused) ?? 0) + 1);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
out.push({
|
|
587
|
+
merged: `${gone}-${kept}`,
|
|
588
|
+
sfn: {
|
|
589
|
+
...sfn,
|
|
590
|
+
body: rename(sfn.body, gone, kept),
|
|
591
|
+
locals: localsAfterMerge(sfn.locals, gone, kept),
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
534
595
|
}
|
|
535
|
-
out.push({
|
|
536
|
-
merged: `${gone}-${kept}`,
|
|
537
|
-
sfn: { ...sfn, body: rename(sfn.body, gone, kept), locals: localsAfterMerge(sfn.locals, gone, kept) },
|
|
538
|
-
});
|
|
539
596
|
}
|
|
540
597
|
}
|
|
541
598
|
}
|
package/src/l3/scopebase.ts
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
import { assertHoistsDominate } from '../contracts';
|
|
70
70
|
import { type IrType, T, scalarTypeForAccess } from '../ir/types';
|
|
71
71
|
import type { Expr, SFn, Stmt } from './ast';
|
|
72
|
-
import { mapExprChildren, stmtExprs, stmtLists } from './ast';
|
|
72
|
+
import { isLoop, mapExprChildren, stmtExprs, stmtLists } from './ast';
|
|
73
73
|
import { type Gate, ablateHeuristic, firstRejection } from './gates';
|
|
74
74
|
import { nameAllocator, takenNames } from './hoist';
|
|
75
75
|
import { addressableGlobals } from './storage';
|
|
@@ -227,7 +227,7 @@ function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[
|
|
|
227
227
|
};
|
|
228
228
|
for (const [i, s] of body.entries()) {
|
|
229
229
|
at = i;
|
|
230
|
-
const
|
|
230
|
+
const repeats = isLoop(s);
|
|
231
231
|
// A loop's OWN condition runs every iteration — a base there is loop-invariant exactly as a
|
|
232
232
|
// body use is, and it lives at THIS list, which does not. basecse.ts and argbase.ts treat the
|
|
233
233
|
// CONDITION the same way. They do NOT agree about a `for`'s `init`: basecse counts it in-loop
|
|
@@ -235,7 +235,7 @@ function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[
|
|
|
235
235
|
// it at the enclosing cadence, which is the truthful reading — it runs once. Recorded because
|
|
236
236
|
// the divergence is real and an extraction has to pick one; both readings are pinned in
|
|
237
237
|
// test/addr-placement.test.ts so the pick is deliberate rather than whichever survives.
|
|
238
|
-
stmtExprs(s).forEach((e) => visit(e,
|
|
238
|
+
stmtExprs(s).forEach((e) => visit(e, repeats));
|
|
239
239
|
if (s.k === 'for') {
|
|
240
240
|
// `init`/`inc` are typed as the full Stmt union, so a COMPOUND one is type-legal. `stmtExprs`
|
|
241
241
|
// reaches only its own expressions while `rewriteStmt` descends into any nested list — the
|
|
@@ -257,7 +257,7 @@ function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[
|
|
|
257
257
|
stmtExprs(s.inc).forEach((e) => visit(e, true));
|
|
258
258
|
}
|
|
259
259
|
for (const child of stmtLists(s)) {
|
|
260
|
-
collect(child, [...path, child], [...loop,
|
|
260
|
+
collect(child, [...path, child], [...loop, repeats], [...idxPath, i], st);
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
}
|