@asmlift/core 0.7.0 → 0.8.1

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.
Files changed (44) hide show
  1. package/README.md +48 -24
  2. package/package.json +1 -1
  3. package/src/backend/pascal.ts +2 -2
  4. package/src/codegen-flags.ts +640 -0
  5. package/src/frontend/disasm.ts +141 -11
  6. package/src/frontend/high-half.ts +149 -0
  7. package/src/frontend/mips.ts +458 -209
  8. package/src/frontend/ppc.ts +332 -67
  9. package/src/frontend/reloc-symbol.ts +109 -0
  10. package/src/frontend/splat.ts +56 -18
  11. package/src/frontend/ssa.ts +126 -29
  12. package/src/frontend/stackargs.ts +420 -0
  13. package/src/frontend/thumb.ts +207 -230
  14. package/src/ir/core.ts +62 -3
  15. package/src/ir/opcodes.ts +9 -0
  16. package/src/ir/parse.ts +7 -1
  17. package/src/l3/advance.ts +2 -2
  18. package/src/l3/argbase.ts +2 -2
  19. package/src/l3/argcopy.ts +269 -0
  20. package/src/l3/ast.ts +45 -1
  21. package/src/l3/basecse.ts +2 -2
  22. package/src/l3/coalesce.ts +109 -52
  23. package/src/l3/scopebase.ts +4 -4
  24. package/src/l3/tailret.ts +70 -0
  25. package/src/l3/unmerge.ts +2 -2
  26. package/src/l3/unreduce.ts +2 -1
  27. package/src/mangle.ts +49 -0
  28. package/src/pattern/engine.ts +128 -13
  29. package/src/pipeline.ts +22 -11
  30. package/src/raise/extscale.ts +5 -2
  31. package/src/raise/paramwidth.ts +111 -3
  32. package/src/raise/pre-recovery.ts +11 -1
  33. package/src/raise/retsink.ts +8 -4
  34. package/src/raise/tailsink.ts +17 -2
  35. package/src/rank-declare.ts +17 -9
  36. package/src/rank.ts +54 -20
  37. package/src/structure/retspell.ts +95 -0
  38. package/src/structure/structure.ts +12 -3
  39. package/src/structure/switch-recover.ts +1 -1
  40. package/src/target.ts +224 -14
  41. package/src/trace.ts +27 -18
  42. package/src/variation-definitions.ts +52 -2
  43. package/src/variation-gates.ts +3 -0
  44. package/src/variation-tokens.ts +1 -0
@@ -0,0 +1,70 @@
1
+ // L3 spelling pass: drop a void `return;` the assembly says the source never wrote.
2
+ //
3
+ // `structure/retspell.ts` marks a return `unspelled` when the machine reached that epilogue without
4
+ // the `b <epilogue>` a source `return;` compiles to — the reading, and what makes it decidable, is
5
+ // stated there. This pass is the other half: a mark alone is not a licence to delete, because a
6
+ // `return` is still a control transfer in the STATEMENT tree. Deleting one that something follows
7
+ // lets control run on into it, and that is a semantic change, not a spelling one.
8
+ //
9
+ // So the licence is TAIL POSITION and nothing weaker: nothing executes after the statement on any
10
+ // path, all the way up to the end of the function. A return ending a loop body, a `switch` arm, or
11
+ // an `if` arm the function continues past is left exactly where it is, whatever the mark says.
12
+ //
13
+ // TWO SHAPES REFUSE EVEN IN TAIL POSITION, both because removing the statement would leave a
14
+ // statement list that has to be re-spelled rather than shortened:
15
+ //
16
+ // - the sole statement of a `then` arm. `if (c) { }` is not the answer — `if (!c) { … }` is, and
17
+ // which sense a compiler emits is a per-SITE question this pass holds nothing to decide.
18
+ // - the whole function body. A body is not a place a statement can vanish from.
19
+ //
20
+ // An `else` arm IS allowed to empty: an `if` with no else is the same statement, and both the
21
+ // printer (`backend/cfamily.ts`) and `l3/dce.ts` already spell `else: []` that way.
22
+ //
23
+ // THE REFUSALS ARE PROSE, NOT A `Gate` TABLE, and that is the second of the three answers
24
+ // `docs/level-tower.md` sanctions rather than an omission. A table buys the ablation — drop this
25
+ // rule and something breaks — as a test instead of a claim, and it is worth building for a refusal a
26
+ // round has HAD to instrument. None of these was: each is a property of one candidate's own
27
+ // position, and `test/tailret.test.ts` holds a test per refusal, which is the ablation the table
28
+ // would have bought. Convert them when a round has to argue about one.
29
+ //
30
+ // Ordering: after `l3/tailmerge.ts`, whose peel moves `assign`/`store`/`exprstmt` only, so a
31
+ // `return` ending an arm blocks it — run first and this pass hands tailmerge arms it could not
32
+ // otherwise peel, changing rows that have nothing to do with returns. Before `l3/dce.ts`, so its
33
+ // branch peephole sees the `else` this pass empties. `pipeline.ts` commits that order.
34
+ import type { SFn, Stmt } from './ast';
35
+
36
+ /** Can this statement be deleted outright — a void return the asm did not spell? */
37
+ const isDroppable = (s: Stmt): boolean => s.k === 'return' && s.value === undefined && s.unspelled === true;
38
+
39
+ /** `stmts` rewritten. `isTail` — control falls off the END of the function after this list, so its
40
+ * last statement is in tail position. `mayEmpty` — the list is allowed to come back empty. */
41
+ function walk(stmts: Stmt[], isTail: boolean, mayEmpty: boolean): Stmt[] {
42
+ const out = stmts.map((s, i) => {
43
+ const tail = isTail && i === stmts.length - 1;
44
+ switch (s.k) {
45
+ case 'if':
46
+ return { ...s, then: walk(s.then, tail, false), else: walk(s.else, tail, true) };
47
+ case 'while':
48
+ case 'dowhile':
49
+ case 'for':
50
+ // A return inside a loop is never in tail position: the statement after it is the next
51
+ // iteration.
52
+ return { ...s, body: walk(s.body, false, false) };
53
+ case 'switch':
54
+ // Nor inside a `switch`: dropping an arm's return diverts it into the arm below.
55
+ return {
56
+ ...s,
57
+ cases: s.cases.map((c) => ({ ...c, body: walk(c.body, false, false) })),
58
+ ...(s.default ? { default: walk(s.default, false, false) } : {}),
59
+ };
60
+ default:
61
+ return s;
62
+ }
63
+ });
64
+ const last = out[out.length - 1];
65
+ return isTail && last !== undefined && isDroppable(last) && (out.length > 1 || mayEmpty) ? out.slice(0, -1) : out;
66
+ }
67
+
68
+ export function dropUnspelledReturns(sfn: SFn): SFn {
69
+ return { ...sfn, body: walk(sfn.body, true, false) };
70
+ }
package/src/l3/unmerge.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  // own definitions into the join statement and duplicating it back recovers that spelling.
14
14
  //
