@asmlift/core 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/backend/cfamily.ts +130 -4
- package/src/backend/cpp.ts +3 -1
- package/src/backend/pascal.ts +11 -0
- package/src/contracts.ts +181 -4
- package/src/declare.ts +35 -9
- package/src/frontend/mips.ts +37 -29
- package/src/frontend/opaque.ts +70 -20
- package/src/frontend/ppc.ts +18 -7
- package/src/frontend/ssa.ts +279 -56
- package/src/frontend/thumb.ts +1372 -87
- package/src/ir/alias.ts +75 -0
- package/src/ir/opcodes.ts +57 -3
- package/src/ir/simplify.ts +72 -0
- package/src/l3/argbase.ts +221 -0
- package/src/l3/ast.ts +127 -5
- package/src/l3/basecse.ts +58 -62
- package/src/l3/coalesce.ts +215 -0
- package/src/l3/dce.ts +33 -41
- package/src/l3/gates.ts +67 -0
- package/src/l3/hoist.ts +65 -0
- package/src/l3/reindex.ts +7 -0
- package/src/l3/scopebase.ts +440 -0
- package/src/l3/tailmerge.ts +124 -0
- package/src/macros.ts +222 -13
- package/src/pattern/engine.ts +99 -6
- package/src/pipeline.ts +65 -6
- package/src/raise/divpow2.ts +227 -0
- package/src/raise/gvn.ts +151 -0
- package/src/raise/pre-recovery.ts +39 -3
- package/src/raise/recover.ts +24 -7
- package/src/raise/retsink.ts +37 -7
- package/src/raise/shortcircuit.ts +262 -22
- package/src/raise/struct-arrays.ts +2 -1
- package/src/raise/structs.ts +41 -3
- package/src/rank.ts +196 -20
- package/src/structure/analysis.ts +175 -89
- package/src/structure/structure.ts +588 -55
- package/src/structure/switch-recover.ts +117 -30
- package/src/symbols.ts +128 -13
- package/src/target.ts +4 -2
- package/src/trace.ts +9 -0
package/src/rank.ts
CHANGED
|
@@ -12,12 +12,16 @@ import { cBackend } from './backend/c';
|
|
|
12
12
|
import { assertDerefsTyped, assertResolved } from './contracts';
|
|
13
13
|
import type { AsmData } from './frontend/asmdata';
|
|
14
14
|
import { frontendFor } from './frontend/registry';
|
|
15
|
+
import { globalCellOf } from './ir/alias';
|
|
15
16
|
import { Fn, type Value, defOpMap } from './ir/core';
|
|
16
17
|
import { T } from './ir/types';
|
|
17
18
|
import { verify } from './ir/verify';
|
|
19
|
+
import { materializeArgBases } from './l3/argbase';
|
|
18
20
|
import type { LanguageBackend, SFn } from './l3/ast';
|
|
21
|
+
import { coalesceCandidates } from './l3/coalesce';
|
|
19
22
|
import { registerishSpellings } from './l3/regspell';
|
|
20
23
|
import { reindexWalks } from './l3/reindex';
|
|
24
|
+
import { hoistScopedBases } from './l3/scopebase';
|
|
21
25
|
import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
|
|
22
26
|
import { RewritePattern } from './pattern/engine';
|
|
23
27
|
import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
|
|
@@ -117,12 +121,30 @@ export interface EnumerateOptions {
|
|
|
117
121
|
asmData?: AsmData;
|
|
118
122
|
/** address→symbol map (symbols.ts) — same contract as DecompileOptions.symbols */
|
|
119
123
|
symbols?: SymbolMap;
|
|
124
|
+
/** Called when a re-spelling lever THROWS or fails a boundary contract, so the failure is visible
|
|
125
|
+
* instead of the candidate silently not existing. Enumeration continues either way — the primary
|
|
126
|
+
* spelling is unaffected — but a lever that never fires because it always throws is a defect, and
|
|
127
|
+
* without this it looks identical to a lever that correctly declined. */
|
|
128
|
+
onLeverError?: (label: string, error: string) => void;
|
|
120
129
|
}
|
|
121
130
|
|
|
122
|
-
/** One distinct candidate spelling
|
|
131
|
+
/** One distinct candidate spelling — a point in the axis cross (signedness × branch sense ×
|
|
132
|
+
* def-site anchoring × bitfield spelling × symbol-map variant, plus the L3 re-spellings) —
|
|
133
|
+
* emitted to source. */
|
|
123
134
|
export interface Candidate {
|
|
124
135
|
label: string;
|
|
125
136
|
source: string;
|
|
137
|
+
/** Which PREFERENCE GROUP this spelling belongs to — the symbol-variant index (0 = the map's own
|
|
138
|
+
* named spellings, 1 = their `/raw-globals` siblings). Enumeration emits the groups in
|
|
139
|
+
* preference order, and a lower group WINS a score tie: when both compile to the same bytes the
|
|
140
|
+
* reader should get `gCounter.field`, not a byte offset off a hoisted `(u8 *)` base.
|
|
141
|
+
*
|
|
142
|
+
* Carried structurally rather than left to enumeration order because the readability tie-break
|
|
143
|
+
* (compareScored) must compare only spellings that are genuinely alternatives of the same
|
|
144
|
+
* thing. Ranking a named spelling against a raw-address one on cast count is not a readability
|
|
145
|
+
* comparison at all — the raw form's `(u8 *)` base is not counted, so it would win by
|
|
146
|
+
* construction, trading named struct fields for anonymous byte offsets. */
|
|
147
|
+
group: number;
|
|
126
148
|
/** the map-derived VALUE references this candidate's tree contains — what the scoring
|
|
127
149
|
* layer's declaration synthesis renders. DERIVED, never carried: computed once from the
|
|
128
150
|
* exact tree this candidate's source was emitted from, at the moment the candidate is
|
|
@@ -179,10 +201,32 @@ export function enumerateCandidates(
|
|
|
179
201
|
// and let the differ referee. The default sense is always among them, so this never scores
|
|
180
202
|
// worse; it only wins where the flip matches.
|
|
181
203
|
const defSense = baseOpts.preserveDivergentBranchSense ?? true;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
204
|
+
// `/defsite` — def-site-anchored constant merge copies (structure.ts anchorConstCopies) — is a
|
|
205
|
+
// structuring axis on the same footing as branch sense: where the asm materialized a merge
|
|
206
|
+
// constant is placement evidence, but whether the SOURCE spelled it there is genuinely
|
|
207
|
+
// ambiguous, so both placements are emitted and the differ referees. Crossed with branch sense
|
|
208
|
+
// (an anchored copy empties an arm, which is exactly what changes which sense wins); the dedup
|
|
209
|
+
// below collapses every variant the anchoring left unchanged.
|
|
210
|
+
const baseSense = [
|
|
211
|
+
{ suffix: '', sense: defSense, anchor: false, bitfields: true },
|
|
212
|
+
{ suffix: '/flip-branch', sense: !defSense, anchor: false, bitfields: true },
|
|
213
|
+
{ suffix: '/defsite', sense: defSense, anchor: true, bitfields: true },
|
|
214
|
+
{ suffix: '/flip-branch/defsite', sense: !defSense, anchor: true, bitfields: true },
|
|
185
215
|
];
|
|
216
|
+
// `/no-bitfield` — keep the honest shift spelling where the map would name a bitfield member.
|
|
217
|
+
// The named read recompiles at the DECLARATION's access width; where that diverges from the
|
|
218
|
+
// asm's load width, the shifts are the spelling that matches — so both are emitted and the
|
|
219
|
+
// differ referees. Enumerated only when the map carries any bitfield member at all (checked
|
|
220
|
+
// below), so the 2× cross is paid exactly by the functions it can help; the dedup collapses
|
|
221
|
+
// every variant where no fold fired.
|
|
222
|
+
const mapHasBitfields =
|
|
223
|
+
opts.symbols !== undefined &&
|
|
224
|
+
[...opts.symbols.values()].some((infos) =>
|
|
225
|
+
infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
|
|
226
|
+
);
|
|
227
|
+
const bitfieldCands = mapHasBitfields
|
|
228
|
+
? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
|
|
229
|
+
: baseSense;
|
|
186
230
|
// Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
|
|
187
231
|
// so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
|
|
188
232
|
// compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
|
|
@@ -196,6 +240,28 @@ export function enumerateCandidates(
|
|
|
196
240
|
// Access facts for name-only symbol declarations (see bareGlobalAccessFacts) — derived once
|
|
197
241
|
// from the probe: widths/offsets are lift-time facts, identical across every candidate.
|
|
198
242
|
const accessFacts = opts.symbols ? bareGlobalAccessFacts(probe) : new Map<string, never>();
|
|
243
|
+
// `/reread-globals` — the VALUE-HOME axis (structure/analysis.ts AnalyzeOptions). Whether the
|
|
244
|
+
// source read a global once into a variable or re-read it at each use is not derivable from asm:
|
|
245
|
+
// the compiler CSEs the second spelling back into one load, and the round-5 dogfood watched agbcc
|
|
246
|
+
// land on both sides inside a single function (its highest-cost defect, 25 of 27 points on one
|
|
247
|
+
// klonoa function and 35/50 both ways on another). So both spellings are emitted and the differ
|
|
248
|
+
// referees — the same footing as signedness and branch sense, and never a default: the cached
|
|
249
|
+
// spelling stays the primary, so this can only ever ADD a winner.
|
|
250
|
+
//
|
|
251
|
+
// Gated on the function having a load that resolves to a named global at all — the only thing the
|
|
252
|
+
// axis can change. The dedup below collapses the pair wherever it changed nothing.
|
|
253
|
+
const probeDefs = defOpMap(probe);
|
|
254
|
+
const readsANamedGlobal = probe.blocks.some((b) =>
|
|
255
|
+
b.ops.some(
|
|
256
|
+
(op) => op.opcode === 'load' && globalCellOf(probeDefs, op.operands[0], op.attrs.off as number) !== null,
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
const senseCands = readsANamedGlobal
|
|
260
|
+
? [
|
|
261
|
+
...bitfieldCands.map((s) => ({ ...s, reread: false })),
|
|
262
|
+
...bitfieldCands.map((s) => ({ ...s, suffix: `${s.suffix}/reread-globals`, reread: true })),
|
|
263
|
+
]
|
|
264
|
+
: bitfieldCands.map((s) => ({ ...s, reread: false }));
|
|
199
265
|
|
|
200
266
|
const seen = new Set<string>();
|
|
201
267
|
const out: Candidate[] = [];
|
|
@@ -211,7 +277,7 @@ export function enumerateCandidates(
|
|
|
211
277
|
{ suffix: '/raw-globals', symbols: undefined },
|
|
212
278
|
]
|
|
213
279
|
: [{ suffix: '' }];
|
|
214
|
-
for (const sv of symbolVariants) {
|
|
280
|
+
for (const [svIndex, sv] of symbolVariants.entries()) {
|
|
215
281
|
const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
|
|
216
282
|
for (const cand of SIGN_CANDS) {
|
|
217
283
|
const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
|
|
@@ -223,7 +289,24 @@ export function enumerateCandidates(
|
|
|
223
289
|
for (const s of senseCands) {
|
|
224
290
|
// structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
|
|
225
291
|
// senses structure the same recovered function without re-lifting.
|
|
226
|
-
|
|
292
|
+
let sfn: SFn;
|
|
293
|
+
try {
|
|
294
|
+
sfn = structureChecked(fn, {
|
|
295
|
+
...svOpts,
|
|
296
|
+
preserveDivergentBranchSense: s.sense,
|
|
297
|
+
anchorConstCopies: s.anchor,
|
|
298
|
+
spellBitfieldMembers: s.bitfields,
|
|
299
|
+
rereadGlobals: s.reread,
|
|
300
|
+
});
|
|
301
|
+
} catch (e) {
|
|
302
|
+
if (!s.anchor && s.bitfields && !s.reread) {
|
|
303
|
+
throw e; // the base axes keep their behavior: a structuring failure aborts the row
|
|
304
|
+
}
|
|
305
|
+
// an anchored variant that fails structuring or its contracts is a dropped lever, never
|
|
306
|
+
// an aborted enumeration — same rule as respell below
|
|
307
|
+
opts.onLeverError?.(name + s.suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
227
310
|
// The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
|
|
228
311
|
// signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
|
|
229
312
|
// genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
|
|
@@ -266,24 +349,72 @@ export function enumerateCandidates(
|
|
|
266
349
|
// to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
|
|
267
350
|
// defect class this project exists to avoid. Hence each lever's decline-over-approximate
|
|
268
351
|
// gates, adversarially audited.
|
|
269
|
-
|
|
352
|
+
// Takes a THUNK, so the lever's own computation is inside the try too. A lever that threw
|
|
353
|
+
// from the pass itself — rather than from the contracts or the backend — would escape and
|
|
354
|
+
// abort the whole enumeration for this row, primary included: the one way a lever can cost
|
|
355
|
+
// a match. Making that structural rather than per-call-site means no lever can opt out.
|
|
356
|
+
const respell = (suffix: string, make: () => SFn | null | undefined): void => {
|
|
270
357
|
try {
|
|
358
|
+
const alt = make();
|
|
359
|
+
if (!alt) {
|
|
360
|
+
return; // the lever declined to fire — no candidate, not a duplicate of the primary
|
|
361
|
+
}
|
|
271
362
|
assertResolved(alt);
|
|
272
363
|
assertDerefsTyped(alt);
|
|
273
364
|
spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
|
|
274
|
-
} catch {
|
|
275
|
-
// contract
|
|
365
|
+
} catch (e) {
|
|
366
|
+
// A throwing lever, a contract failure, or an unspellable re-spelling: keep the primary.
|
|
367
|
+
// REPORTED, not swallowed. `dropped` (below) records only spellings the SCORER refused,
|
|
368
|
+
// so without this a lever that fails here vanishes with no trace — indistinguishable
|
|
369
|
+
// from one that correctly declined, which is exactly the hidden failure
|
|
370
|
+
// DroppedCandidate exists to surface.
|
|
371
|
+
opts.onLeverError?.(name + suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
|
|
276
372
|
}
|
|
277
373
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
374
|
+
// `/argbase` — name a call's argument bases before the call (l3/argbase.ts). A lever on the
|
|
375
|
+
// same footing as the others: the primary inline spelling stays in the list, so the differ
|
|
376
|
+
// referees and this can never cost a match.
|
|
377
|
+
respell('/argbase', () => materializeArgBases(sfn));
|
|
378
|
+
// `/scopebase` — name a reused global base at the INNERMOST scope holding its uses
|
|
379
|
+
// (l3/scopebase.ts). Distinct from basecse's function-top hoist, which the primary already
|
|
380
|
+
// carries: this one fires exactly where that placement would extend a live range the
|
|
381
|
+
// original never had.
|
|
382
|
+
// `/scopebase`, and its COALESCED variants. Which locals a register allocator shared is not
|
|
383
|
+
// derivable from the tree — on the row this was built for the two legal merges score 18 and
|
|
384
|
+
// 40 against a no-merge 21, so committing to one by declaration order costs 19 points and
|
|
385
|
+
// discards the winner. Every variant is emitted and the differ referees, exactly as
|
|
386
|
+
// `/regcopy` does for its allocator-ambiguous tail choice.
|
|
387
|
+
//
|
|
388
|
+
// POLICY NOTE: rank.ts's rule is that re-spellings derive from the BASE spelling only —
|
|
389
|
+
// levers do not compose. These are not a second lever composed onto the first: coalescing is
|
|
390
|
+
// enumerated as alternative OUTPUTS of the base hoist, in the one place that knows the hoist
|
|
391
|
+
// just happened. The un-coalesced `/scopebase` stays in the list, so nothing is lost.
|
|
392
|
+
//
|
|
393
|
+
// EVERY pass invocation stays INSIDE a thunk — see the paragraph above on why a pass that
|
|
394
|
+
// runs outside `respell`'s try is the one way a lever can cost a match. `enumerate` re-runs
|
|
395
|
+
// the hoist per candidate, which is pure and cheap, rather than caching it outside the guard.
|
|
396
|
+
respell('/scopebase', () => hoistScopedBases(sfn));
|
|
397
|
+
const enumerate = (label: string, from: () => SFn | null | undefined): void => {
|
|
398
|
+
let variants: { merged: string; sfn: SFn }[] = [];
|
|
399
|
+
try {
|
|
400
|
+
const base = from();
|
|
401
|
+
variants = base ? coalesceCandidates(base) : [];
|
|
402
|
+
} catch (e) {
|
|
403
|
+
opts.onLeverError?.(name + label, e instanceof Error ? e.message.split('\n')[0] : String(e));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
for (const c of variants) {
|
|
407
|
+
respell(`${label}-${c.merged}`, () => c.sfn);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
enumerate('/scopebase-coalesce', () => hoistScopedBases(sfn));
|
|
411
|
+
enumerate('/coalesce', () => sfn);
|
|
412
|
+
respell('/indexed', () => reindexWalks(sfn));
|
|
282
413
|
// the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
|
|
283
414
|
// the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
|
|
284
415
|
// ambiguous, so both are ranked)
|
|
285
416
|
const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
|
|
286
|
-
registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, alt));
|
|
417
|
+
registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, () => alt));
|
|
287
418
|
for (const sp of spellings) {
|
|
288
419
|
const source = sp.source;
|
|
289
420
|
// Collapse a spelling that produced identical source (a function with no divergent `if`
|
|
@@ -298,6 +429,7 @@ export function enumerateCandidates(
|
|
|
298
429
|
out.push({
|
|
299
430
|
label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
|
|
300
431
|
source,
|
|
432
|
+
group: svIndex,
|
|
301
433
|
...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
|
|
302
434
|
});
|
|
303
435
|
}
|
|
@@ -332,15 +464,59 @@ export function rankBy<S extends { score: number }>(
|
|
|
332
464
|
if (results.length === 0) {
|
|
333
465
|
throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
|
|
334
466
|
}
|
|
335
|
-
|
|
336
|
-
// enumerateCandidates emits the symbol-map spellings before their `/raw-globals` siblings, so
|
|
337
|
-
// when both compile to the same bytes the named one wins and the reader gets `gCounter` rather
|
|
338
|
-
// than a bare address. Spelled as an explicit comparator because relying on Array#sort's
|
|
339
|
-
// stability would make the preference an accident of two unrelated decisions.
|
|
340
|
-
results.sort((a, b) => a.score.score - b.score.score || a.order - b.order);
|
|
467
|
+
results.sort(compareScored);
|
|
341
468
|
return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
|
|
342
469
|
}
|
|
343
470
|
|
|
471
|
+
/** THE candidate ordering — score, then preference group, then readability, then enumeration
|
|
472
|
+
* order. Exported because there are TWO drivers over the same enumeration (this module's sync
|
|
473
|
+
* `rankBy` for the Node/objdiff scorer, and the webapp's async await-loop for the wasm one), and
|
|
474
|
+
* a per-driver copy would let the same input produce two different winners.
|
|
475
|
+
*
|
|
476
|
+
* SCORE dominates absolutely: the differ is the fitness function, and a tie means the axis that
|
|
477
|
+
* separates these two spellings did not change the bytes — so everything below only chooses what
|
|
478
|
+
* the READER sees, and can never cost a match.
|
|
479
|
+
*
|
|
480
|
+
* GROUP next: a named symbol-map spelling beats its `/raw-globals` sibling at equal bytes.
|
|
481
|
+
*
|
|
482
|
+
* CAST COUNT next, and only WITHIN a group. A wrong signedness pin is what manufactures casts —
|
|
483
|
+
* the C backend has to cast a shift operand back to the signedness the machine op needs, so
|
|
484
|
+
* pinning `u32` on a genuinely-signed parameter buys `s32 f(u32 a0) { return (s32)a0 >> a1; }`
|
|
485
|
+
* for the same bytes as `s32 f(s32 a0) { return a0 >> a1; }`. Before the backend synthesized that
|
|
486
|
+
* cast the wrong pin simply lost on score; now it ties, and enumeration order alone would
|
|
487
|
+
* silently install the noisier spelling.
|
|
488
|
+
*
|
|
489
|
+
* ENUMERATION ORDER last, which makes this a strict total order (indices are unique) and the
|
|
490
|
+
* result deterministic. Spelled explicitly rather than leaning on Array#sort's stability, which
|
|
491
|
+
* would make each preference an accident of two unrelated decisions. */
|
|
492
|
+
export function compareScored<S extends { score: number }>(
|
|
493
|
+
a: Candidate & { score: S; order: number },
|
|
494
|
+
b: Candidate & { score: S; order: number },
|
|
495
|
+
): number {
|
|
496
|
+
return (
|
|
497
|
+
a.score.score - b.score.score || a.group - b.group || castCount(a.source) - castCount(b.source) || a.order - b.order
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Scalar casts in a candidate's rendered source — the readability tie-break above.
|
|
502
|
+
*
|
|
503
|
+
* A TEXT count over the emitted string, matching how the benchmark's own readability metric
|
|
504
|
+
* measures the same thing (apps/benchmark/src/eval/quality.ts) — the two must agree about what
|
|
505
|
+
* "cast noise" means, or ranking optimizes for something the report then scores differently.
|
|
506
|
+
*
|
|
507
|
+
* It counts the decomp typedef vocabulary only, so a pointer or struct cast is not read as noise
|
|
508
|
+
* — those are structural spellings a candidate does not choose. And it carries `quality.ts`'s
|
|
509
|
+
* ADDRESS-CAST exemption: `(u32)&gSym` / `(s32)&gSym` is the CORRECT source spelling of integer
|
|
510
|
+
* arithmetic on a link-time address, which decomp projects write themselves. Counting it would
|
|
511
|
+
* penalize precisely the named spelling this ranking is supposed to prefer.
|
|
512
|
+
*
|
|
513
|
+
* Deterministic, and total on any string. */
|
|
514
|
+
function castCount(source: string): number {
|
|
515
|
+
const all = source.match(/\((?:u|s)(?:8|16|32)\)/g)?.length ?? 0;
|
|
516
|
+
const addr = source.match(/\((?:u|s)32\)\s*&/g)?.length ?? 0;
|
|
517
|
+
return all - addr;
|
|
518
|
+
}
|
|
519
|
+
|
|
344
520
|
/** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
|
|
345
521
|
function firstLine(e: unknown): string {
|
|
346
522
|
return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// interference check in structure.ts;
|
|
6
6
|
// • the effect-ordering model — which call/load defs must MATERIALIZE as named temps at
|
|
7
7
|
// their own program position instead of inlining at their use.
|
|
8
|
+
import { globalCellOf, mayWriteGlobal } from '../ir/alias';
|
|
8
9
|
import { Block, Fn, Op, Value, successorsOf } from '../ir/core';
|
|
9
10
|
|
|
10
11
|
export interface UseSite {
|
|
@@ -24,9 +25,42 @@ export interface StructureAnalysis {
|
|
|
24
25
|
materialize: Set<Op>;
|
|
25
26
|
/** cached forward reachability (successors-transitive, excluding the start block itself) */
|
|
26
27
|
reachFrom: (b: Block) => Set<Block>;
|
|
28
|
+
/** where a value's expression ultimately renders — the anchored consumer it inlines into,
|
|
29
|
+
* transitively; null = several places / unresolvable (callers treat conservatively) */
|
|
30
|
+
emitPos: (op: Op) => { blk: Block; idx: number } | null;
|
|
31
|
+
/** may an op `isWrite` accepts execute between `def` and a statement at `render`, on any
|
|
32
|
+
* def-avoiding path — the fold-ordering gate (see the closure's comment) */
|
|
33
|
+
memWriteBetween: (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean) => boolean;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
export
|
|
36
|
+
export interface AnalyzeOptions {
|
|
37
|
+
/** the fn's def map (`defOpMap`) — the structurer already holds one, so it is passed rather than
|
|
38
|
+
* rebuilt. Absent ⇒ the global-aware alias rule below cannot resolve anything and every write
|
|
39
|
+
* bars, exactly as before it existed. */
|
|
40
|
+
defs?: Map<Value, Op>;
|
|
41
|
+
/** THE value-home axis (rank.ts `/reread-globals`). A read of a named global is barred from
|
|
42
|
+
* rendering at its use by any write in between — even a store to an unrelated global, which
|
|
43
|
+
* cannot possibly change what it sees. That over-conservatism is what invents the locals the
|
|
44
|
+
* round-5 dogfood measured as its highest-cost defect ("hoists what agbcc re-reads"):
|
|
45
|
+
*
|
|
46
|
+
* gA = v; gB = v; with `s32 v = gValue;` where the source said `gA = gValue; gB = gValue;`
|
|
47
|
+
*
|
|
48
|
+
* With this on, the barrier scan for a load whose address resolves to a named global uses THE
|
|
49
|
+
* shared disjointness query (ir/alias.ts) instead of "any write at all". Materializing is always
|
|
50
|
+
* sound, so today's spelling is never wrong — only sometimes not the one the compiler was given.
|
|
51
|
+
* Which side matches is genuinely per-function (the same dogfood watched agbcc go both ways
|
|
52
|
+
* inside ONE function), so this is a differ-refereed candidate axis, never a default. */
|
|
53
|
+
rereadGlobals?: boolean;
|
|
54
|
+
/** "does the project declare this global volatile?" — a read of a volatile object may NOT be
|
|
55
|
+
* duplicated or moved, so the axis above refuses on one. Answers false for a symbol the map
|
|
56
|
+
* does not carry (and for no map at all), which is the same posture the multi-render rule has
|
|
57
|
+
* always had: without a declaration nothing here can know, and the differ referees the extra
|
|
58
|
+
* load. Where the map DOES know, the axis is silent about it rather than wrong. */
|
|
59
|
+
volatileGlobal?: (name: string) => boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {}): StructureAnalysis {
|
|
63
|
+
const { defs, rereadGlobals = false, volatileGlobal } = opts;
|
|
30
64
|
// ── use registry ────────────────────────────────────────────────────────────────────────
|
|
31
65
|
// Every use of a value, POSITIONED: the consuming op and its block/index. Successor args are
|
|
32
66
|
// uses AT the terminator (they render in argAssigns at block end). A void function's `ret`
|
|
@@ -198,29 +232,116 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
|
|
|
198
232
|
// terminator, materialized def) it inlines into, transitively through single-use pure ops.
|
|
199
233
|
// null = renders in several places / unresolvable (treated conservatively by the caller).
|
|
200
234
|
const emitPosCache = new Map<Op, { blk: Block; idx: number } | null>();
|
|
235
|
+
/** an op that renders AT ITS OWN position: a statement, a terminator, a materialized or dead def */
|
|
236
|
+
const anchored = (op: Op): boolean =>
|
|
237
|
+
op.successors.length > 0 ||
|
|
238
|
+
op.opcode === 'ret' ||
|
|
239
|
+
op.opcode === 'store' ||
|
|
240
|
+
op.opcode === 'astore' ||
|
|
241
|
+
materialize.has(op) ||
|
|
242
|
+
!op.results.length ||
|
|
243
|
+
!useSitesOf.has(op.results[0]);
|
|
244
|
+
const consumersOf = (op: Op): Op[] => [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))];
|
|
201
245
|
const emitPos = (op: Op): { blk: Block; idx: number } | null => {
|
|
202
246
|
if (emitPosCache.has(op)) {
|
|
203
247
|
return emitPosCache.get(op)!;
|
|
204
248
|
}
|
|
205
|
-
const own = { blk: opBlock.get(op)!, idx: opIndex.get(op)! };
|
|
206
249
|
let res: { blk: Block; idx: number } | null;
|
|
207
|
-
if (
|
|
208
|
-
op.
|
|
209
|
-
op.opcode === 'ret' ||
|
|
210
|
-
op.opcode === 'store' ||
|
|
211
|
-
op.opcode === 'astore' ||
|
|
212
|
-
materialize.has(op) ||
|
|
213
|
-
!op.results.length ||
|
|
214
|
-
!useSitesOf.has(op.results[0])
|
|
215
|
-
) {
|
|
216
|
-
res = own; // statements, terminators, materialized/dead defs
|
|
250
|
+
if (anchored(op)) {
|
|
251
|
+
res = { blk: opBlock.get(op)!, idx: opIndex.get(op)! };
|
|
217
252
|
} else {
|
|
218
|
-
const consumers =
|
|
253
|
+
const consumers = consumersOf(op);
|
|
219
254
|
res = consumers.length === 1 ? emitPos(consumers[0]) : null;
|
|
220
255
|
}
|
|
221
256
|
emitPosCache.set(op, res);
|
|
222
257
|
return res;
|
|
223
258
|
};
|
|
259
|
+
// EVERY position a value's expression renders at — `emitPos` generalized to the whole set (it
|
|
260
|
+
// answers one place or gives up), by following ALL consumers transitively. That matters for
|
|
261
|
+
// the value-home axis: a pure expression with two consumers (`gOut = e; return e;`) has no single
|
|
262
|
+
// emit position, so `emitPos` answers null and every memory read feeding it is forced into a
|
|
263
|
+
// local — even when re-reading at both places is provably equivalent. Null only for a genuine
|
|
264
|
+
// cycle (defensive: SSA use-def is acyclic through ops), which the caller treats as unresolvable.
|
|
265
|
+
//
|
|
266
|
+
// NEVER for a call: two render positions mean two executions, so a call whose consumer renders in
|
|
267
|
+
// several places must keep answering null and materialize.
|
|
268
|
+
const emitPosSetCache = new Map<Op, { blk: Block; idx: number }[] | null>();
|
|
269
|
+
const emitPositions = (op: Op, visiting: Set<Op> = new Set()): { blk: Block; idx: number }[] | null => {
|
|
270
|
+
const hit = emitPosSetCache.get(op);
|
|
271
|
+
if (hit !== undefined) {
|
|
272
|
+
return hit;
|
|
273
|
+
}
|
|
274
|
+
if (visiting.has(op)) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
let res: { blk: Block; idx: number }[] | null;
|
|
278
|
+
if (anchored(op)) {
|
|
279
|
+
res = [{ blk: opBlock.get(op)!, idx: opIndex.get(op)! }];
|
|
280
|
+
} else {
|
|
281
|
+
visiting.add(op);
|
|
282
|
+
const seenPos = new Set<string>();
|
|
283
|
+
const acc: { blk: Block; idx: number }[] = [];
|
|
284
|
+
res = acc;
|
|
285
|
+
for (const c of consumersOf(op)) {
|
|
286
|
+
const sub = emitPositions(c, visiting);
|
|
287
|
+
if (!sub) {
|
|
288
|
+
res = null;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
for (const p of sub) {
|
|
292
|
+
const key = `${blockPos.get(p.blk)}:${p.idx}`;
|
|
293
|
+
if (!seenPos.has(key)) {
|
|
294
|
+
seenPos.add(key);
|
|
295
|
+
acc.push(p);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
visiting.delete(op);
|
|
300
|
+
}
|
|
301
|
+
emitPosSetCache.set(op, res);
|
|
302
|
+
return res;
|
|
303
|
+
};
|
|
304
|
+
// THE def→render path discipline — one implementation, three callers (the two materialization
|
|
305
|
+
// rules below and structure.ts's bitfield fold, which imports it). May an op `isWrite` accepts
|
|
306
|
+
// execute between `def` and a statement at `render`, on any def-avoiding path? The def block's
|
|
307
|
+
// tail, the render block's head, and every between-block on a path; a path re-crossing the def
|
|
308
|
+
// is the NEXT dynamic instance and does not count. Path-based on purpose: `fn.blocks` is ADDRESS
|
|
309
|
+
// order, so a linear-position scan misses a block laid out after the render that executes
|
|
310
|
+
// between def and render on the taken path (an audit round broke exactly that way).
|
|
311
|
+
const memWriteBetween = (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean): boolean => {
|
|
312
|
+
const b = opBlock.get(def)!;
|
|
313
|
+
const oi = opIndex.get(def)!;
|
|
314
|
+
const wDirty = (list: Op[], from: number, to: number): boolean => {
|
|
315
|
+
for (let k = from; k < to; k++) {
|
|
316
|
+
if (isWrite(list[k])) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
};
|
|
322
|
+
// Same block: the only def-avoiding path is the straight line between the two indices
|
|
323
|
+
// (leaving and re-entering the block re-crosses the def). A render BEFORE the def cannot
|
|
324
|
+
// happen — within a block, uses follow defs — and falls through to the path walk, whose
|
|
325
|
+
// answer is the conservative one.
|
|
326
|
+
if (render.blk === b && oi < render.idx) {
|
|
327
|
+
return wDirty(b.ops, oi + 1, render.idx);
|
|
328
|
+
}
|
|
329
|
+
if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(render.blk.ops, 0, render.idx)) {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
for (const x of reachAvoiding(b, b)) {
|
|
333
|
+
if (x === render.blk && !reachAvoiding(render.blk, b).has(render.blk)) {
|
|
334
|
+
continue; // acyclic render block: head checked
|
|
335
|
+
}
|
|
336
|
+
if (x !== render.blk && !reachAvoiding(x, b).has(render.blk)) {
|
|
337
|
+
continue; // not on a def→render path
|
|
338
|
+
}
|
|
339
|
+
if (wDirty(x.ops, 0, x.ops.length)) {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
};
|
|
224
345
|
// Decide in REVERSE program order so a consumer's own materialization is settled before any
|
|
225
346
|
// producer asks for its emit position (SSA: uses follow defs in dominance/layout order) — and
|
|
226
347
|
// iterate to a fixpoint for IR whose block layout does not follow dominance (hand-built IR):
|
|
@@ -229,6 +350,7 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
|
|
|
229
350
|
for (let sizeBefore = -1; sizeBefore !== materialize.size;) {
|
|
230
351
|
sizeBefore = materialize.size;
|
|
231
352
|
emitPosCache.clear();
|
|
353
|
+
emitPosSetCache.clear(); // both render-position caches read `materialize`, which just grew
|
|
232
354
|
for (let bi = fn.blocks.length - 1; bi >= 0; bi--) {
|
|
233
355
|
const b = fn.blocks[bi];
|
|
234
356
|
for (let oi = b.ops.length - 1; oi >= 0; oi--) {
|
|
@@ -268,6 +390,14 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
|
|
|
268
390
|
if (!r || !useSitesOf.has(r)) {
|
|
269
391
|
continue;
|
|
270
392
|
} // dead call → exprstmt (unchanged)
|
|
393
|
+
// Under the value-home axis: which named global cell this op reads, if any. A constant-
|
|
394
|
+
// offset `load` only — an `aload`'s runtime index names no single cell, and a call reads
|
|
395
|
+
// everything. Null ⇒ every write bars, exactly as before.
|
|
396
|
+
const cell =
|
|
397
|
+
rereadGlobals && defs && op.opcode === 'load'
|
|
398
|
+
? globalCellOf(defs, op.operands[0], op.attrs.off as number)
|
|
399
|
+
: null;
|
|
400
|
+
const barsThisRead = cell && defs && !volatileGlobal?.(cell.name) ? mayWriteGlobal(defs, cell.name) : null;
|
|
271
401
|
const sites = useSitesOf.get(r)!;
|
|
272
402
|
const consumers = [...new Set(sites.map((s) => s.op))];
|
|
273
403
|
const isCall = op.opcode === 'call';
|
|
@@ -280,50 +410,32 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
|
|
|
280
410
|
// per-use source spelling did (`while (*s != EOS) *d = *s;` reads *s twice per iteration),
|
|
281
411
|
// so it is sound iff every render still sees the def-time memory: NO write anywhere
|
|
282
412
|
// between the def and ANY render (cycle-aware, conservative write set). Otherwise a temp.
|
|
283
|
-
|
|
413
|
+
//
|
|
414
|
+
// WHERE it renders. Without the axis: one position per consumer, and a consumer with no
|
|
415
|
+
// single position (its own value renders in several places) refuses. With the axis a load
|
|
416
|
+
// resolves the whole SET instead — the second half of the value-home defect, where the
|
|
417
|
+
// local is invented not by a barrier but because the pure expression downstream is itself
|
|
418
|
+
// duplicated (`gOut = (gValue << 1) + gValue; return (gValue << 1) + gValue;`). Never for a
|
|
419
|
+
// call: several positions there mean several executions.
|
|
420
|
+
const poss =
|
|
421
|
+
rereadGlobals && !isCall
|
|
422
|
+
? emitPositions(op)
|
|
423
|
+
: consumers.length > 1
|
|
424
|
+
? consumers.map((c) => emitPos(c))
|
|
425
|
+
: [emitPos(consumers[0])];
|
|
426
|
+
if (!poss || poss.some((p) => p === null)) {
|
|
427
|
+
materialize.add(op);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (poss.length > 1) {
|
|
284
431
|
const MW = new Set(['store', 'astore', 'call', 'opaque']);
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
if (MW.has(list[k].opcode)) {
|
|
288
|
-
return true;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return false;
|
|
292
|
-
};
|
|
293
|
-
const defToRenderDirty = (q: { blk: Block; idx: number }): boolean => {
|
|
294
|
-
// Same block: the only def-avoiding path is the straight line between the two indices
|
|
295
|
-
// (leaving and re-entering the block re-crosses the def).
|
|
296
|
-
if (q.blk === b && oi < q.idx) {
|
|
297
|
-
return wDirty(b.ops, oi + 1, q.idx);
|
|
298
|
-
}
|
|
299
|
-
if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(q.blk.ops, 0, q.idx)) {
|
|
300
|
-
return true;
|
|
301
|
-
}
|
|
302
|
-
const between = reachAvoiding(b, b);
|
|
303
|
-
for (const x of between) {
|
|
304
|
-
if (x === q.blk && !reachAvoiding(q.blk, b).has(q.blk)) {
|
|
305
|
-
continue;
|
|
306
|
-
} // acyclic render blk: head checked
|
|
307
|
-
if (x !== q.blk && !reachAvoiding(x, b).has(q.blk)) {
|
|
308
|
-
continue;
|
|
309
|
-
} // not on a def→render path
|
|
310
|
-
if (wDirty(x.ops, 0, x.ops.length)) {
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
return false;
|
|
315
|
-
};
|
|
316
|
-
const poss = consumers.map((c) => emitPos(c));
|
|
317
|
-
if (poss.some((p) => p === null) || poss.some((p) => defToRenderDirty(p!))) {
|
|
432
|
+
const isWrite = barsThisRead ?? ((x: Op) => MW.has(x.opcode));
|
|
433
|
+
if (poss.some((p) => memWriteBetween(op, p!, isWrite))) {
|
|
318
434
|
materialize.add(op);
|
|
319
435
|
}
|
|
320
436
|
continue;
|
|
321
437
|
}
|
|
322
|
-
const pos =
|
|
323
|
-
if (!pos) {
|
|
324
|
-
materialize.add(op);
|
|
325
|
-
continue;
|
|
326
|
-
}
|
|
438
|
+
const pos = poss[0]!;
|
|
327
439
|
// A between-op is a BARRIER when it renders as a sequenced statement the def would cross:
|
|
328
440
|
// stores/opaque always; a call/load that is dead (statement), materialized (statement), or
|
|
329
441
|
// inlined into a DIFFERENT statement. A sibling effect inlined into the SAME statement is
|
|
@@ -331,6 +443,11 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
|
|
|
331
443
|
// exactly as it originally chose to. Loads never bar a load (reads don't conflict).
|
|
332
444
|
const samePos = (q: { blk: Block; idx: number } | null) => q !== null && q.blk === pos.blk && q.idx === pos.idx;
|
|
333
445
|
const isBarrier = (x: Op): boolean => {
|
|
446
|
+
// Value-home axis: a store/astore this read is PROVABLY disjoint from (a different named
|
|
447
|
+
// global) does not sequence against it, so the read may still render at its use.
|
|
448
|
+
if (barsThisRead && (x.opcode === 'store' || x.opcode === 'astore') && !barsThisRead(x)) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
334
451
|
if (x.opcode === 'store') {
|
|
335
452
|
// A store to a PROVABLY-DISJOINT slot of the same base never aliases the load: same
|
|
336
453
|
// base SSA value, both constant offset+width, ranges non-overlapping (the everyday
|
|
@@ -362,49 +479,18 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
|
|
|
362
479
|
}
|
|
363
480
|
return false;
|
|
364
481
|
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
return true;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
return false;
|
|
372
|
-
};
|
|
373
|
-
if (pos.blk === b) {
|
|
374
|
-
if (gapDirty(b.ops, oi + 1, pos.idx)) {
|
|
375
|
-
materialize.add(op);
|
|
376
|
-
}
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
// Cross-block: a call's execution would become path-dependent — always materialize. A
|
|
380
|
-
// load may inline only if NO write exists on any DEF-AVOIDING def→render path (a path
|
|
381
|
-
// re-crossing the def is the next dynamic instance): the def block's tail, the render
|
|
382
|
-
// block's head, and every block between; a render block cyclic WITHOUT passing the def
|
|
383
|
-
// (an inner loop around the render) is checked in full.
|
|
384
|
-
if (isCall) {
|
|
482
|
+
// A CROSS-BLOCK call's execution would become path-dependent — always materialize. Within
|
|
483
|
+
// its own block a call is judged like everything else, by the barrier scan below.
|
|
484
|
+
if (isCall && pos.blk !== b) {
|
|
385
485
|
materialize.add(op);
|
|
386
486
|
continue;
|
|
387
487
|
}
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
for (const x of reachAvoiding(b, b)) {
|
|
391
|
-
if (x === pos.blk && !reachAvoiding(pos.blk, b).has(pos.blk)) {
|
|
392
|
-
continue;
|
|
393
|
-
} // acyclic render block: head checked
|
|
394
|
-
if (x !== pos.blk && !reachAvoiding(x, b).has(pos.blk)) {
|
|
395
|
-
continue;
|
|
396
|
-
} // not on a def→render path
|
|
397
|
-
if (gapDirty(x.ops, 0, x.ops.length)) {
|
|
398
|
-
dirty = true;
|
|
399
|
-
break;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
if (dirty) {
|
|
488
|
+
// Otherwise: inline only if no barrier stands on any def-avoiding def→render path.
|
|
489
|
+
if (memWriteBetween(op, pos, isBarrier)) {
|
|
404
490
|
materialize.add(op);
|
|
405
491
|
}
|
|
406
492
|
}
|
|
407
493
|
}
|
|
408
494
|
}
|
|
409
|
-
return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom };
|
|
495
|
+
return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween };
|
|
410
496
|
}
|