@asmlift/core 0.5.0 → 0.7.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 +270 -171
- package/src/backend/cpp.ts +1 -0
- package/src/backend/pascal.ts +26 -12
- package/src/contracts.ts +243 -39
- package/src/declare.ts +41 -4
- package/src/frontend/mips.ts +11 -0
- package/src/frontend/ppc.ts +43 -7
- package/src/frontend/ssa.ts +404 -29
- package/src/frontend/thumb.ts +2176 -686
- package/src/ir/alias.ts +78 -0
- package/src/ir/bits.ts +75 -0
- package/src/ir/core.ts +345 -2
- package/src/ir/opcodes.ts +176 -21
- 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/advance.ts +373 -0
- package/src/l3/argbase.ts +6 -5
- package/src/l3/ast.ts +510 -59
- package/src/l3/basecse.ts +686 -78
- package/src/l3/coalesce.ts +432 -46
- package/src/l3/dce.ts +31 -9
- package/src/l3/gates.ts +96 -1
- 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 +176 -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 +114 -89
- package/src/l3/reindex.ts +722 -80
- package/src/l3/scopebase.ts +649 -220
- 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 +16 -1
- package/src/l3/typing.ts +198 -9
- package/src/l3/unmerge.ts +687 -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 +239 -16
- package/src/pipeline.ts +173 -60
- package/src/proto.ts +112 -14
- package/src/raise/arrays.ts +6 -1
- package/src/raise/const.ts +203 -3
- package/src/raise/divpow2.ts +4 -4
- package/src/raise/extscale.ts +342 -0
- package/src/raise/globalshape.ts +1058 -0
- package/src/raise/gvn.ts +33 -18
- package/src/raise/latch.ts +126 -0
- package/src/raise/magicdiv.ts +2 -2
- package/src/raise/memberarrays.ts +594 -0
- package/src/raise/narrow.ts +124 -0
- package/src/raise/narrowlocal.ts +572 -0
- package/src/raise/paramwidth.ts +201 -0
- package/src/raise/pre-recovery.ts +169 -21
- package/src/raise/recover.ts +56 -23
- package/src/raise/retsink.ts +585 -19
- package/src/raise/shortcircuit.ts +1050 -89
- package/src/raise/struct-arrays.ts +19 -2
- package/src/raise/structs.ts +34 -4
- package/src/raise/tailsink.ts +126 -0
- package/src/rank-declare.ts +256 -0
- package/src/rank-variations.ts +760 -0
- package/src/rank.ts +2122 -326
- package/src/structure/analysis.ts +1398 -150
- package/src/structure/bitfields.ts +432 -0
- package/src/structure/globalaccess.ts +300 -0
- package/src/structure/hazards.ts +411 -20
- package/src/structure/loops.ts +2 -49
- package/src/structure/namecoalesce.ts +454 -0
- package/src/structure/structure.ts +3979 -612
- package/src/structure/switch-recover.ts +710 -145
- package/src/symbols.ts +188 -6
- package/src/target.ts +495 -32
- package/src/trace.ts +112 -33
- package/src/variation-definitions.ts +1540 -0
- package/src/variation-gates.ts +89 -0
- package/src/variation-tokens.ts +355 -0
package/src/l3/scopebase.ts
CHANGED
|
@@ -1,22 +1,43 @@
|
|
|
1
|
-
// L3
|
|
2
|
-
//
|
|
1
|
+
// L3 respell variation: name a reused global base in a pointer local placed by SCOPE rather than at
|
|
2
|
+
// the function top. Two region rules ship, one pass and one collected index behind them:
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// `/scopebase` ONE local for a key, at the innermost list holding all of its uses (else the
|
|
5
|
+
// deepest cluster of two).
|
|
6
|
+
// `/regionbase` ONE LOCAL PER REGION — a base the source spells inside N disjoint regions is N
|
|
7
|
+
// locals. agbcc discriminates on how many distinct locals with disjoint live
|
|
8
|
+
// ranges exist, NOT on where they are declared: the three-at-function-top spelling
|
|
9
|
+
// and the three-block-scoped one assemble byte-identically. So there is no nested
|
|
10
|
+
// declaration block here and none is needed — the locals are declared at function
|
|
11
|
+
// top and only their ASSIGNMENTS are placed per region. That compiler fact is
|
|
12
|
+
// PINNED rather than asserted: packages/cli/test/matching/decl-scope-variation.test.ts
|
|
13
|
+
// compiles both spellings through the project's own agbcc and compares the object
|
|
14
|
+
// bytes, and compiles a count-collapsed third spelling to show the COUNT is not
|
|
15
|
+
// free either.
|
|
6
16
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
17
|
+
// The variation earned its place on kleod's kl-eod-decomp rows: returning `null` from `hoistScopedBases`
|
|
18
|
+
// cost kleod:UpdateHUDCounterDisplay its match. That row was retired on 2026-09-13 with its source,
|
|
19
|
+
// so no current benchmark row is known to guard this file; re-measure by ablation on the current
|
|
20
|
+
// rows before naming one.
|
|
9
21
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
22
|
+
// `l3/basecse.ts` already hoists a reused leaf base — at three positions now, of which two are in
|
|
23
|
+
// the TOP-LEVEL statement list (the function top, or an init's first use where a roster row asks
|
|
24
|
+
// `l3/hoist.ts` for that) — and only for an `addr`/`const` base. What is left to this file is one
|
|
25
|
+
// half of the placement question and the whole of eligibility:
|
|
26
|
+
//
|
|
27
|
+
// PLACEMENT — NARROWED, NOT OWNED. A base used only inside one `if` arm is live across everything
|
|
28
|
+
// before that arm under either of basecse's FLAT positions — a live range the original never had,
|
|
29
|
+
// which is the register-pressure failure basecse's own loop gate exists for. First-use placement
|
|
30
|
+
// narrows that range and does not close it: the init still lands ABOVE the `if`. `l3/hoist.ts`'s
|
|
31
|
+
// third placement, `scope`, now does close it for the run basecse places, so "into a nested list"
|
|
32
|
+
// is no longer this file's alone; what stays here is the base this file can SEE (below) and the
|
|
33
|
+
// COUNT question (`REGION_RULES`), which no placement answers. That argument is why the variation is
|
|
34
|
+
// scope-aware; it is NOT a claim about what the variation achieves, and no committed measurement
|
|
35
|
+
// separates basecse's two flat placements (the one that did edited a reference source by hand and
|
|
36
|
+
// cannot be re-run). On kleod:UpdateHUDCounterDisplay the primary path declines outright (a later
|
|
37
|
+
// pass retired the phi it keyed on, so the base's uses span the function body), and the cluster
|
|
38
|
+
// fallback below is what recovers it. basecse's header names a LOOP-BODY base as left inline for
|
|
39
|
+
// a future scope-aware hoist, and this is that hoist: `scope` cannot serve one, because every
|
|
40
|
+
// gate table paired with it keeps the `loop` rule that refuses the base outright.
|
|
20
41
|
//
|
|
21
42
|
// ELIGIBILITY. With a symbol map that states an array's RANK, the access renders as the bare
|
|
22
43
|
// `gSym[0][i]`, whose base node is a `var` naming the global, not an `addr`. basecse's
|
|
@@ -28,39 +49,33 @@
|
|
|
28
49
|
// shape. The decomp author's alternative is a no-op read-modify-write (`g[0][K] += 0;`) purely to
|
|
29
50
|
// force that materialization; naming the base is the same codegen without the quirk.
|
|
30
51
|
//
|
|
31
|
-
// A
|
|
32
|
-
// differ refereeing, so the un-hoisted spelling is always
|
|
33
|
-
// a match.
|
|
52
|
+
// A VARIATION, not a committed rewrite: both region rules are emitted as ADDITIONAL candidates (rank.ts
|
|
53
|
+
// `/scopebase`, `/regionbase`) with the differ refereeing, so the un-hoisted spelling is always
|
|
54
|
+
// still in the list and neither can cost a match.
|
|
34
55
|
//
|
|
35
56
|
// SEMANTICS ARE PRESERVED BY CONSTRUCTION. The hoisted value is a pure ADDRESS of a global — no
|
|
36
57
|
// load, nothing observable, nothing that can fault — so evaluating it earlier in a scope that
|
|
37
58
|
// DOMINATES every use is invisible. The rewritten accesses keep their own width/signedness, so
|
|
38
|
-
// every stride is unchanged. Domination is the load-bearing half
|
|
39
|
-
//
|
|
40
|
-
// assignment does not reach it
|
|
41
|
-
//
|
|
59
|
+
// every stride is unchanged. Domination is the load-bearing half, and it is CHECKED rather than
|
|
60
|
+
// argued: `assertHoistsDominate` re-walks the emitted tree, because an access repointed at a local
|
|
61
|
+
// whose assignment does not reach it compiles, scores, and can WIN, and no stage-boundary contract
|
|
62
|
+
// sees it (they check resolution, deref typing, and whether a local is written ANYWHERE).
|
|
42
63
|
//
|
|
43
|
-
// ORDERING: `
|
|
44
|
-
// rank's
|
|
45
|
-
//
|
|
46
|
-
// rather than assuming those bases never
|
|
64
|
+
// ORDERING: `hoistBaseLocals` (basecse) runs unconditionally in `structureChecked`, BEFORE
|
|
65
|
+
// rank's variations see the tree. So this pass's `addr`/`const` input is what basecse's DEFAULT table
|
|
66
|
+
// refused — EVERY gate in it, single-use bases as much as loop and repeated-constant-offset ones —
|
|
67
|
+
// which is why `SCOPEBASE_GATES` re-states basecse's rules rather than assuming those bases never
|
|
68
|
+
// arrive.
|
|
69
|
+
import { assertHoistsDominate } from '../contracts';
|
|
47
70
|
import { type IrType, T, scalarTypeForAccess } from '../ir/types';
|
|
48
71
|
import type { Expr, SFn, Stmt } from './ast';
|
|
49
|
-
import { mapExprChildren, stmtExprs } from './ast';
|
|
50
|
-
import {
|
|
72
|
+
import { mapExprChildren, stmtExprs, stmtLists } from './ast';
|
|
73
|
+
import { type Gate, ablateHeuristic, firstRejection } from './gates';
|
|
74
|
+
import { nameAllocator, takenNames } from './hoist';
|
|
75
|
+
import { addressableGlobals } from './storage';
|
|
51
76
|
|
|
52
|
-
/** A base this
|
|
53
|
-
*
|
|
54
|
-
* `var` is included ONLY for a name in `SFn.globals`. That list is populated by `noteGlobal` alone
|
|
55
|
-
* (two call sites in structure.ts, both on the `bareArrayLead` path, which requires
|
|
56
|
-
* `shape === 'array'`) — so a `var` base here is always an ARRAY-declared global and `(T *)&gSym`
|
|
57
|
-
* is its start address under any declaration. The invariant is worth stating because it is what
|
|
58
|
-
* keeps a POINTER-shaped global out: for one of those, `(T *)&gPtr` names the pointer CELL rather
|
|
59
|
-
* than the object it points at, which would be silently the wrong address. A local `var` is
|
|
60
|
-
* excluded for the ordinary reason: it can be assigned between the hoist point and a use. */
|
|
77
|
+
/** A base this variation may name: a leaf whose value is a fixed address. */
|
|
61
78
|
type LeafBase = Extract<Expr, { k: 'addr' } | { k: 'const' } | { k: 'var' }>;
|
|
62
|
-
const isLeaf = (e: Expr, globals: ReadonlySet<string>): e is LeafBase =>
|
|
63
|
-
e.k === 'addr' || e.k === 'const' || (e.k === 'var' && globals.has(e.name));
|
|
64
79
|
|
|
65
80
|
/** THE identity of a base — what makes two accesses "the same address".
|
|
66
81
|
*
|
|
@@ -68,25 +83,75 @@ const isLeaf = (e: Expr, globals: ReadonlySet<string>): e is LeafBase =>
|
|
|
68
83
|
* cell; keying on the NAME alone means a function that mixes them still sees one base. */
|
|
69
84
|
const baseId = (b: LeafBase): string => (b.k === 'const' ? `c:${b.value}` : `n:${b.name}`);
|
|
70
85
|
|
|
71
|
-
/**
|
|
86
|
+
/** One candidate ACCESS, as the eligibility rules see it. */
|
|
87
|
+
export interface AccessCtx {
|
|
88
|
+
readonly base: LeafBase;
|
|
89
|
+
readonly lead: readonly Expr[] | undefined;
|
|
90
|
+
/** the names declared as GLOBALS in this function — a local or param of the same name is absent */
|
|
91
|
+
readonly addressable: ReadonlySet<string>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Which accesses this variation may re-point. BOTH rules are SOUND: each one, removed, makes the
|
|
95
|
+
* rewrite name DIFFERENT BYTES — C that compiles, type-checks and scores, which is the failure
|
|
96
|
+
* mode nothing downstream catches.
|
|
72
97
|
*
|
|
73
|
-
*
|
|
74
|
-
* `g[
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
98
|
+
* `lead` pins the leading subscripts of a multidimensional array, so `g[1][i]` is a whole ROW past
|
|
99
|
+
* `g[0][i]`; the hoisted local points at the START of the object and the rewrite DROPS the lead.
|
|
100
|
+
* A subscript that is not the literal 0 — a recovered row index included — is therefore refused.
|
|
101
|
+
*
|
|
102
|
+
* A `var` base is admitted ONLY for a name in `SFn.globals`. That list is populated by `noteGlobal`
|
|
103
|
+
* alone — three call sites in structure.ts (`declaredSubscripts`' recovered subscripts, and
|
|
104
|
+
* `bareArrayLead`'s rank-pinned form on each of the byte-address and element-index paths) — and
|
|
105
|
+
* the guarantee is not the count but what they SHARE: all three are gated on
|
|
106
|
+
* `structure/globalaccess.ts`'s `bareArrayElement`, which requires `shape === 'array'`. So a `var`
|
|
107
|
+
* base here is always an ARRAY-declared global and `(T *)&gSym` is its start address under any
|
|
108
|
+
* declaration, and a fourth spelling added there inherits that only by going through the same
|
|
109
|
+
* gate. For a POINTER-shaped global `(T *)&gPtr` names the
|
|
110
|
+
* pointer CELL rather than the object it points at; for a LOCAL it names a cell something may
|
|
111
|
+
* assign between the hoist point and a use. */
|
|
112
|
+
export const SCOPEBASE_ELIGIBILITY: readonly Gate<AccessCtx>[] = [
|
|
113
|
+
{
|
|
114
|
+
id: 'nonzero-lead',
|
|
115
|
+
why: 'the rewrite drops `lead`, so a non-zero one would name a different array row',
|
|
116
|
+
sound: true,
|
|
117
|
+
guardedBy: 'scopebase.test.ts: a NON-ZERO lead is refused',
|
|
118
|
+
rejects: (c) => (c.lead ?? []).some((n) => !(n.k === 'const' && n.value === 0)),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'shadowed-or-nonarray-base',
|
|
122
|
+
why: 'taking `&name` of a local or a pointer-shaped global names a different object',
|
|
123
|
+
sound: true,
|
|
124
|
+
guardedBy: 'addr-placement.test.ts: scopebase declines the shadowed name rather than take its address',
|
|
125
|
+
rejects: (c) => c.base.k === 'var' && !c.addressable.has(c.base.name),
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
/** An access this variation may re-point, or null. */
|
|
130
|
+
function eligible(
|
|
131
|
+
e: Expr,
|
|
132
|
+
globals: ReadonlySet<string>,
|
|
133
|
+
rules: readonly Gate<AccessCtx>[],
|
|
134
|
+
): Extract<Expr, { k: 'index' }> | null {
|
|
135
|
+
if (e.k !== 'index') {
|
|
81
136
|
return null;
|
|
82
137
|
}
|
|
83
|
-
|
|
138
|
+
const b = e.base;
|
|
139
|
+
if (b.k !== 'addr' && b.k !== 'const' && b.k !== 'var') {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return firstRejection(rules, { base: b, lead: e.lead, addressable: globals }) === null ? e : null;
|
|
84
143
|
}
|
|
85
144
|
|
|
86
145
|
/** The (base, access-shape) key an access shares with its reuse siblings. Width and signedness are
|
|
87
146
|
* part of it because the hoisted local carries the access's pointer type — two widths through one
|
|
88
147
|
* base are two different locals, exactly as in basecse. */
|
|
89
|
-
const keyOf = (n: Extract<Expr, { k: 'index' }>): string =>
|
|
148
|
+
const keyOf = (n: Extract<Expr, { k: 'index' }>): string => scopedBaseKey(n.base as LeafBase, n.width, n.signed);
|
|
149
|
+
|
|
150
|
+
/** The same key, from a base a DIFFERENT pass is holding. `l3/basecse.ts` spells an `addr` base's
|
|
151
|
+
* identity `a:name` where this one spells it `n:name` (it shares that spelling with the bare `var`
|
|
152
|
+
* the rank-aware lift produces, which denotes the same cell), so a caller crossing between the two
|
|
153
|
+
* translates through this rather than comparing strings that can never match. */
|
|
154
|
+
export const scopedBaseKey = (b: LeafBase, width: number, signed: boolean): string => `${baseId(b)} ${width} ${signed}`;
|
|
90
155
|
|
|
91
156
|
/** One use, located by its chain of enclosing statement LISTS (outermost first).
|
|
92
157
|
*
|
|
@@ -96,42 +161,64 @@ const keyOf = (n: Extract<Expr, { k: 'index' }>): string => `${baseId(n.base as
|
|
|
96
161
|
interface Site {
|
|
97
162
|
path: Stmt[][];
|
|
98
163
|
loop: boolean[];
|
|
99
|
-
/**
|
|
100
|
-
* the
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
164
|
+
/** The chain of statement indices leading to this use, read through `indexWithin` below — which
|
|
165
|
+
* owns the off-by-one against `path`. Used to place the hoist immediately before the FIRST
|
|
166
|
+
* statement that needs it rather than at the list head: a call between the assignment and the
|
|
167
|
+
* first use is exactly what forces the pointer into a CALLEE-SAVED register and adds the
|
|
168
|
+
* prologue push/pop the original avoided — the same failure, one level smaller, that this module
|
|
169
|
+
* exists to fix. argbase.ts places by the same rule. */
|
|
104
170
|
idx: number[];
|
|
105
171
|
/** the use runs EVERY ITERATION of a loop whose body is not on `path` — a loop's own condition,
|
|
106
172
|
* or a `for`'s increment. No scope reachable from `path` runs at that cadence, so a key with any
|
|
107
173
|
* such use is refused outright rather than hoisted to a point that runs once. */
|
|
108
174
|
perIteration: boolean;
|
|
175
|
+
/** the access node itself — what the rewrite repoints, so `collect` and the rewrite cannot
|
|
176
|
+
* disagree about which uses a plan entry owns */
|
|
177
|
+
node: Extract<Expr, { k: 'index' }>;
|
|
109
178
|
}
|
|
110
179
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
|
|
180
|
+
/** WHERE, within the region statement list at `depth`, the statement holding this use sits.
|
|
181
|
+
*
|
|
182
|
+
* THE OFF-BY-ONE HAS ONE HOME AND THIS IS IT. `collect` starts `path` EMPTY and pushes a statement
|
|
183
|
+
* index for every list it descends, so `idx` carries one entry MORE than `path`: `idx[j + 1]` is
|
|
184
|
+
* the index within `path[j]`. A region is `path[depth - 1]`, so its own index is `idx[depth]` —
|
|
185
|
+
* and `idx[0]` for the depth-0 body region `perRegions` synthesizes, which is why `path` must NOT
|
|
186
|
+
* be seeded with `sfn.body`: seeding it would shift this read for every key in every function. */
|
|
187
|
+
const indexWithin = (u: Site, depth: number): number => u.idx[depth];
|
|
188
|
+
|
|
189
|
+
/** Everything one `collect` run carries that the descent does not change. Bundled rather than
|
|
190
|
+
* spelled as five more positional parameters, and the bundling is what makes the walk RE-ENTRANT:
|
|
191
|
+
* every field, `compound` included, is built per call, so nothing one run decides can reach the
|
|
192
|
+
* next. A refusal held in module scope would instead need a reset the caller cannot forget. */
|
|
193
|
+
interface CollectState {
|
|
194
|
+
readonly globals: ReadonlySet<string>;
|
|
195
|
+
/** key → its uses and the access the local's type is taken from, in first-appearance order */
|
|
196
|
+
readonly out: Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }> }>;
|
|
197
|
+
readonly rules: readonly Gate<AccessCtx>[];
|
|
198
|
+
/** every eligible node already visited — meeting one twice is one object at two tree positions */
|
|
199
|
+
readonly seenNodes: Set<Expr>;
|
|
200
|
+
/** the KEYS that sharing refuses, filled here and read in `planScopedBases`, where the argument
|
|
201
|
+
* for refusing per KEY rather than per function is written out */
|
|
202
|
+
readonly sharedKeys: Set<string>;
|
|
203
|
+
/** the tree holds a shape `collect` and `rewriteStmt` would disagree about — see the `for`-part
|
|
204
|
+
* note below. The pass then declines outright. */
|
|
205
|
+
compound: boolean;
|
|
206
|
+
}
|
|
114
207
|
|
|
115
208
|
/** Walk every expression in the tree, recording each eligible access's key and its scope path. */
|
|
116
|
-
function collect(
|
|
117
|
-
body: Stmt[],
|
|
118
|
-
globals: ReadonlySet<string>,
|
|
119
|
-
out: Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }>; constOff: Map<number, number> }>,
|
|
120
|
-
path: Stmt[][],
|
|
121
|
-
loop: boolean[],
|
|
122
|
-
idxPath: number[],
|
|
123
|
-
): void {
|
|
209
|
+
function collect(body: Stmt[], path: Stmt[][], loop: boolean[], idxPath: number[], st: CollectState): void {
|
|
124
210
|
let at = 0;
|
|
125
211
|
const visit = (e: Expr, perIteration: boolean): void => {
|
|
126
|
-
const ix = eligible(e, globals);
|
|
212
|
+
const ix = eligible(e, st.globals, st.rules);
|
|
127
213
|
if (ix) {
|
|
128
214
|
const k = keyOf(ix);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (ix.idx.k === 'const') {
|
|
132
|
-
rec.constOff.set(ix.idx.value, (rec.constOff.get(ix.idx.value) ?? 0) + 1);
|
|
215
|
+
if (st.seenNodes.has(ix)) {
|
|
216
|
+
st.sharedKeys.add(k);
|
|
133
217
|
}
|
|
134
|
-
|
|
218
|
+
st.seenNodes.add(ix);
|
|
219
|
+
const rec = st.out.get(k) ?? { uses: [], sample: ix };
|
|
220
|
+
rec.uses.push({ path, loop, perIteration, idx: [...idxPath, at], node: ix });
|
|
221
|
+
st.out.set(k, rec);
|
|
135
222
|
}
|
|
136
223
|
mapExprChildren(e, (c) => {
|
|
137
224
|
visit(c, perIteration);
|
|
@@ -158,8 +245,8 @@ function collect(
|
|
|
158
245
|
// rather than grow a second recursion this REFUSES the whole function — loud decline over a
|
|
159
246
|
// silently unreachable definition. Delete this when `stmtLists` makes collect/rewrite share
|
|
160
247
|
// one traversal.
|
|
161
|
-
if (
|
|
162
|
-
compound = true;
|
|
248
|
+
if (stmtLists(s.init).length > 0 || stmtLists(s.inc).length > 0) {
|
|
249
|
+
st.compound = true;
|
|
163
250
|
}
|
|
164
251
|
// `init` and `inc` are STATEMENTS, so their expressions are reached by neither `stmtExprs`
|
|
165
252
|
// nor `childLists` — yet `rewriteStmt` rewrites them. Collect and rewrite MUST see the same
|
|
@@ -169,46 +256,31 @@ function collect(
|
|
|
169
256
|
stmtExprs(s.init).forEach((e) => visit(e, false));
|
|
170
257
|
stmtExprs(s.inc).forEach((e) => visit(e, true));
|
|
171
258
|
}
|
|
172
|
-
for (const child of
|
|
173
|
-
collect(child,
|
|
259
|
+
for (const child of stmtLists(s)) {
|
|
260
|
+
collect(child, [...path, child], [...loop, isLoop], [...idxPath, i], st);
|
|
174
261
|
}
|
|
175
262
|
}
|
|
176
263
|
}
|
|
177
264
|
|
|
178
|
-
/** The nested statement LISTS of a statement — the scopes a hoist could land in.
|
|
179
|
-
*
|
|
180
|
-
* Deliberately not `stmtChildren`, which flattens a `for`'s `init`/`inc` in with its body: those
|
|
181
|
-
* are single statements, not lists, and a hoist has nowhere legal to go in either (before the loop
|
|
182
|
-
* changes when it runs, inside the body repeats it). A `for`'s body IS a list and is included. */
|
|
183
|
-
function childLists(s: Stmt): Stmt[][] {
|
|
184
|
-
switch (s.k) {
|
|
185
|
-
case 'if':
|
|
186
|
-
return [s.then, s.else];
|
|
187
|
-
case 'while':
|
|
188
|
-
case 'dowhile':
|
|
189
|
-
case 'for':
|
|
190
|
-
return [s.body];
|
|
191
|
-
case 'switch':
|
|
192
|
-
return [...s.cases.map((c) => c.body), ...(s.default ? [s.default] : [])];
|
|
193
|
-
// Exhaustive on purpose — no `default`. A future Stmt kind carrying a nested list must be a
|
|
194
|
-
// COMPILE error here, exactly as it is in `stmtChildren`: a silent `[]` would collect that
|
|
195
|
-
// kind's uses at the wrong scope while `rewriteStmt`, which IS exhaustive, still rewrote them.
|
|
196
|
-
case 'assign':
|
|
197
|
-
case 'store':
|
|
198
|
-
case 'exprstmt':
|
|
199
|
-
case 'return':
|
|
200
|
-
case 'break':
|
|
201
|
-
case 'continue':
|
|
202
|
-
return [];
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
265
|
/** The innermost statement list common to every use, or null when they span the function body.
|
|
207
266
|
*
|
|
208
267
|
* Null is NOT a decline any more: the caller falls through to `deepestCluster`. Kept as a distinct
|
|
209
268
|
* answer because "one scope holds everything" is the better shape when it exists — every use is
|
|
210
269
|
* named, not just a cluster. The consolidation this file still owes would make both of these one
|
|
211
|
-
* selector parameter over a single collected index.
|
|
270
|
+
* selector parameter over a single collected index.
|
|
271
|
+
*
|
|
272
|
+
* A THIRD ANSWER TO THE SAME QUESTION now exists and is booked here rather than left for a reader
|
|
273
|
+
* to collide with: `l3/hoist.ts`'s `scopeSite` finds the innermost list holding every MENTION of a
|
|
274
|
+
* minted local, top-down, with no cluster fallback. The two are not merged, and the reason is the
|
|
275
|
+
* domain rather than the algorithm — this one partitions the ACCESSES of a base key it is about to
|
|
276
|
+
* repoint, that one places a statement whose local already exists, so a shared implementation
|
|
277
|
+
* would take the collected index this file owes anyway. THE ONE DIVERGENCE TO CARRY INTO THAT
|
|
278
|
+
* EXTRACTION is the `for` reading `collect` records above: this pass counts a `for`'s `init` at
|
|
279
|
+
* the enclosing cadence, `l3/basecse.ts`'s own census counts it in-loop, and only those two
|
|
280
|
+
* readings are pinned (test/addr-placement.test.ts). `scopeSite` is a THIRD reader of the same
|
|
281
|
+
* position and agrees with this pass — `init`/`inc` are statements no list holds, so a mention in
|
|
282
|
+
* either stops the descent (test/sinkinit.test.ts) — but by its own route, and nothing checks that
|
|
283
|
+
* the two keep agreeing. */
|
|
212
284
|
function commonScope(uses: Site[]): { scope: Stmt[]; depth: number } | null {
|
|
213
285
|
const first = uses[0].path;
|
|
214
286
|
let depth = 0;
|
|
@@ -218,13 +290,20 @@ function commonScope(uses: Site[]): { scope: Stmt[]; depth: number } | null {
|
|
|
218
290
|
return depth === 0 ? null : { scope: first[depth - 1], depth };
|
|
219
291
|
}
|
|
220
292
|
|
|
221
|
-
/**
|
|
222
|
-
* scope holds them all.
|
|
293
|
+
/** `'whole'`'s fallback: the DEEPEST statement list holding 2+ uses, with just those uses.
|
|
223
294
|
*
|
|
224
295
|
* Ties are broken by first appearance, so emission stays deterministic. Returning a SUBSET is the
|
|
225
296
|
* whole point: the uses outside the cluster keep their original spelling, which is exactly the
|
|
226
297
|
* mixed form the compiler produces when it materializes an address in one arm and re-derives it
|
|
227
|
-
* elsewhere.
|
|
298
|
+
* elsewhere.
|
|
299
|
+
*
|
|
300
|
+
* DEEPEST with no size term, and that is a limitation rather than a model of the compiler: a scope
|
|
301
|
+
* with four uses enclosing a nested scope with two names the TWO and leaves the four re-deriving
|
|
302
|
+
* (pinned in test/scopebase.test.ts). Only ONE cluster is ever served here — serving all of them
|
|
303
|
+
* is what `'per-region'` does, under its own admission table.
|
|
304
|
+
*
|
|
305
|
+
* Its uses are RESIDUAL — a list's entry holds every use beneath it, not just its direct ones —
|
|
306
|
+
* which is why `regionsOf` builds its partition itself rather than reusing this. */
|
|
228
307
|
function deepestCluster(all: Site[]): { scope: Stmt[]; depth: number; uses: Site[] } | null {
|
|
229
308
|
const byList = new Map<Stmt[], { depth: number; uses: Site[] }>();
|
|
230
309
|
for (const u of all) {
|
|
@@ -243,117 +322,481 @@ function deepestCluster(all: Site[]): { scope: Stmt[]; depth: number; uses: Site
|
|
|
243
322
|
return best;
|
|
244
323
|
}
|
|
245
324
|
|
|
246
|
-
/**
|
|
325
|
+
/** How a key's uses are cut into REGIONS — the one dimension this pass varies. Names a `REGION_RULES`
|
|
326
|
+
* entry; it is the only field a production caller passes. */
|
|
327
|
+
export type RegionSelector = 'whole' | 'per-region';
|
|
328
|
+
|
|
329
|
+
interface Region {
|
|
330
|
+
scope: Stmt[];
|
|
331
|
+
depth: number;
|
|
332
|
+
uses: Site[];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** `'whole'`'s partition: ONE region for the key — the innermost list holding every use, else the
|
|
336
|
+
* deepest cluster of two. `body` is unused; the signature is the RULE's, so both partitions are
|
|
337
|
+
* one type and the selector can be a value. */
|
|
338
|
+
function wholeRegion(all: Site[], _body: Stmt[]): Region[] {
|
|
339
|
+
const at = commonScope(all);
|
|
340
|
+
if (at) {
|
|
341
|
+
return [{ scope: at.scope, depth: at.depth, uses: all }];
|
|
342
|
+
}
|
|
343
|
+
const cluster = deepestCluster(all);
|
|
344
|
+
return cluster ? [cluster] : [];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** `'per-region'`'s partition: one region per INNERMOST ENCLOSING LIST, and every partition is
|
|
348
|
+
* served. DIRECT uses only, and the FUNCTION BODY is a region like any other.
|
|
247
349
|
*
|
|
248
|
-
*
|
|
249
|
-
* `
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
function
|
|
257
|
-
|
|
350
|
+
* Not the residual-subtree rule (a list's entry holding every use beneath it, which is what
|
|
351
|
+
* `deepestCluster` builds): under that rule the body region holds the arms' uses too, and the
|
|
352
|
+
* loop and offset rules then judge a region on uses that are not in it. Not an ANTICHAIN of
|
|
353
|
+
* scope-disjoint regions either — the body list encloses both arms and is served anyway. What
|
|
354
|
+
* separates a region from the ones nested in it is only which uses are direct.
|
|
355
|
+
*
|
|
356
|
+
* The synthetic depth-0 entry is why `collect`'s `path` is NOT seeded with `sfn.body` —
|
|
357
|
+
* `indexWithin` owns that invariant and states what re-seeding would cost. */
|
|
358
|
+
function perRegions(all: Site[], body: Stmt[]): Region[] {
|
|
359
|
+
const byList = new Map<Stmt[], Region>();
|
|
360
|
+
for (const u of all) {
|
|
361
|
+
const depth = u.path.length;
|
|
362
|
+
const scope = depth === 0 ? body : u.path[depth - 1];
|
|
363
|
+
const e = byList.get(scope) ?? { scope, depth, uses: [] };
|
|
364
|
+
e.uses.push(u);
|
|
365
|
+
byList.set(scope, e);
|
|
366
|
+
}
|
|
367
|
+
// insertion order — first appearance of each region, so emission stays deterministic
|
|
368
|
+
return [...byList.values()];
|
|
258
369
|
}
|
|
259
370
|
|
|
260
|
-
/**
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
371
|
+
/** Is some literal offset reached twice among these uses? Tallied from the SITES, so the answer is
|
|
372
|
+
* scoped by whichever set the caller judges — the key's, or one region's. */
|
|
373
|
+
function repeatsAConstOffset(uses: Site[]): boolean {
|
|
374
|
+
const seen = new Set<number>();
|
|
375
|
+
for (const u of uses) {
|
|
376
|
+
const i = u.node.idx;
|
|
377
|
+
if (i.k === 'const') {
|
|
378
|
+
if (seen.has(i.value)) {
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
seen.add(i.value);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The two loop facts, split apart because they are two different rules with two different
|
|
388
|
+
* arguments — see the gate table.
|
|
389
|
+
*
|
|
390
|
+
* `perIteration` OVER-REFUSES in two shapes, deliberately: a `do { … } while (g[1]) ;` body head
|
|
391
|
+
* and a `for (…; …; i = g[5])` body head both DO run at the flagged cadence, so a hoist there
|
|
392
|
+
* would be legal. Refusing them costs a missed spelling and nothing else (bench: 0 lost, 0
|
|
393
|
+
* gained), and the precise rule needs a loop-DEPTH model. When EVERY use is inside the loop the
|
|
394
|
+
* scope IS the loop body: the assignment then runs per iteration exactly as the inline spelling
|
|
395
|
+
* did, and `nestedLoop` is false — there is nothing to refuse. */
|
|
396
|
+
const runsPerIteration = (uses: Site[]): boolean => uses.some((u) => u.perIteration);
|
|
397
|
+
const underNestedLoop = (uses: Site[], depth: number): boolean => uses.some((u) => u.loop.slice(depth).some(Boolean));
|
|
398
|
+
|
|
399
|
+
/** One candidate REGION, as the admission rules see it. */
|
|
400
|
+
export interface RegionCtx {
|
|
401
|
+
/** how many uses the local would serve */
|
|
402
|
+
readonly uses: number;
|
|
403
|
+
/** some constant offset is reached twice through this base */
|
|
404
|
+
readonly repeatedConstOffset: boolean;
|
|
405
|
+
/** some use runs at a cadence no reachable scope has — a loop's own condition, a `for`'s inc */
|
|
406
|
+
readonly perIteration: boolean;
|
|
407
|
+
/** some use sits inside a loop nested BELOW the region */
|
|
408
|
+
readonly nestedLoop: boolean;
|
|
409
|
+
/** how many of this key's regions hold two or more direct uses */
|
|
410
|
+
readonly siblingRegions: number;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The rules judged over a POPULATION OF USES — the half the region rule re-reads. Split out
|
|
414
|
+
* because that is what `perRegionReading` below renames, so a fourth counting rule is renamed by
|
|
415
|
+
* construction instead of by remembering to extend a list of ids. */
|
|
416
|
+
const COUNTING_RULES: readonly Gate<RegionCtx>[] = [
|
|
417
|
+
{
|
|
418
|
+
id: 'single-use',
|
|
419
|
+
why: 'a base accessed once is as cheap to load again as to hold in a named local',
|
|
420
|
+
sound: false,
|
|
421
|
+
rejects: (c) => c.uses < 2,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
id: 'repeated-const-offset',
|
|
425
|
+
why: 'a fixed offset read and then written is one scalar update, and the compiler loads its address again for it',
|
|
426
|
+
sound: false,
|
|
427
|
+
rejects: (c) => c.repeatedConstOffset,
|
|
428
|
+
},
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
/** The rules judged over the REGION's own loop facts, which every region rule reads the same way
|
|
432
|
+
* (`RegionRule.judged` says why: no reachable scope answers for a use outside the region). */
|
|
433
|
+
const LOOP_RULES: readonly Gate<RegionCtx>[] = [
|
|
434
|
+
{
|
|
435
|
+
id: 'per-iteration-use',
|
|
436
|
+
why: 'no scope reachable from the use runs as often as a loop condition or a `for` increment',
|
|
437
|
+
sound: false,
|
|
438
|
+
rejects: (c) => c.perIteration,
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
id: 'nested-loop-use',
|
|
442
|
+
why: 'hoisting out of a nested loop is code motion to a point the original never had',
|
|
443
|
+
sound: false,
|
|
444
|
+
rejects: (c) => c.nestedLoop,
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
/** The admission rules. NONE is sound: a wrong decision here names the same address in a different
|
|
449
|
+
* place, so it costs bytes and a match, never meaning — the eligibility table above is where
|
|
450
|
+
* meaning is at stake, and `rank.ts` keeps the un-hoisted spelling beside every candidate.
|
|
451
|
+
*
|
|
452
|
+
* `repeated-const-offset` and `nested-loop-use` are inherited from `BASECSE_GATES`, which learned
|
|
453
|
+
* them by losing the ProcessHBlankWait match and by forcing a callee-saved register across a loop.
|
|
454
|
+
* `per-iteration-use` is the half of the loop question basecse never faces: this pass places into
|
|
455
|
+
* a NESTED list, and no reachable list runs at a loop condition's cadence.
|
|
456
|
+
*
|
|
457
|
+
* `repeated-const-offset` is an EXTRAPOLATION on half this pass's input, and honestly so: the
|
|
458
|
+
* evidence is a `const` MMIO address, and the `var` (array-global) half is input basecse never
|
|
459
|
+
* saw. It also SLIPS on a fixed offset not spelled as a literal — two identical `g[i]` accesses
|
|
460
|
+
* are not tallied. rank's `/livebase` takes the OPPOSITE side, ablating the same rule in
|
|
461
|
+
* `LIVEBASE_GATES` for the poll shapes it mispredicts, and the differ arbitrates.
|
|
462
|
+
*
|
|
463
|
+
* MEASURED REACH, so a reader prices these from evidence rather than from the table's existence.
|
|
464
|
+
* Instrumented census over the klonoa corpus with no symbol map (469 `.s`, 257 lifting functions),
|
|
465
|
+
* every rule evaluated on every admission context the pass builds. `any` counts the contexts a
|
|
466
|
+
* rule rejects; `first` the ones where it is the DECIDING rejection, which is the only column that
|
|
467
|
+
* prices it — `firstRejection` short-circuits, so a rule that is never first changes no decision.
|
|
468
|
+
* `relaxed` re-asks `first` with the counting rules dropped, which is what separates a rule that
|
|
469
|
+
* decides nothing from one MASKED by the two that precede it:
|
|
470
|
+
*
|
|
471
|
+
* table contexts rule any first relaxed
|
|
472
|
+
* SB 79880 single-use 24268 24268 —
|
|
473
|
+
* repeated-const-offset 54120 54120 —
|
|
474
|
+
* per-iteration-use 8784 0 8784
|
|
475
|
+
* nested-loop-use 6560 656 4768
|
|
476
|
+
* RB 512760 region-single-use 393153 393153 —
|
|
477
|
+
* region-repeated-const-offset 94857 94857 —
|
|
478
|
+
* per-iteration-use 32586 0 32586
|
|
479
|
+
* regions-degenerate 353352 846 331530
|
|
480
|
+
*
|
|
481
|
+
* So `per-iteration-use` decides nothing on this corpus in either table, and ablating it moves no
|
|
482
|
+
* gating row. It is kept, and the `relaxed` column is why: every context it rejects it would
|
|
483
|
+
* decide, the moment a counting rule stopped rejecting first. Dropping a masked rule is a change
|
|
484
|
+
* one corpus licenses; `nested-loop-use` left the region table on a proof that it CANNOT fire
|
|
485
|
+
* there, which is a different standard and the one this file holds.
|
|
486
|
+
*
|
|
487
|
+
* A SECOND POPULATION, AT A DIFFERENT GRAIN, agrees on the masked rule. Over the artifact's 404
|
|
488
|
+
* agbcc rows — `decompile()`'s default structuring, map-LESS, one tree per row — read per KEY
|
|
489
|
+
* through `planScopedBases(...).refusals` rather than per admission context: 201 keys reach the
|
|
490
|
+
* tables. Under `'whole'` the deciding refusal is `repeated-const-offset` on 57 keys and
|
|
491
|
+
* `single-use` on 52 (92 keys served); under `'per-region'` it is `region-single-use` on 133,
|
|
492
|
+
* `region-repeated-const-offset` on 50 and `regions-degenerate` on 8 (10 served).
|
|
493
|
+
* `per-iteration-use` is the deciding refusal for ZERO keys in either table, and `nested-loop-use`
|
|
494
|
+
* for zero of the `'whole'` ones.
|
|
495
|
+
*
|
|
496
|
+
* BOTH CENSUSES ARE MAP-LESS, and neither says anything about the arm the real tier runs on: under
|
|
497
|
+
* a symbol map an absolute pool constant lifts to a `gaddr`, so the base population this pass is
|
|
498
|
+
* handed is a different one. */
|
|
499
|
+
export const SCOPEBASE_GATES: readonly Gate<RegionCtx>[] = [...COUNTING_RULES, ...LOOP_RULES];
|
|
500
|
+
|
|
501
|
+
/** A counting rule's PER-REGION reading. Same predicate, different POPULATION — under `'whole'` it
|
|
502
|
+
* is judged over the KEY's uses (the cluster fallback serves a SUBSET of them, so the two really
|
|
503
|
+
* do differ), under `'per-region'` over one region's direct uses. One id naming two predicates
|
|
504
|
+
* makes `without(table, id)` two different ablations and a price table ambiguous about which
|
|
505
|
+
* reading it priced, so the per-region reading gets its own id. */
|
|
506
|
+
const perRegionReading = (g: Gate<RegionCtx>): Gate<RegionCtx> => ({
|
|
507
|
+
...g,
|
|
508
|
+
id: `region-${g.id}`,
|
|
509
|
+
why: `counted over one region’s own uses: ${g.why}`,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
/** `/regionbase`'s admission (rank.ts): the per-region readings of `SCOPEBASE_GATES`, MINUS the one
|
|
513
|
+
* rule the region rule makes vacuous, plus the one that exists only once a key can hold MORE THAN
|
|
514
|
+
* ONE local.
|
|
515
|
+
*
|
|
516
|
+
* `nested-loop-use` CANNOT FIRE under `'per-region'` and is dropped rather than left in the table
|
|
517
|
+
* reading as safety. `perRegions` sets a region's `depth` to `u.path.length`, and a region is
|
|
518
|
+
* exactly the uses whose innermost enclosing list IS that region — so `u.loop.slice(depth)` is
|
|
519
|
+
* the empty slice for every use it judges, and "a use under a loop BELOW the region" is a shape
|
|
520
|
+
* the partition cannot produce. A use inside a nested loop is its own region, at its own depth.
|
|
521
|
+
* Measured on the PREDICATE, not on table membership, since a rule the table no longer holds
|
|
522
|
+
* cannot be censused through it: over the corpus census above, `underNestedLoop` is true on 0 of
|
|
523
|
+
* 512760 `'per-region'` contexts and on 6560 of 79880 `'whole'` ones, where the rule stays in
|
|
524
|
+
* `SCOPEBASE_GATES` and is load-bearing.
|
|
525
|
+
*
|
|
526
|
+
* `regions-degenerate` is a FAN SAVING, not a codegen model: it counts regions holding two or
|
|
527
|
+
* more DIRECT uses, which is `single-use` applied region-wise and so is computable before any
|
|
528
|
+
* gate runs. Its saving is USUALLY a duplicate — one such region is the function-top question
|
|
529
|
+
* `basecse`/`/livebase`/`/scopebase` already answer — but not always, and this pass produces the
|
|
530
|
+
* counterexample: one arm with three direct uses plus a nested loop holding a fourth is refused
|
|
531
|
+
* under `'whole'` by `nested-loop-use` and here by this rule, and ablating it alone is the only
|
|
532
|
+
* way to reach the hoist (test/regionbase.test.ts). A heuristic, and the differ referees what it
|
|
533
|
+
* admits.
|
|
534
|
+
*
|
|
535
|
+
* ONE RULE HERE IS PRICED BY A ROW; three are not. Ablating `region-single-use` moves
|
|
536
|
+
* `synthetic:dmascope` — the variation's own row — while `region-repeated-const-offset`,
|
|
537
|
+
* `per-iteration-use` and `regions-degenerate` each leave all five gating rows exactly where they
|
|
538
|
+
* stand. NO SCORE PAIR IS QUOTED for that move: `dmascope` is MATCH in the committed artifact, so
|
|
539
|
+
* a pair whose unablated endpoint is a nonmatch score describes a corpus state that no longer
|
|
540
|
+
* exists. Re-run the ablation before writing one back. The OVER-SCOPING controls
|
|
541
|
+
* (`synthetic:dmascope1`,
|
|
542
|
+
* `synthetic:offhi_fused`) must stay MATCH but can price nothing here: censused on their own
|
|
543
|
+
* disassembly, `dmascope1` enumerates 6 candidates with 0 carrying `/regionbase` and
|
|
544
|
+
* `offhi_fused` 12 with 0. The other three are guarded by unit fixtures in test/regionbase.test.ts
|
|
545
|
+
* and by the reach census above. */
|
|
546
|
+
export const REGIONBASE_GATES: readonly Gate<RegionCtx>[] = [
|
|
547
|
+
...COUNTING_RULES.map(perRegionReading),
|
|
548
|
+
...ablateHeuristic(LOOP_RULES, 'nested-loop-use'),
|
|
549
|
+
{
|
|
550
|
+
id: 'regions-degenerate',
|
|
551
|
+
why: 'a single region is the function-top local that the default hoist and `livebase` already offer',
|
|
552
|
+
sound: false,
|
|
553
|
+
rejects: (c) => c.siblingRegions < 2,
|
|
554
|
+
},
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
/** THE REGION RULE, as a value. A third rule is one entry here — a partition, a gate table, and
|
|
558
|
+
* the population its counting rules are judged over — rather than three hand-edited branches in
|
|
559
|
+
* three functions, which is the same doctrine `rank-variations.ts` states for its hoist roster ("one entry
|
|
560
|
+
* here, one gate table, and that table's line in the gate-contract roster — not nine hand-edited
|
|
561
|
+
* sites that can drift"). */
|
|
562
|
+
export interface RegionRule {
|
|
563
|
+
readonly id: RegionSelector;
|
|
564
|
+
/** how a key's uses are cut into regions */
|
|
565
|
+
readonly partition: (all: Site[], body: Stmt[]) => Region[];
|
|
566
|
+
/** the admission table `hoistScopedBases` uses when no ablation is passed */
|
|
567
|
+
readonly gates: readonly Gate<RegionCtx>[];
|
|
568
|
+
/** the uses the COUNTING rules (`uses`, `repeatedConstOffset`) are tallied over. The loop facts
|
|
569
|
+
* are always the region's own — no reachable scope answers for a use outside it. */
|
|
570
|
+
readonly judged: (region: Region, key: Site[]) => Site[];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export const REGION_RULES: Record<RegionSelector, RegionRule> = {
|
|
574
|
+
// `'whole'` judges the count and the offset tally over the KEY and the loop facts over the
|
|
575
|
+
// chosen region — the scoping this pass has always used, and the cluster case is why: its region
|
|
576
|
+
// is a SUBSET of the key's uses.
|
|
577
|
+
whole: { id: 'whole', partition: wholeRegion, gates: SCOPEBASE_GATES, judged: (_r, key) => key },
|
|
578
|
+
// `'per-region'` judges every rule over the region's own direct uses — a REFINEMENT and not a
|
|
579
|
+
// relaxation: an offset repeated INSIDE one region is still repeated.
|
|
580
|
+
'per-region': { id: 'per-region', partition: perRegions, gates: REGIONBASE_GATES, judged: (r) => r.uses },
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/** `regions` picks the region rule and is the only field a caller passes in production
|
|
584
|
+
* (`'per-region'` is `/regionbase`). The other three are for ABLATION and INJECTION — the
|
|
585
|
+
* differentials in test/scopebase.test.ts re-run the real pass with one rule dropped, and the
|
|
586
|
+
* ownership contract below is shown load-bearing by a `rule` no production caller passes. All
|
|
587
|
+
* three default to what the selector implies. */
|
|
588
|
+
export interface ScopeBaseOpts {
|
|
589
|
+
readonly regions?: RegionSelector;
|
|
590
|
+
readonly eligibility?: readonly Gate<AccessCtx>[];
|
|
591
|
+
readonly gates?: readonly Gate<RegionCtx>[];
|
|
592
|
+
readonly rule?: RegionRule;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** One admitted region, as the applier and a gating caller both read it. `uses` are the ACCESS
|
|
596
|
+
* NODES the entry owns — the same set the counting rules were judged over, so the planner and the
|
|
597
|
+
* rewrite cannot disagree about them. */
|
|
598
|
+
export interface ScopedBaseEntry {
|
|
599
|
+
readonly scope: Stmt[];
|
|
600
|
+
readonly key: string;
|
|
601
|
+
readonly name: string;
|
|
602
|
+
readonly type: IrType;
|
|
603
|
+
readonly base: LeafBase;
|
|
604
|
+
/** index within `scope` the init is spliced at — the first statement that (transitively) uses it */
|
|
605
|
+
readonly before: number;
|
|
606
|
+
readonly uses: readonly Expr[];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** What the pass DECIDED, before anything is applied. `applyScopedBasePlan` is the other half: a
|
|
610
|
+
* caller that has to both COUNT what a key got and rewrite the tree (l3/homesplit.ts) plans once
|
|
611
|
+
* and applies that same plan, rather than re-deriving the decision from the applied tree. */
|
|
612
|
+
export interface ScopedBasePlan {
|
|
613
|
+
/** every eligible key `collect` found, in first-appearance order */
|
|
614
|
+
readonly keys: readonly string[];
|
|
615
|
+
readonly entries: readonly ScopedBaseEntry[];
|
|
616
|
+
/** access node → the local that replaces its base */
|
|
617
|
+
readonly repoint: ReadonlyMap<Expr, string>;
|
|
618
|
+
/** for a key NO region admitted, the id of the rule that refused it first — `'shared-node'` for
|
|
619
|
+
* the pre-gate refusal `sharedKeys` makes. A key with an entry is absent. */
|
|
620
|
+
readonly refusals: ReadonlyMap<string, string>;
|
|
621
|
+
/** the tree holds the `for`-part shape collect and rewrite disagree about: the pass declines */
|
|
622
|
+
readonly compound: boolean;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** THE OWNERSHIP CONTRACT, and it is deliberately not a `Gate`: it is a property of the whole plan,
|
|
626
|
+
* decided after every `RegionCtx` has been judged, so there is no admission context to attach it
|
|
627
|
+
* to — the same reason `sharedKeys` and `compound` are not gates either.
|
|
628
|
+
*
|
|
629
|
+
* Two properties, one failure each, and NEITHER is a compile error downstream:
|
|
630
|
+
*
|
|
631
|
+
* • an access node claimed by two entries. `repoint` is a `Map`, so the second `set` WINS and the
|
|
632
|
+
* access is silently repointed at a local whose assignment need not dominate it — C that
|
|
633
|
+
* compiles, scores, and names a different variable.
|
|
634
|
+
* • a minted name that is not fresh. The applier appends `entries`' names to `sfn.locals`
|
|
635
|
+
* wholesale, so a duplicate emits a duplicate declaration — and freshness is `takenNames`'
|
|
636
|
+
* reading of it, which is what `nameAllocator` mints against: a name that only shadows a
|
|
637
|
+
* PARAMETER, or a body assignment no declaration list carries, is a different variable rather
|
|
638
|
+
* than a duplicate declaration, and that is the failure this contract exists for.
|
|
639
|
+
*
|
|
640
|
+
* Both fire ZERO times under either shipped region rule (the partitions are node-disjoint, and
|
|
641
|
+
* `nameAllocator` re-derives its taken names from the tree it is handed — including across the
|
|
642
|
+
* rank.ts pipe, where the second pass sees the first's mints as taken). They are checks rather
|
|
643
|
+
* than arguments because a future rule, or a merge of two independently-planned runs, breaks
|
|
644
|
+
* either one without breaking a type. */
|
|
645
|
+
export function assertPlanOwnership(
|
|
646
|
+
sfn: SFn,
|
|
647
|
+
entries: readonly { name: string; key: string; uses?: readonly Expr[] }[],
|
|
648
|
+
): void {
|
|
649
|
+
const taken = takenNames(sfn);
|
|
650
|
+
const claimed = new Set<Expr>();
|
|
651
|
+
for (const e of entries) {
|
|
652
|
+
if (taken.has(e.name)) {
|
|
653
|
+
throw new Error(`scopebase: the plan mints \`${e.name}\`, a name the tree already carries (key ${e.key})`);
|
|
654
|
+
}
|
|
655
|
+
taken.add(e.name);
|
|
656
|
+
for (const u of e.uses ?? []) {
|
|
657
|
+
if (claimed.has(u)) {
|
|
658
|
+
throw new Error(`scopebase: an access is claimed by two plan entries (key ${e.key}, local ${e.name})`);
|
|
659
|
+
}
|
|
660
|
+
claimed.add(u);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** What the pass DECIDES for `sfn` under `opts`, with nothing applied. `applyScopedBasePlan` is the
|
|
666
|
+
* applier and `hoistScopedBases` the two of them in order; a caller that gates a PAIRING on "how
|
|
667
|
+
* many locals did this key get?" reads the plan and applies THAT one (l3/homesplit.ts). */
|
|
668
|
+
export function planScopedBases(sfn: SFn, opts: ScopeBaseOpts = {}): ScopedBasePlan {
|
|
669
|
+
const rules = opts.eligibility ?? SCOPEBASE_ELIGIBILITY;
|
|
670
|
+
const rule = opts.rule ?? REGION_RULES[opts.regions ?? 'whole'];
|
|
671
|
+
const gates = opts.gates ?? rule.gates;
|
|
672
|
+
const globals = addressableGlobals(sfn);
|
|
673
|
+
const found = new Map<string, { uses: Site[]; sample: Extract<Expr, { k: 'index' }> }>();
|
|
674
|
+
/** The KEYS whose tree holds one `index` OBJECT at two positions. The rewrite repoints by node
|
|
675
|
+
* identity, so a shared node is one plan entry claiming two uses it need not dominate.
|
|
676
|
+
*
|
|
677
|
+
* PER KEY, not per function, and the difference is not hypothetical. Nothing in the L3 contract
|
|
678
|
+
* forbids the sharing — `l3/pollguard.ts` already emits it (`{ k: 'if', cond: s.cond, then: [s] }`
|
|
679
|
+
* puts one `cond` object at two tree positions), and it is harmless today only because the shapes
|
|
680
|
+
* are derived AFTER this variation in `rank.ts`, an ordering nothing pins. A whole-function decline
|
|
681
|
+
* would make a future producer that shares one node silently delete every base this pass names —
|
|
682
|
+
* including, until that row was retired (2026-09-13), `kleod:UpdateHUDCounterDisplay`'s match,
|
|
683
|
+
* which returning `null` cost. Refusing the
|
|
684
|
+
* key that actually shares costs that key's spelling and nothing else, and the differ still has
|
|
685
|
+
* every other spelling in the list.
|
|
686
|
+
*
|
|
687
|
+
* Not a `Gate`: it is decided during `collect`, before a `RegionCtx` exists. */
|
|
688
|
+
const sharedKeys = new Set<string>();
|
|
689
|
+
const st: CollectState = { globals, out: found, rules, seenNodes: new Set(), sharedKeys, compound: false };
|
|
690
|
+
collect(sfn.body, [], [], [], st);
|
|
691
|
+
if (st.compound) {
|
|
692
|
+
return { keys: [...found.keys()], entries: [], repoint: new Map(), refusals: new Map(), compound: true };
|
|
277
693
|
}
|
|
278
694
|
|
|
279
695
|
const fresh = nameAllocator(sfn);
|
|
280
|
-
//
|
|
281
|
-
const
|
|
696
|
+
// one entry per (key, admitted region) — several for one key under `'per-region'`
|
|
697
|
+
const entries: ScopedBaseEntry[] = [];
|
|
698
|
+
// INSTRUMENTATION, with no production reader — the only thing that reads it is
|
|
699
|
+
// test/regionbase.test.ts's `…and a key it serves nowhere names the DECIDING rule rather than
|
|
700
|
+
// vanishing`. The caller that gates on this pass counts the ENTRIES a key got, and a decline
|
|
701
|
+
// tells it only `not split`. Recorded because the id separates a region that held too few uses
|
|
702
|
+
// from a shape the pass refuses outright, which is what a probe of this pass has to know.
|
|
703
|
+
// `firstRejection` short-circuits, so this is the DECIDING rule.
|
|
704
|
+
const refusals = new Map<string, string>();
|
|
282
705
|
for (const [key, rec] of found) {
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
// A constant offset touched 2+ times is a SCALAR re-access at one fixed location (an MMIO
|
|
287
|
-
// read-modify-write, a repeated `*p`), which the compiler re-materializes rather than
|
|
288
|
-
// register-holds. basecse.ts learned this by LOSING the ProcessHBlankWait match to it. Inherited
|
|
289
|
-
// here rather than re-lost — but honestly: the evidence is a `const` MMIO address, and it
|
|
290
|
-
// applies cleanly only to the `addr`/`const` half of this pass's input, which is exactly what
|
|
291
|
-
// basecse refused and left behind. For the `var` (array-global) half basecse never ran, so this
|
|
292
|
-
// is an EXTRAPOLATION, not an inheritance. Conservative direction, so the cost is a missed
|
|
293
|
-
// hoist rather than a wrong one. It also SLIPS on a fixed offset not spelled as a literal —
|
|
294
|
-
// two identical `g[i]` accesses are not tallied — which basecse acknowledges in its own comment
|
|
295
|
-
// and which this pass is MORE exposed to, since it deliberately admits loop-body uses, exactly
|
|
296
|
-
// the input basecse's `inLoop` gate kept away from that hole.
|
|
297
|
-
if ([...rec.constOff.values()].some((n) => n >= 2)) {
|
|
706
|
+
if (sharedKeys.has(key)) {
|
|
707
|
+
refusals.set(key, 'shared-node'); // one node at two positions — see `sharedKeys`
|
|
298
708
|
continue;
|
|
299
709
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
710
|
+
const regions = rule.partition(rec.uses, sfn.body);
|
|
711
|
+
const siblingRegions = regions.filter((r) => r.uses.length >= 2).length;
|
|
712
|
+
let served = false;
|
|
713
|
+
for (const r of regions) {
|
|
714
|
+
// WHICH USES the counting rules are tallied over is the RULE's answer, not a branch here —
|
|
715
|
+
// see `REGION_RULES`. Nothing here tests the base kind: an `addr`/`const` base reaching this
|
|
716
|
+
// pass is one basecse already REFUSED (see the ordering note in the file header).
|
|
717
|
+
const judged = rule.judged(r, rec.uses);
|
|
718
|
+
const refused = firstRejection(gates, {
|
|
719
|
+
uses: judged.length,
|
|
720
|
+
repeatedConstOffset: repeatsAConstOffset(judged),
|
|
721
|
+
perIteration: runsPerIteration(r.uses),
|
|
722
|
+
nestedLoop: underNestedLoop(r.uses, r.depth),
|
|
723
|
+
siblingRegions,
|
|
724
|
+
});
|
|
725
|
+
if (refused !== null) {
|
|
726
|
+
if (!refusals.has(key)) {
|
|
727
|
+
refusals.set(key, refused);
|
|
728
|
+
}
|
|
319
729
|
continue;
|
|
320
730
|
}
|
|
321
|
-
|
|
322
|
-
|
|
731
|
+
const type = T.ptr(scalarTypeForAccess(rec.sample.width, rec.sample.signed));
|
|
732
|
+
// the earliest statement of the region list that (transitively) holds one of its uses —
|
|
733
|
+
// `indexWithin` carries the `idx`/`path` off-by-one this reads through.
|
|
734
|
+
const before = Math.min(...r.uses.map((u) => indexWithin(u, r.depth)));
|
|
735
|
+
served = true;
|
|
736
|
+
entries.push({
|
|
737
|
+
scope: r.scope,
|
|
738
|
+
key,
|
|
739
|
+
name: fresh(),
|
|
740
|
+
type,
|
|
741
|
+
base: rec.sample.base as LeafBase,
|
|
742
|
+
before,
|
|
743
|
+
uses: r.uses.map((u) => u.node),
|
|
744
|
+
});
|
|
323
745
|
}
|
|
324
|
-
if (
|
|
325
|
-
|
|
746
|
+
if (served) {
|
|
747
|
+
refusals.delete(key);
|
|
326
748
|
}
|
|
327
|
-
const type = T.ptr(scalarTypeForAccess(rec.sample.width, rec.sample.signed));
|
|
328
|
-
// the earliest statement of the scope list that (transitively) holds a use
|
|
329
|
-
// `path` starts EMPTY, so `idx` carries one entry more than `path`: idx[j+1] is the index
|
|
330
|
-
// within path[j]. The scope is path[depth-1], so its index is idx[depth].
|
|
331
|
-
const before = Math.min(...uses.map((u) => u.idx[at.depth]));
|
|
332
|
-
plan.push({ scope: at.scope, key, name: fresh(), type, base: rec.sample.base as LeafBase, before });
|
|
333
749
|
}
|
|
334
|
-
|
|
750
|
+
assertPlanOwnership(sfn, entries);
|
|
751
|
+
// access node → the local that replaces its base. Built from the SITES a plan entry was judged
|
|
752
|
+
// on, so the set the planner counted and the set the rewrite repoints are the same set by
|
|
753
|
+
// construction rather than by a second predicate that could disagree.
|
|
754
|
+
const repoint = new Map<Expr, string>();
|
|
755
|
+
for (const e of entries) {
|
|
756
|
+
e.uses.forEach((u) => repoint.set(u, e.name));
|
|
757
|
+
}
|
|
758
|
+
return { keys: [...found.keys()], entries, repoint, refusals, compound: false };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The re-spelling `regions` asks for — `/scopebase` or `/regionbase` — or null when nothing
|
|
763
|
+
* qualifies (the caller then adds no candidate rather than a duplicate of the default).
|
|
764
|
+
*/
|
|
765
|
+
export const hoistScopedBases = (sfn: SFn, opts: ScopeBaseOpts = {}): SFn | null =>
|
|
766
|
+
applyScopedBasePlan(sfn, planScopedBases(sfn, opts));
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* `plan` applied to the tree it was planned over — null when it decided nothing (the caller then
|
|
770
|
+
* adds no candidate rather than a duplicate of the default).
|
|
771
|
+
*
|
|
772
|
+
* IDENTITY-BOUND to that tree, and not by the type: `scope` is matched against statement LISTS and
|
|
773
|
+
* `repoint` against access NODES, both by reference. A plan from a DIFFERENT tree therefore splices
|
|
774
|
+
* nothing and repoints nothing while still declaring its locals; `assertLocalsWritten` (rank.ts's
|
|
775
|
+
* `respell`) is what makes that loud rather than a silently wrong candidate.
|
|
776
|
+
*/
|
|
777
|
+
export function applyScopedBasePlan(sfn: SFn, { entries: plan, repoint, compound: bad }: ScopedBasePlan): SFn | null {
|
|
778
|
+
if (bad || plan.length === 0) {
|
|
335
779
|
return null;
|
|
336
780
|
}
|
|
781
|
+
// THE TWO FIELDS MUST AGREE, and they arrive as independent data. `assertPlanOwnership` runs in
|
|
782
|
+
// the planner, so it cannot see an edit made between plan and apply — which is exactly the seam
|
|
783
|
+
// exporting this applier opened. A `repoint` naming a local no entry mints is neither declared
|
|
784
|
+
// nor assigned, and both boundary contracts pass it: `assertResolved` looks for absent names, not
|
|
785
|
+
// for unwritten ones. Checked here, where the two halves meet.
|
|
786
|
+
const minted = new Set(plan.map((p) => p.name));
|
|
787
|
+
for (const name of repoint.values()) {
|
|
788
|
+
if (!minted.has(name)) {
|
|
789
|
+
throw new Error(`scopebase: the plan repoints an access to \`${name}\`, which no entry mints`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
337
792
|
|
|
338
|
-
// A plan entry may own only a SUBSET of its key's uses (see deepestCluster), so repointing is
|
|
339
|
-
// scoped: a key becomes active when the rewrite enters its scope and inactive on the way out.
|
|
340
|
-
// Repointing by key alone would rewrite uses the hoist does not dominate.
|
|
341
|
-
// SAFE ONLY because `plan` holds at most one entry per key, so `delete` on the way out cannot
|
|
342
|
-
// discard an outer binding. Serving a second cluster for one key — the obvious next step — makes
|
|
343
|
-
// that false, and an inner delete would silently unbind the outer one for the rest of its scope:
|
|
344
|
-
// a use of an unassigned pointer, the defect class this module has already shipped twice. Switch
|
|
345
|
-
// to save/restore (or pass the bindings as an argument) before serving more than one cluster.
|
|
346
|
-
const active = new Map<string, string>();
|
|
347
793
|
const point = (e: Expr): Expr => {
|
|
348
|
-
const
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const { lead: _drop, ...rest } = ix;
|
|
355
|
-
return { ...rest, base: { k: 'var', name: nm }, idx: point(ix.idx) };
|
|
356
|
-
}
|
|
794
|
+
const nm = repoint.get(e);
|
|
795
|
+
if (nm) {
|
|
796
|
+
// `lead` is DROPPED — the local already points at the object start, and `nonzero-lead` has
|
|
797
|
+
// established every leading subscript is 0.
|
|
798
|
+
const { lead: _drop, ...rest } = e as Extract<Expr, { k: 'index' }>;
|
|
799
|
+
return { ...rest, base: { k: 'var', name: nm }, idx: point(rest.idx) };
|
|
357
800
|
}
|
|
358
801
|
return mapExprChildren(e, point);
|
|
359
802
|
};
|
|
@@ -363,28 +806,12 @@ export function hoistScopedBases(sfn: SFn): SFn | null {
|
|
|
363
806
|
// fresh tree in one pass — a two-pass version would compare rebuilt lists that no longer match.
|
|
364
807
|
const rewriteList = (list: Stmt[]): Stmt[] => {
|
|
365
808
|
const here = plan.filter((p) => p.scope === list);
|
|
366
|
-
// SAVE/RESTORE, not set/delete. A plain delete on the way out is correct only while `plan`
|
|
367
|
-
// holds one entry per key; the moment a second cluster for one key is served, an inner exit
|
|
368
|
-
// would unbind an OUTER hoist for the rest of its scope — under-repointing silently. Restoring
|
|
369
|
-
// makes the nesting correct by construction instead of by an unguarded invariant.
|
|
370
|
-
const saved = here.map((p) => [p.key, active.get(p.key)] as const);
|
|
371
|
-
for (const p of here) {
|
|
372
|
-
active.set(p.key, p.name);
|
|
373
|
-
}
|
|
374
809
|
const rewritten = list.map(rewriteStmt);
|
|
375
|
-
for (const [key, prev] of saved) {
|
|
376
|
-
if (prev === undefined) {
|
|
377
|
-
active.delete(key);
|
|
378
|
-
} else {
|
|
379
|
-
active.set(key, prev);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
810
|
// Insert each hoist immediately before the first statement that uses it. Descending by index so
|
|
383
811
|
// earlier insertions do not shift the positions later ones were computed against. NOTE that two
|
|
384
812
|
// hoists sharing a `before` come out REVERSED relative to `plan` order — the sort is stable and
|
|
385
813
|
// descending, so both splice at the same index and the later one ends up first. Deterministic
|
|
386
|
-
// and semantically irrelevant, but it is not first-appearance order
|
|
387
|
-
// claim.
|
|
814
|
+
// and semantically irrelevant, but it is not first-appearance order.
|
|
388
815
|
for (const p of [...here].sort((a, b) => b.before - a.before)) {
|
|
389
816
|
rewritten.splice(p.before, 0, {
|
|
390
817
|
k: 'assign',
|
|
@@ -436,5 +863,7 @@ export function hoistScopedBases(sfn: SFn): SFn | null {
|
|
|
436
863
|
// Declared from `plan`, one per hoist — NOT accumulated inside `rewriteList`, which would emit a
|
|
437
864
|
// duplicate declaration (non-compiling C) if a `Stmt[]` were ever structurally shared by two tree
|
|
438
865
|
// positions.
|
|
439
|
-
|
|
866
|
+
const out = { ...sfn, body, locals: [...sfn.locals, ...plan.map((p) => ({ name: p.name, type: p.type }))] };
|
|
867
|
+
assertHoistsDominate(out, new Set(plan.map((p) => p.name)));
|
|
868
|
+
return out;
|
|
440
869
|
}
|