15
15
  // A VARIATION, NOT A DEFAULT, and the reason is the tower's: the LIFTED TREE underdetermines the
16
- // source. This is a compiler claim, so it was compiled — agbcc `gcc 2.9-arm-000512` at
17
- // `TOOLCHAIN.agbccFlags`, both spellings, `diff` on the `.s`:
16
+ // source. This is a compiler claim, so it was compiled — agbcc `gcc 2.9-arm-000512` at its
17
+ // canonical flags, both spellings, `diff` on the `.s`:
18
18
  //
19
19
  // - the example above (an ADDRESS temp and a VALUE temp, against `*gA1 = gB1;` in one arm and
20
20
  // `*gA2 = gB2;` in the other, `v17` typed as the store's own type) is BYTE-IDENTICAL. There
@@ -196,6 +196,7 @@ import {
196
196
  exprEquals,
197
197
  exprHasEffect,
198
198
  exprReadsVolatile,
199
+ isLoop,
199
200
  mapExprChildren,
200
201
  stmtChildren,
201
202
  stmtExprs,
@@ -813,7 +814,7 @@ export function unreduceAccumulators(
813
814
 
814
815
  for (let li = 0; li < body.length; li++) {
815
816
  const loop = body[li];
816
- if (loop.k !== 'while' && loop.k !== 'dowhile' && loop.k !== 'for') {
817
+ if (!isLoop(loop)) {
817
818
  continue;
818
819
  }
819
820
  // the counter: one name stepped by a constant, whose start stands above the loop
package/src/mangle.ts CHANGED
@@ -129,6 +129,55 @@ export function demangle(sym: string): CppSig | null {
129
129
  return { name, cls, params };
130
130
  }
131
131
 
132
+ /** A class qualifier off the front of `s` — `12RefCountable`, or `Q23zen17particleGenerator` for a
133
+ * nested one — as its scope names and the remaining string; null when `s` does not open with one. */
134
+ function parseQualifier(s: string): { scopes: string[]; rest: string } | null {
135
+ const nested = /^Q(\d)/.exec(s);
136
+ let count = nested === null ? 1 : Number(nested[1]);
137
+ let rest = nested === null ? s : s.slice(2);
138
+ const scopes: string[] = [];
139
+ for (; count > 0; count--) {
140
+ const digits = /^(\d+)/.exec(rest);
141
+ if (digits === null) {
142
+ return null;
143
+ }
144
+ const len = Number(digits[1]);
145
+ const id = rest.slice(digits[1].length, digits[1].length + len);
146
+ if (len === 0 || id.length !== len) {
147
+ return null;
148
+ }
149
+ scopes.push(id);
150
+ rest = rest.slice(digits[1].length + len);
151
+ }
152
+ return { scopes, rest };
153
+ }
154
+
155
+ /** The name a CodeWarrior function symbol has in its SOURCE — `PlayerState::getStartHour` for
156
+ * `getStartHour__11PlayerStateFv`, `zen::particleGenerator::RotAxisX` for a nested class, and the
157
+ * constructor and destructor spelled as they are declared (`RefCountable::RefCountable`,
158
+ * `System::~System`). Only the name: unlike `demangle` it reads no parameter code, so it answers
159
+ * for the references, arrays and const qualifiers that one refuses. Null where `sym` is not a
160
+ * mangled function, and for an operator, whose source name is not an identifier. */
161
+ export function demangledName(sym: string): string | null {
162
+ for (let at = sym.indexOf('__', 1); at > 0; at = sym.indexOf('__', at + 1)) {
163
+ const name = sym.slice(0, at);
164
+ const rest = sym.slice(at + 2);
165
+ const qualified = /^[\dQ]/.test(rest) ? parseQualifier(rest) : { scopes: [], rest };
166
+ if (qualified === null || !/^C?F/.test(qualified.rest)) {
167
+ continue;
168
+ }
169
+ const { scopes } = qualified;
170
+ const cls = scopes.at(-1);
171
+ // every other special name — `__as`, `__nw`, `__pl` — is an operator
172
+ const member = name === '__ct' ? cls : name === '__dt' && cls !== undefined ? `~${cls}` : name;
173
+ if (member === undefined || member.startsWith('__')) {
174
+ return null;
175
+ }
176
+ return [...scopes, member].join('::');
177
+ }
178
+ return null;
179
+ }
180
+
132
181
  /** Spell a CppType as C++ source (`Vec *`, `unsigned int`). */
133
182
  export function spellType(t: CppType): string {
134
183
  return t.base + (t.ptr ? ' ' + '*'.repeat(t.ptr) : '');
@@ -91,16 +91,30 @@ export interface RewritePattern {
91
91
  * is one inhabitant. The direction was measured on ONE compiler, so `validatePattern` pins the
92
92
  * declaring pattern's compiler gate to that set; widening it fails loud. */
93
93
  unsequencedRightFirst?: [string, string];
94
+ /** The compilers measured to RECOMPUTE this idiom instead of CSEing it back, when an interior op
95
+ * of the match survives the fold because something outside still reads it. On one of these the
96
+ * fold refuses that shape (`sharesInterior`, which carries the disassemblies); everywhere else
97
+ * it is byte-neutral and refusing it costs matches, so the list is opt-in and per-compiler. A
98
+ * compiler absent from it was either measured neutral or not measured — `validatePattern`
99
+ * refuses a name that is not in `applies.compilers`, where the fold cannot fire anyway. */
100
+ recomputesSharedInterior?: string[];
101
+ }
102
+
103
+ /** What the pattern layer reads off a target. STRUCTURAL on purpose: the pattern set is
104
+ * serializable data and does not import `target.ts`, so a `TargetDescription` satisfies this by
105
+ * shape. It is the layer's ONE channel — `patternApplies` and `applyPattern` both take it, so a
106
+ * caller that raises through the patterns cannot hand one of them a target and the other nothing. */
107
+ export interface PatternTarget {
108
+ id: string;
109
+ compiler: string;
110
+ capabilities: { hwDivide: boolean; hwFloat: boolean };
94
111
  }
95
112
 
96
113
  /** Does this pattern apply to `target`? Every DECLARED field must match: the ISA (so an idiom can be
97
114
  * pinned to one frontend), the compiler set (so an idiom fires only for the compilers that emit
98
115
  * it — the reason MIPS+IDO and MIPS+GCC are distinguishable despite one frontend), and every
99
116
  * declared capability. An omitted field is unconstrained. */
100
- export function patternApplies(
101
- p: RewritePattern,
102
- target: { id: string; compiler: string; capabilities: { hwDivide: boolean; hwFloat: boolean } },
103
- ): boolean {
117
+ export function patternApplies(p: RewritePattern, target: PatternTarget): boolean {
104
118
  if (p.applies.isa && p.applies.isa !== target.id) {
105
119
  return false;
106
120
  }
@@ -271,9 +285,27 @@ export const MUL_CONST_PATTERNS: RewritePattern[] = [MUL_SHIFT_ADD, MUL_SHIFT_SU
271
285
  // lsr/asr #24`). The naive lift prints `x << 24 >> 24` — but C's `>>` over the s32-typed value is
272
286
  // ARITHMETIC, so the UNSIGNED case recompiles with `asr` where the target has `lsr`: a miscompile
273
287
  // (tou8/zextb/tou16 nonmatch). Folding to a cast op both fixes that and reads correctly; recompiling
274
- // `(u8)x` reproduces `lsl;lsr`. Gated to agbcc: on IDO/GCC the zero-extend is `andi`/`and` (not a
275
- // shift pair) and `(u8)x` lowers to `andi` there — so this shift-pair shape is agbcc's alone, and the
276
- // fold must not touch the other compilers (where it would change `srl`↔`andi`). `k = 32 - w`.
288
+ // `(u8)x` reproduces `lsl;lsr`. `k = 32 - w`.
289
+ //
290
+ // THE TWO SIGNEDNESSES ARE GATED SEPARATELY, because MIPS spells them differently and the pair of
291
+ // gates is the measurement. Compiled at each row's own flags — IDO 7.1 `-mips2 -O2 -32 -non_shared
292
+ // -G 0`, KMC gcc `-mips3 -O2`, gcc 2.7.2 `-mips3 -O1`:
293
+ //
294
+ // (s8)x sll v0,a0,0x18 ; sra v0,v0,0x18 (s16)x sll 0x10 ; sra 0x10 all three
295
+ // (u8)x andi v0,a0,0xff all three
296
+ //
297
+ // So the SIGN-extend is a shift pair on MIPS exactly as on agbcc, and folding it is byte-neutral
298
+ // there: the fold changes the printed spelling from `x << 24 >> 24` to `(s8)x` and recompiles to
299
+ // the same two instructions. The ZERO-extend is not — `andi` is not a shift pair, and folding an
300
+ // IDO `srl` to a `zext` would re-spell it as `andi`, a miscompile. So `zextPat` is pinned to agbcc
301
+ // alone, and the row that pin protects is `synthetic:zextb:ido7.1` — whose target IS that `andi`.
302
+ //
303
+ // mwcc/PowerPC is in NEITHER list: it has `extsb`/`extsh`, which the frontend lifts straight to
304
+ // `sext`, so the shift-pair shape is not one it emits and no measurement licenses the fold there.
305
+ /** The compilers measured to lower a SIGNED narrowing cast to a shift pair — see the pair of
306
+ * disassemblies above. A compiler outside this list keeps the raw shifts. */
307
+ export const SEXT_SHIFT_PAIR_COMPILERS = ['agbcc', 'ido', 'gcc'];
308
+
277
309
  const zextPat = (w: number, k: number): RewritePattern => ({
278
310
  id: `zext${w}`,
279
311
  applies: { compilers: ['agbcc'] },
@@ -282,7 +314,10 @@ const zextPat = (w: number, k: number): RewritePattern => ({
282
314
  });
283
315
  const sextPat = (w: number, k: number): RewritePattern => ({
284
316
  id: `sext${w}`,
285
- applies: { compilers: ['agbcc'] },
317
+ applies: { compilers: SEXT_SHIFT_PAIR_COMPILERS },
318
+ // IDO alone emits the `sll` twice when the pair's own `sll` has a second reader; agbcc and both
319
+ // MIPS GCCs CSE it back, byte-identically. `sharesInterior` carries the three-spelling table.
320
+ recomputesSharedInterior: ['ido'],
286
321
  match: { op: 'shr_s', attrEquals: { imm: k }, args: [{ op: 'shl', attrEquals: { imm: k }, args: [{ bind: 'X' }] }] },
287
322
  replaceWith: { op: 'sext', args: ['X'], attrs: { width: w } },
288
323
  });
@@ -296,7 +331,8 @@ const sextPat = (w: number, k: number): RewritePattern => ({
296
331
  * and never reaches the bitfield member recognizer (structure.ts, which matches the raw
297
332
  * `shr(shl(load))` shape only). Honest output, not a miscompile — the field just keeps the cast
298
333
  * spelling at those widths. Teaching the recognizer a zext/sext arm is the coverage extension if
299
- * a row ever needs it. */
334
+ * a row ever needs it. The SIGNED half of that shadow reaches MIPS too, where the symbol maps are:
335
+ * measured over the whole corpus, no row's emitted C moves for it (`pnpm bench sweep`). */
300
336
  export const CAST_PATTERNS: RewritePattern[] = [zextPat(8, 24), zextPat(16, 16), sextPat(8, 24), sextPat(16, 16)];
301
337
 
302
338
  // ── boolean-negation idiom ───────────────────────────────────────────────────────────────────
@@ -408,6 +444,9 @@ const COMMUTATIVE = new Set(['add', 'mul', 'and', 'or', 'xor', 'icmp_eq', 'icmp_
408
444
  interface Binds {
409
445
  values: Map<string, Value>;
410
446
  imms: Map<string, number>;
447
+ /** Every value matched by an `op` node of the pattern, root included — the ops this rewrite is
448
+ * about to REPLACE. `sharesInterior` below turns it into the fold's own legality condition. */
449
+ interior: Set<Value>;
411
450
  }
412
451
 
413
452
  function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): boolean {
@@ -430,6 +469,7 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
430
469
  if (!d || d.opcode !== node.op) {
431
470
  return false;
432
471
  }
472
+ b.interior.add(v);
433
473
  if (node.attrEquals) {
434
474
  for (const [k, val] of Object.entries(node.attrEquals)) {
435
475
  if (d.attrs[k] !== val) {
@@ -459,7 +499,7 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
459
499
  [0, 1],
460
500
  [1, 0],
461
501
  ] as const) {
462
- const trial: Binds = { values: new Map(b.values), imms: new Map(b.imms) };
502
+ const trial: Binds = { values: new Map(b.values), imms: new Map(b.imms), interior: new Set(b.interior) };
463
503
  if (tryMatch(node.args[0], d.operands[i], defs, trial) && tryMatch(node.args[1], d.operands[j], defs, trial)) {
464
504
  for (const [k, val] of trial.values) {
465
505
  b.values.set(k, val);
@@ -467,6 +507,9 @@ function tryMatch(node: MatchNode, v: Value, defs: Map<Value, Op>, b: Binds): bo
467
507
  for (const [k, val] of trial.imms) {
468
508
  b.imms.set(k, val);
469
509
  }
510
+ for (const val of trial.interior) {
511
+ b.interior.add(val);
512
+ }
470
513
  return true;
471
514
  }
472
515
  }
@@ -523,6 +566,17 @@ function validatePattern(pat: RewritePattern): void {
523
566
  );
524
567
  }
525
568
  }
569
+ const recompiles = pat.recomputesSharedInterior;
570
+ if (recompiles?.length) {
571
+ const on = pat.applies.compilers;
572
+ const inert = on === undefined ? [] : recompiles.filter((c) => !on.includes(c));
573
+ if (inert.length) {
574
+ throw new Error(
575
+ `pattern '${pat.id}' names [${inert.join(', ')}] in 'recomputesSharedInterior' but does not apply to ` +
576
+ `${on === undefined ? 'them' : `[${on.join(', ')}]`} — the refusal could never fire, so the declaration is inert`,
577
+ );
578
+ }
579
+ }
526
580
  validated.add(pat);
527
581
  }
528
582
 
@@ -621,8 +675,66 @@ function reordersUnsequenced(
621
675
  return false;
622
676
  }
623
677
 
624
- /** Apply one pattern greedily to a fixed point. Returns the number of rewrites. */
625
- export function applyPattern(fn: Fn, pat: RewritePattern): number {
678
+ /** Would this fold RECOMPUTE the idiom rather than re-spell it leave an INTERIOR op standing
679
+ * because something outside the match still reads it, while the replacement computes the same
680
+ * thing again?
681
+ *
682
+ * Every pattern here is licensed by a compiled pair showing that the replacement's C recompiles to
683
+ * the SAME instructions. That pair is measured on the shape where the fold's interior ops DIE with
684
+ * it — `dce` below removes exactly the ops nothing else reads. An interior op with a surviving
685
+ * reader does not die, and whether the recompiling compiler then emits the work twice or CSEs it
686
+ * back is a COMPILER fact, so it was compiled rather than reasoned. One function, three spellings,
687
+ * each toolchain at its own canonical flags:
688
+ *
689
+ * int r1(int x){ int y = x << 24; return (y >> 24) + y; }
690
+ *
691
+ * ido7.1 raw `(a0 << 24 >> 24) + (a0 << 24)` 4 words == the object of the C above
692
+ * folded `(s8)a0 + (a0 << 24)` 5 words an extra `sll`
693
+ * gcc2.7.2kmc raw 4 words / folded 4 words, BYTE-IDENTICAL
694
+ * gcc2.7.2 raw 4 words / folded 4 words, BYTE-IDENTICAL
695
+ * agbcc raw and folded identical (`lsl;asr;add`) — one `lsl` either way
696
+ *
697
+ * So only IDO recomputes, and `recomputesSharedInterior` carries that list. On the other three the
698
+ * fold stays byte-neutral in this shape and refusing it would COST: agbcc's `s16 i; i++` is one
699
+ * `lsl #16` read by both the write-back's `lsr` and the comparison's `asr`, and a blanket refusal
700
+ * measured `synthetic:{membnarrow,sibwalk}:agbcc` out of MATCH and `kleod:sub_0800A5B8:agbcc` from
701
+ * 173 to 179.
702
+ *
703
+ * The ROOT is exempt and must be: `replaceAllUsesWith` brings its readers along, which is what
704
+ * makes a fold a re-spelling at all. It is the interior — the operands the match walked THROUGH —
705
+ * that this asks about. CONSTANTS REACH IT ASYMMETRICALLY, and both answers are the wanted ones:
706
+ * a `constImm` node binds a literal and returns before the record, so a shared constant the
707
+ * replacement re-spells as a literal never refuses a fold — nothing keeps a register alive for a
708
+ * literal. An `{ op: 'const' }` node is walked like any other op and IS interior, so one the
709
+ * replacement drops while another op still reads it counts as a survivor and refuses, which is the
710
+ * conservative direction for a materialization the compiler may or may not rematerialize.
711
+ *
712
+ * Refusing leaves the raw ops standing, which is the spelling that was byte-exact before any fold
713
+ * existed — a worse-READING answer, never a worse-scoring one. */
714
+ function sharesInterior(fn: Fn, root: Op, interior: Set<Value>, defs: Map<Value, Op>): boolean {
715
+ const matched = new Set([...interior].map((v) => defs.get(v)));
716
+ for (const b of fn.blocks) {
717
+ for (const o of b.ops) {
718
+ if (matched.has(o)) {
719
+ continue; // a read from INSIDE the idiom is one this rewrite is replacing
720
+ }
721
+ for (const v of [...o.operands, ...o.successors.flatMap((x) => x.args)]) {
722
+ if (v !== root.results[0] && interior.has(v)) {
723
+ return true;
724
+ }
725
+ }
726
+ }
727
+ }
728
+ return false;
729
+ }
730
+
731
+ /** Apply one pattern greedily to a fixed point. Returns the number of rewrites.
732
+ *
733
+ * `target` is the same one `patternApplies` filtered with, REQUIRED because exactly one refusal
734
+ * reads it (`recomputesSharedInterior`, whose answer is per-compiler). Both towers that fold —
735
+ * `pipeline.ts` and `trace.ts` — must hand it over or the traced tower shows a fold `decompile()`
736
+ * refuses; required, a caller that omits it is a type error rather than a wrong answer. */
737
+ export function applyPattern(fn: Fn, pat: RewritePattern, target: PatternTarget): number {
626
738
  validatePattern(pat);
627
739
  let count = 0,
628
740
  changed = true;
@@ -635,10 +747,13 @@ export function applyPattern(fn: Fn, pat: RewritePattern): number {
635
747
  if (op.results.length !== 1) {
636
748
  continue;
637
749
  }
638
- const binds: Binds = { values: new Map(), imms: new Map() };
750
+ const binds: Binds = { values: new Map(), imms: new Map(), interior: new Set() };
639
751
  if (!tryMatch(pat.match, op.results[0], defs, binds)) {
640
752
  continue;
641
753
  }
754
+ if (pat.recomputesSharedInterior?.includes(target.compiler) && sharesInterior(fn, op, binds.interior, defs)) {
755
+ continue;
756
+ }
642
757
  if (pat.unsequencedRightFirst && reordersUnsequenced(fn, op, pat.unsequencedRightFirst, binds, defs, pat.id)) {
643
758
  continue;
644
759
  }
package/src/pipeline.ts CHANGED
@@ -21,6 +21,7 @@ import { LanguageBackend, SFn, gapReasonFor, walkExprs } from './l3/ast';
21
21
  import { BASECSE_GATES, hoistBaseLocals } from './l3/basecse';
22
22
  import { eliminateDeadStores } from './l3/dce';
23
23
  import { mergeCommonTails } from './l3/tailmerge';
24
+ import { dropUnspelledReturns } from './l3/tailret';
24
25
  import { DEFAULT_IDIOM_PATTERNS, RewritePattern, applyPattern, dce, patternApplies } from './pattern/engine';
25
26
  import { type FnProto, type Prototypes, prototypesFromSymbols } from './proto';
26
27
  import { RaiseUnsupportedError } from './raise/errors';
@@ -212,7 +213,7 @@ export function applyIdiomPatterns(fn: Fn, target: TargetDescription, patterns?:
212
213
  const active = (patterns ?? DEFAULT_IDIOM_PATTERNS).filter((p) => patternApplies(p, target));
213
214
  let hits = 0;
214
215
  for (const p of active) {
215
- hits += applyPattern(fn, p);
216
+ hits += applyPattern(fn, p, target);
216
217
  }
217
218
  if (active.length) {
218
219
  dce(fn);
@@ -348,6 +349,23 @@ function attributeOpaques<T>(fn: Fn, body: () => T): T {
348
349
  }
349
350
  }
350
351
 
352
+ /** The committed readability/quality rewrites, in the order they are committed in: merge a
353
+ * statement common to every arm of an if, drop the void returns the assembly did not spell, drop
354
+ * dead stores (whose empty-then peephole flips the arm the merge empties), then hoist each leaf
355
+ * base the DEFAULT gate table admits into a typed local pointer.
356
+ *
357
+ * THE ORDER IS OBSERVABLE, not a preference: drop the returns before the merge and the same arms
358
+ * become peelable, emptying both of them. `l3/tailret.ts` states the mechanism, and
359
+ * `test/tailret.test.ts` pins the difference and which side of it this function is on.
360
+ *
361
+ * `hoistBaseLocals`' two arguments are SPELLED, defaults or not: this is the one call to that pass
362
+ * that is committed rather than offered, so it is the one whose gate table and whose placement can
363
+ * cost a MATCH instead of a candidate (docs/level-tower.md). A committed policy that reads as
364
+ * "whatever the default is" is the policy nobody reviews. */
365
+ export function readabilityRewrites(raw: SFn): SFn {
366
+ return hoistBaseLocals(eliminateDeadStores(dropUnspelledReturns(mergeCommonTails(raw))), BASECSE_GATES, 'head');
367
+ }
368
+
351
369
  /** Stage 4 — structure + its boundary contracts, always as a pair. */
352
370
  export function structureChecked(
353
371
  fn: Fn,
@@ -365,16 +383,9 @@ export function structureChecked(
365
383
  assertDerefsTyped(raw);
366
384
  assertLocalsWritten(raw);
367
385
  assertEffectsPreserved(fn, raw);
368
- // Then the readability/quality rewrites: merge a statement common to every arm of an if,
369
- // drop dead stores (whose empty-then peephole flips the arm the merge empties), then hoist each
370
- // leaf base the DEFAULT gate table admits into a typed local pointer. The hoist moves the deref
371
- // cast from each `index` node onto the local's initializer, so re-validate deref typing on the
372
- // rewritten tree.
373
- // Both arguments SPELLED, defaults or not: this is the one call to this pass that is committed
374
- // rather than offered, so it is the one whose gate table and whose placement can cost a MATCH
375
- // instead of a candidate (docs/level-tower.md). A committed policy that reads as "whatever the
376
- // default is" is the policy nobody reviews.
377
- const sfn = hoistBaseLocals(eliminateDeadStores(mergeCommonTails(raw)), BASECSE_GATES, 'head');
386
+ // The hoist moves the deref cast from each `index` node onto the local's initializer, so
387
+ // re-validate deref typing on the rewritten tree.
388
+ const sfn = readabilityRewrites(raw);
378
389
  assertDerefsTyped(sfn);
379
390
  // Re-checked after the readability rewrites for the same reason deref typing is: a pass that
380
391
  // merges arms or drops statements must not be able to lose or duplicate a call.
@@ -157,8 +157,11 @@ export function scaledExtensionOf(op: Op | undefined, defs: Map<Value, Op>): Sca
157
157
  return { src: inner.operands[0], width: 32 - l, signed: op.opcode === 'shr_s', shift: l - r, inner };
158
158
  }
159
159
 
160
- /** Does `target` lower a narrowing cast to a shift pair the gate the fold shares with the cast
161
- * idiom itself. */
160
+ /** Does `target` lower BOTH narrowing casts to a shift pair? The conjunction is what the two
161
+ * consumers need: a fused pair carries either signedness and this fold re-splits it without
162
+ * knowing which. It is a stricter question than either cast pattern's own gate, because
163
+ * `pattern/engine.ts` measures the halves separately — MIPS spells `(s8)x` as a shift pair and
164
+ * `(u8)x` as `andi`, so every MIPS compiler answers `false` here while folding the signed half. */
162
165
  export const foldsShiftPairCasts = (target: TargetDescription): boolean =>
163
166
  CAST_PATTERNS.every((p) => patternApplies(p, target));
164
167
 
@@ -21,6 +21,76 @@
21
21
  // narrow parameter with the `extsb`/`extsh` the frontend lifts to the same op, and the synthetic
22
22
  // `sextb`/`tos8` rows keep matching on that toolchain through this pass.
23
23
  //
24
+ // BUT THE PROLOGUE TEST IS NOT UNIVERSAL EVIDENCE, AND ON MIPS IT IS NO EVIDENCE AT ALL. Everything
25
+ // above reads the extension's POSITION, which works only where the compiler puts a declaration's
26
+ // extension somewhere a body cast's never goes. IDO 7.1 at -O2 does not: it leads the function with
27
+ // the `sll` for BOTH spellings. Two OTHER facts separate them there, and it takes both — each one
28
+ // alone is refuted by a compiled counterexample, at the row's own flags:
29
+ //
30
+ // home store widened in place
31
+ // int f(s8 x){ return x; } sw a0 sll a0,a0,0x18 the declaration
32
+ // int f(s32 x){ return (s8)x; } — sll v0,a0,0x18 a body cast
33
+ // int f(int x){ x = (signed char)x; return x; } — sll a0,a0,0x18 NOT homed
34
+ // int f(long long x){ return (signed char)x; } sw a1 sll v0,a1,0x18 NOT in place
35
+ //
36
+ // The last two lines are why the conjunction is the claim. A DEAD ABI ARGUMENT HOME STORE on its
37
+ // own is not "declared narrow" — IDO emits one wherever the incoming register value goes unused,
38
+ // whatever that value's type:
39
+ //
40
+ // int unused1(int x){ return 7; } sw a0,0(sp) / jr ra / li v0,7
41
+ // int ptr1(int *p, int y){ return y; } sw a0,0(sp) / jr ra / move v0,a1
42
+ // int ll2i(long long x){ return (int)x; } sw a0,0(sp) / sw a1,4(sp) / jr ra / move v0,a1
43
+ // int used2(int x, int y){ return x+y; } jr ra / addu v0,a0,a1 (no store at all)
44
+ //
45
+ // — so the store says the incoming register was not consumed, not that anything was declared
46
+ // narrow, and on the `long long` line that is a 64-bit parameter whose low half the extension would
47
+ // narrow to `s8`; `widened-elsewhere` is what refuses it. WIDENING IN PLACE on its own is not
48
+ // "declared narrow" either: the third line is a plain `int` the source re-assigns, and only the
49
+ // absent home store tells it from the first.
50
+ //
51
+ // The conjunction is NECESSARY-and-measured, not necessary-and-sufficient, and it errs toward
52
+ // refusing: `int m2(signed char a){ int i,s=0; for(i=0;i<10;i++) s+=arr[i]+a; return s; }` is a
53
+ // narrow declaration IDO widens into a SCRATCH register under register pressure (`sll a1,a0,0x18`),
54
+ // so this pass leaves its parameter wide and re-spells the cast. That is a worse-reading answer,
55
+ // not a wrong one.
56
+ //
57
+ // THE PAIR SAYS A NARROW DECLARATION EXISTS, NOT WHICH ONE. The WIDTH comes from the extension,
58
+ // and where a wider mask sits on that extension's result the declaration could have been the wider
59
+ // type — `int f(u16 x){ x = (signed char)x; return x; }` and `int f(s8 x){ return x & 0xffff; }`
60
+ // are ONE object at the row's own flags (`sw a0,0(sp) / sll a0,a0,0x18 / sra a0,a0,0x18 / andi
61
+ // v0,a0,0xffff`), so the asm decides nothing between them and this pass answers `s8`. Refusing on
62
+ // that disagreement is not the repair, because the spelling a refusal falls back to is a DIFFERENT
63
+ // object: `int f(int a){ return ((a << 24) >> 24) & 0xffff; }` is four words with no home store and
64
+ // both shifts in `v0`, so the refusal would cost the byte match under BOTH readings. Where the
65
+ // caller declares the parameter `proto-width` takes the tiebreak; where nobody does, the two
66
+ // readings recompile alike and only a PROTOTYPED CALL SITE of this function could tell them apart.
67
+ //
68
+ // Ungated, this pass narrows the body cast too and loses `synthetic:tos8:ido7.1`, a MATCH. The two
69
+ // MIPS GCCs are a third case again: their two spellings are one BYTE-IDENTICAL object, so nothing
70
+ // in the asm decides the width and the honest answer is to leave the extension standing.
71
+ //
72
+ // So which fact settles the width is a per-COMPILER question, asked as
73
+ // `compilerBehaviors.narrowParamWitness` and answered by `no-declaration-witness`, `unhomed-param`
74
+ // and `widened-elsewhere` below. Both facts it reads are destroyed at lift and survive as the
75
+ // frontend's `Fn.paramEvidence` (ir/core.ts).
76
+ //
77
+ // `not-prologue` STILL FIRES ON SUCH A TARGET, and it costs rather than protects there. IDO's
78
+ // scheduler interleaves the widenings of several narrow parameters with the arithmetic that
79
+ // consumes the earlier ones, and the scan below stops at the first value-reading op, so only the
80
+ // LEADING parameters are seen as prologue:
81
+ //
82
+ // int d4(s8 a, s8 b, s8 c){ return a+b+c; } sll a1 / sll a0 / sll a2 / sra a0 / sra a1 /
83
+ // addu t6,a0,a1 / sra a2 / addu v0,t6,a2
84
+ // recovered s32 d4(s8 a0, s8 a1, s32 a2) { return a0 + a1 + (s8)a2; }
85
+ // int m3(s16 a, s16 b, s16 c, s16 d){ return a*b+c*d; } all four widenings precede the first
86
+ // recovered s32 m3(s16 a0, s16 a1, s16 a2, s16 a3) multiply — all four narrow
87
+ //
88
+ // So the limit is the SCHEDULE's, not the declaration's, and the gate answers in the refusing
89
+ // direction on the parameters it cuts off. Kept because it is the sound direction and because it is
90
+ // the only gate that keeps a body cast behind real body code from being read as a prologue widening
91
+ // on the position-witness targets; NO row on either tier is known to turn on the IDO half of it —
92
+ // `d4` above is a probe, not a row, and lifting the scan is a change with its own measurement to make.
93
+ //
24
94
  // WHAT THE PROLOGUE TEST CANNOT SEE, and why the declaration settles it. The scan steps over the
25
95
  // pure materializations agbcc interleaves among the extensions, so a constant the scheduler HOISTED
26
96
  // above a mid-body cast leaves `pb` looking like `pa`:
@@ -57,6 +127,7 @@ import { CAST_WIDTHS, MATERIALIZING_OPS } from '../ir/opcodes';
57
127
  import { T } from '../ir/types';
58
128
  import { type Gate, firstRejection } from '../l3/gates';
59
129
  import { type FnProto, declaredWidth } from '../proto';
130
+ import type { NarrowParamWitness } from '../target';
60
131
 
61
132
  /** What the gates below judge: one entry parameter and the extension that reads it. */
62
133
  export interface NarrowParamCandidate {
@@ -75,6 +146,12 @@ export interface NarrowParamCandidate {
75
146
  /** the extension is one raise/extscale.ts re-split from a fused pair whose `shl` the machine ran
76
147
  * behind a pool load — see FUSED BEHIND A POOL LOAD */
77
148
  fusedBehindPool: boolean;
149
+ /** what this compiler's object shows for a narrow declaration (target.ts `narrowParamWitness`) */
150
+ witness: NarrowParamWitness;
151
+ /** the machine stored this parameter to a stack slot nothing reads back (`ParamObservation`) */
152
+ homed: boolean;
153
+ /** the machine put the parameter's own widened value back in its argument register (`ParamObservation`) */
154
+ selfRedefined: boolean;
78
155
  }
79
156
 
80
157
  export const PARAM_WIDTH_GATES: readonly Gate<NarrowParamCandidate>[] = [
@@ -115,11 +192,34 @@ export const PARAM_WIDTH_GATES: readonly Gate<NarrowParamCandidate>[] = [
115
192
  },
116
193
  {
117
194
  id: 'not-prologue',
118
- why: 'an extension behind body code is where the SOURCE wrote the cast',
195
+ why: 'an extension behind body code is where the SOURCE wrote the cast — and on a schedule that interleaves them, the refusing answer',
119
196
  sound: true,
120
197
  guardedBy: 'param-width.test.ts: an extension behind a nullary call is body code',
121
198
  rejects: (c) => !c.inPrologue,
122
199
  },
200
+ {
201
+ id: 'no-declaration-witness',
202
+ why: 'this compiler spells a narrow declaration and a body cast the same way, so the asm decides nothing',
203
+ sound: true,
204
+ guardedBy:
205
+ 'param-width.test.ts: a compiler whose two spellings are one object refuses the narrowing, measured or not',
206
+ rejects: (c) => c.witness === 'none',
207
+ },
208
+ {
209
+ id: 'unhomed-param',
210
+ why: 'this compiler homes a narrow DECLARED parameter, so the absent home store proves the declaration was wide',
211
+ sound: true,
212
+ guardedBy: 'param-width.test.ts: a homing compiler refuses the parameter it did not home',
213
+ rejects: (c) => c.witness === 'home-store-and-in-place' && !c.homed,
214
+ },
215
+ {
216
+ id: 'widened-elsewhere',
217
+ why: 'this compiler widens a narrow DECLARED parameter in the argument register itself, so a widening that lands in a scratch register is not a declaration',
218
+ sound: true,
219
+ guardedBy:
220
+ 'param-width.test.ts: \u2026and refuses one it homed but widened SOMEWHERE ELSE \u2014 that is a 64-bit half',
221
+ rejects: (c) => c.witness === 'home-store-and-in-place' && !c.selfRedefined,
222
+ },
123
223
  {
124
224
  id: 'fused-behind-pool',
125
225
  why: 'a fused cast behind a pool load is body code if unsigned, and either width is the same object if signed',
@@ -144,15 +244,20 @@ function useCount(fn: Fn, v: Value): number {
144
244
  }
145
245
 
146
246
  /** Type an entry parameter at the width its prologue extension proves, and drop the extension.
147
- * `self` is the prototype the caller supplied for THIS function, if any; `fusedBehindPool` is
148
- * raise/extscale.ts's record of the extensions it re-split behind a pool load
247
+ * `witness` is what this compiler's object shows for a narrow declaration (target.ts
248
+ * `narrowParamWitness`); `self` is the prototype the caller supplied for THIS function, if any;
249
+ * `fusedBehindPool` is raise/extscale.ts's record of the extensions it re-split behind a pool load
149
250
  * (`ScaleRecord.behindPool`). Returns the number of parameters narrowed. */
150
251
  export function narrowEntryParams(
151
252
  fn: Fn,
253
+ witness: NarrowParamWitness,
152
254
  self?: FnProto,
153
255
  gates: readonly Gate<NarrowParamCandidate>[] = PARAM_WIDTH_GATES,
154
256
  fusedBehindPool: ReadonlySet<Op> = new Set(),
155
257
  ): number {
258
+ // ABSENT ⇒ NEITHER OBSERVATION, which is the refusing direction on a target that reads them: a
259
+ // function nobody measured (parsed IR, a hand-built fn) is never narrowed on evidence never taken.
260
+ const evidence = fn.paramEvidence;
156
261
  const entry = fn.blocks[0];
157
262
  const declared = Array.isArray(self?.params) ? self.params.map(declaredWidth) : [];
158
263
  const entryIsJoin = fn.blocks.some((b) => successorsOf(b).includes(entry));
@@ -188,6 +293,9 @@ export function narrowEntryParams(
188
293
  uses: useCount(fn, p),
189
294
  declared: declared[entry.params.indexOf(p)],
190
295
  fusedBehindPool: fusedBehindPool.has(op),
296
+ witness,
297
+ homed: evidence?.get(p)?.deadHome ?? false,
298
+ selfRedefined: evidence?.get(p)?.selfRedefined ?? false,
191
299
  };
192
300
  if (firstRejection(gates, c) !== null) {
193
301
  continue;
@@ -201,9 +201,19 @@ export const PRE_RECOVERY_PASSES: PreRecoveryPass[] = [
201
201
  ),
202
202
  dce: false,
203
203
  },
204
+ // The `target` argument is read by TWO of this pass's gates, and unlike `narrowlocal`'s single
205
+ // conjunct it is not a tuning knob: `narrowParamWitness` says WHICH fact in the object settles a
206
+ // parameter's declared width on this compiler, and a target that names none refuses outright.
204
207
  {
205
208
  id: 'paramwidth',
206
- run: (fn, self, _opts, _target, lifted) => narrowEntryParams(fn, self, PARAM_WIDTH_GATES, lifted.scales.behindPool),
209
+ run: (fn, self, _opts, target, lifted) =>
210
+ narrowEntryParams(
211
+ fn,
212
+ target.compilerBehaviors.narrowParamWitness ?? 'none',
213
+ self,
214
+ PARAM_WIDTH_GATES,
215
+ lifted.scales.behindPool,
216
+ ),
207
217
  dce: false,
208
218
  },
209
219
  // LAST, after every pass that can CLAIM what `extscale` exposed — the two width passes above take