@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/ir/alias.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// asmlift — memory DISJOINTNESS: the one place that answers "could this write change what that
|
|
2
|
+
// read sees". A pure query over L2 (typed SSA); no structuring or emission state.
|
|
3
|
+
//
|
|
4
|
+
// It exists because the answer was being given at three different strengths in three places, the
|
|
5
|
+
// weakest one governing the most common case (the materialization model's multi-render load rule,
|
|
6
|
+
// which barred on ANY write). A read that is barred by a store to an unrelated global is spelled
|
|
7
|
+
// as a named local the source never had — the "value home" defect the round-5 dogfood measured as
|
|
8
|
+
// its single highest cost. One predicate, one strength, one place to sharpen.
|
|
9
|
+
//
|
|
10
|
+
// The rule is deliberately NAME-based and deliberately narrow:
|
|
11
|
+
//
|
|
12
|
+
// • two DIFFERENT named globals are different objects, so a store through one can never change
|
|
13
|
+
// what a read of the other sees. That is a C guarantee about distinct declared objects, not a
|
|
14
|
+
// heuristic about what the compiler happened to do.
|
|
15
|
+
// • name comparison suffices because the pool promotion picks ONE canonical name per address,
|
|
16
|
+
// so a single cell cannot appear under two names within one function (frontend/thumb.ts).
|
|
17
|
+
// • anything that does not resolve to a name — a materialized base, a variable index, a pointer
|
|
18
|
+
// parameter — is unknown, and unknown BARS. A call or an `opaque` bars unconditionally: it may
|
|
19
|
+
// write anything.
|
|
20
|
+
//
|
|
21
|
+
// Being conservative here costs at most a match (an extra local the compiler would have folded);
|
|
22
|
+
// being wrong here is a silently wrong read. Every relaxation must keep that asymmetry.
|
|
23
|
+
import { type Op, type Value } from './core';
|
|
24
|
+
|
|
25
|
+
/** A byte cell of a named global: the symbol plus the byte offset within it. */
|
|
26
|
+
export interface GlobalCell {
|
|
27
|
+
name: string;
|
|
28
|
+
byte: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The named global cell an address value denotes, resolved through defs alone — `gaddr`, or
|
|
33
|
+
* `gaddr + const` in either operand order — plus the access's own `off`. Null when the address
|
|
34
|
+
* does not reduce to a name (a materialized base, a runtime index, a pointer): the caller must
|
|
35
|
+
* then treat it as unknown memory.
|
|
36
|
+
*/
|
|
37
|
+
export function globalCellOf(defs: Map<Value, Op>, addr: Value, off: number): GlobalCell | null {
|
|
38
|
+
const d = defs.get(addr);
|
|
39
|
+
if (d?.opcode === 'gaddr') {
|
|
40
|
+
return { name: d.attrs.sym as string, byte: off };
|
|
41
|
+
}
|
|
42
|
+
if (d?.opcode === 'add' && d.operands.length === 2) {
|
|
43
|
+
for (const [x, y] of [
|
|
44
|
+
[d.operands[0], d.operands[1]],
|
|
45
|
+
[d.operands[1], d.operands[0]],
|
|
46
|
+
] as const) {
|
|
47
|
+
const g = defs.get(x);
|
|
48
|
+
const c = defs.get(y);
|
|
49
|
+
if (g?.opcode === 'gaddr' && c?.opcode === 'const') {
|
|
50
|
+
return { name: g.attrs.sym as string, byte: (c.attrs.value as number) + off };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* "May op `x` write the global named `sym`?" — the predicate a read of `sym` must clear on every
|
|
59
|
+
* path between its def and each of its render positions (analysis.ts `memWriteBetween`).
|
|
60
|
+
*
|
|
61
|
+
* Calls and opaques always may. A store/astore may unless its base resolves to a DIFFERENT named
|
|
62
|
+
* global. Everything else (pure arithmetic, loads) never writes.
|
|
63
|
+
*/
|
|
64
|
+
export function mayWriteGlobal(defs: Map<Value, Op>, sym: string): (x: Op) => boolean {
|
|
65
|
+
return (x: Op): boolean => {
|
|
66
|
+
if (x.opcode === 'call' || x.opcode === 'opaque') {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (x.opcode !== 'store' && x.opcode !== 'astore') {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const t = globalCellOf(defs, x.operands[0], 0);
|
|
73
|
+
return !(t && t.name !== sym);
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/ir/opcodes.ts
CHANGED
|
@@ -108,8 +108,21 @@ export const OPCODES = {
|
|
|
108
108
|
// - an indexed or non-zero-offset AGGREGATE access → the address-cast `((T *)&gSym)[i]`;
|
|
109
109
|
// - any other use (e.g. `&gSym` passed to a call) → the `{k:'addr'}` L3 node, printed `&gSym`.
|
|
110
110
|
gaddr: { operands: 0, results: 1, requiredAttrs: ['sym'] },
|
|
111
|
+
// The address of a FRAME-LOCAL object — gaddr's local twin, for the address-taken stack local
|
|
112
|
+
// (`mov rD, sp` feeding a DMA register or a callee). `off` is the byte offset inside the frame's
|
|
113
|
+
// reserved local area; the Thumb frontend's post-lift audit stamps `name`/`width`/`signed` after
|
|
114
|
+
// proving every access agrees, and the structurer declares the local and renders `&name` exactly
|
|
115
|
+
// as it renders a gaddr's `&sym`. Operand-free and pure, so GVN numbers it like gaddr and a dead
|
|
116
|
+
// one is reaped.
|
|
117
|
+
// `width`/`signed` are stamped by the frontend's frame-object AUDIT — requiring them makes
|
|
118
|
+
// "the audit ran" a verifier-checkable fact instead of a convention: a frontend that emits a
|
|
119
|
+
// laddr and skips the audit fails verify loudly instead of rendering `&undefined`.
|
|
120
|
+
laddr: { operands: 0, results: 1, requiredAttrs: ['off', 'width', 'signed'] },
|
|
111
121
|
// --- black-box escape hatch (keeps lifting total) ---
|
|
112
|
-
|
|
122
|
+
// `effects: true`: an instruction asmlift could not model may do anything — write memory, trap,
|
|
123
|
+
// touch a system register — and `results[0]` is only the part we can name. So a dead `opaque` is
|
|
124
|
+
// no more reapable than a dead `call`.
|
|
125
|
+
opaque: { operands: 'variadic', results: 1, effects: true },
|
|
113
126
|
// --- terminators ---
|
|
114
127
|
ret: { operands: 'variadic', results: 0, terminator: true, successors: 0 },
|
|
115
128
|
br: { operands: 0, results: 0, terminator: true, successors: 1 },
|
|
@@ -130,13 +143,54 @@ export function opSig(opcode: string): OpSig | undefined {
|
|
|
130
143
|
return (OPCODES as Record<string, OpSig | undefined>)[opcode];
|
|
131
144
|
}
|
|
132
145
|
|
|
133
|
-
/**
|
|
146
|
+
/** The comparison whose result is the logical NEGATION of each `icmp_*` — `!(a < b)` is `a >= b`.
|
|
147
|
+
*
|
|
148
|
+
* Unlike EFFECTFUL_OPS/HOIST_UNSAFE_OPS below, this is AUTHORED data seated beside the registry,
|
|
149
|
+
* not a view derived from it: nothing in `OPCODES` states which comparison opposes which. What is
|
|
150
|
+
* derived is its SYMMETRY — the five involutive pairs are expanded both ways, so `neg(neg(c)) === c`
|
|
151
|
+
* holds by construction (a hand-written map is one typo away from breaking it, and the symptom is a
|
|
152
|
+
* plainly inverted condition in the emitted C). Completeness against the icmp family is the part
|
|
153
|
+
* construction cannot give, so a test asserts it (test/pattern.test.ts) — an eleventh comparison
|
|
154
|
+
* added to `OPCODES` would otherwise degrade three consumers three different ways.
|
|
155
|
+
*
|
|
156
|
+
* It lives here for the reason HOIST_UNSAFE_OPS does: every consumer that has to say "the opposite
|
|
157
|
+
* of this compare" reads THIS one — the MIPS frontend's `slt …; beqz` branch-when-false fold, the
|
|
158
|
+
* short-circuit recognizer's diamond negation, and the idiom layer's `cmp ^ 1` fold — so they
|
|
159
|
+
* cannot drift apart the way inline copies did. Two adjacent facts worth knowing: raise/
|
|
160
|
+
* shortcircuit.ts derives its `BOOL_OPS` from these keys (asserting negatable-icmp == boolean-op,
|
|
161
|
+
* true today), and l3/ast.ts `NEGATE_REL` is the SAME relation over the neutral L3 operator
|
|
162
|
+
* vocabulary — deliberately separate, because signedness lives in the operand types there, so the
|
|
163
|
+
* two tables are not candidates for further consolidation. */
|
|
164
|
+
const ICMP_NEGATION_PAIRS: readonly (readonly [Opcode, Opcode])[] = [
|
|
165
|
+
['icmp_eq', 'icmp_ne'],
|
|
166
|
+
['icmp_slt', 'icmp_sge'],
|
|
167
|
+
['icmp_sgt', 'icmp_sle'],
|
|
168
|
+
['icmp_ult', 'icmp_uge'],
|
|
169
|
+
['icmp_ugt', 'icmp_ule'],
|
|
170
|
+
];
|
|
171
|
+
export const NEGATED_ICMP: Readonly<Record<string, Opcode>> = Object.fromEntries(
|
|
172
|
+
ICMP_NEGATION_PAIRS.flatMap(([a, b]) => [
|
|
173
|
+
[a, b],
|
|
174
|
+
[b, a],
|
|
175
|
+
]),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
/** Ops with an observable side effect: the flag on the signature, derived rather than re-listed.
|
|
179
|
+
* Consumed by `isDceSafe`, by `HOIST_UNSAFE_OPS` below, and by structure.ts's `sideEffects` walk
|
|
180
|
+
* (an effectful op whose result nobody reads is still an execution). */
|
|
134
181
|
export const EFFECTFUL_OPS: ReadonlySet<string> = new Set(
|
|
135
182
|
(Object.keys(OPCODES) as Opcode[]).filter((k) => (OPCODES[k] as OpSig).effects),
|
|
136
183
|
);
|
|
137
184
|
|
|
185
|
+
/** Ops that may not be REORDERED across other code. Identical to `EFFECTFUL_OPS` — one flag answers
|
|
186
|
+
* both "deletable when dead" and "movable when live" — and kept as its own name because the call
|
|
187
|
+
* sites ask the reordering question. Derived here rather than re-spelled per consumer:
|
|
188
|
+
* structure/analysis.ts and structure/structure.ts each carry their own inline copy of this
|
|
189
|
+
* membership, which is how the two models drifted apart in the first place. */
|
|
190
|
+
export const HOIST_UNSAFE_OPS: ReadonlySet<string> = EFFECTFUL_OPS;
|
|
191
|
+
|
|
138
192
|
/** May a dead result of this opcode be deleted? Registered, no observable effects, not control
|
|
139
|
-
* flow.
|
|
193
|
+
* flow. `opaque` is excluded via its `effects` flag — see the note on its signature. */
|
|
140
194
|
export function isDceSafe(opcode: string): boolean {
|
|
141
195
|
const sig = opSig(opcode);
|
|
142
196
|
return !!sig && !sig.effects && !sig.terminator;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// asmlift — SSA cleanups that belong to the substrate, not to any one pass.
|
|
2
|
+
//
|
|
3
|
+
// Peer of `pattern/engine.ts`'s `dce`: general, opcode-agnostic, and callable by anything that has
|
|
4
|
+
// just changed the CFG.
|
|
5
|
+
import { Block, Fn, Successor, Value, replaceAllUsesWith } from './core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Remove block params that are really TRIVIAL PHIS — those whose incoming args, across every
|
|
9
|
+
* predecessor edge and ignoring a self-reference from a back edge, are all one value. Such a param
|
|
10
|
+
* carries no join information, so it is replaced by that value and the arg dropped from each
|
|
11
|
+
* predecessor's terminator. Returns how many were removed.
|
|
12
|
+
*
|
|
13
|
+
* Iterated to a fixpoint: removing one phi can make the next trivial.
|
|
14
|
+
*
|
|
15
|
+
* DOMINANCE is free. If every predecessor passes `v`, then `v` is defined before each of those
|
|
16
|
+
* terminators and every path into the block goes through one of them — so `v` dominates the block
|
|
17
|
+
* and every use the param had. The self-reference waiver preserves that: a class where every edge
|
|
18
|
+
* passes the param itself has no first dynamic entry, so the first real entry always arrives on a
|
|
19
|
+
* `v`-passing edge.
|
|
20
|
+
*
|
|
21
|
+
* THE ENTRY BLOCK IS NEVER TOUCHED. Its params are the function's own parameters. Braun's
|
|
22
|
+
* construction in `frontend/ssa.ts` used to rely on entry having no in-edges to get this for free —
|
|
23
|
+
* which stops being true for an entry block that is ALSO a loop header, a shape this codebase does
|
|
24
|
+
* have (`raise/shortcircuit.ts` and `raise/retsink.ts` both guard it explicitly, the former after a
|
|
25
|
+
* reproduced silent miscompile). The guard is stated rather than inherited from an accident.
|
|
26
|
+
*
|
|
27
|
+
* `onRemoved` lets a caller drop its own bookkeeping for the retired param (the frontend's phi-block
|
|
28
|
+
* map); it is called once per removal, before the param is spliced out.
|
|
29
|
+
*/
|
|
30
|
+
export function simplifyTrivialPhis(fn: Fn, onRemoved?: (param: Value) => void): number {
|
|
31
|
+
const edgesTo = (b: Block): Successor[] => {
|
|
32
|
+
const out: Successor[] = [];
|
|
33
|
+
for (const pb of fn.blocks) {
|
|
34
|
+
for (const op of pb.ops) {
|
|
35
|
+
for (const s of op.successors) {
|
|
36
|
+
if (s.block === b) {
|
|
37
|
+
out.push(s);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
};
|
|
44
|
+
let removed = 0;
|
|
45
|
+
for (;;) {
|
|
46
|
+
let changed = false;
|
|
47
|
+
for (const b of fn.blocks) {
|
|
48
|
+
if (b === fn.blocks[0]) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const incoming = edgesTo(b);
|
|
52
|
+
for (let i = b.params.length - 1; i >= 0; i--) {
|
|
53
|
+
const param = b.params[i];
|
|
54
|
+
const distinct = [...new Set(incoming.map((s) => s.args[i]).filter((v) => v !== param))];
|
|
55
|
+
if (distinct.length !== 1) {
|
|
56
|
+
continue; // a genuine join, or unreachable (no in-edges at all)
|
|
57
|
+
}
|
|
58
|
+
replaceAllUsesWith(fn, param, distinct[0]);
|
|
59
|
+
onRemoved?.(param);
|
|
60
|
+
b.params.splice(i, 1);
|
|
61
|
+
for (const s of incoming) {
|
|
62
|
+
s.args.splice(i, 1);
|
|
63
|
+
}
|
|
64
|
+
removed++;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!changed) {
|
|
69
|
+
return removed;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// L3 re-spelling lever: materialize the deref BASES of a call's arguments into locals, before the
|
|
2
|
+
// call.
|
|
3
|
+
//
|
|
4
|
+
// When a call's arguments are each a deref through a different fixed address, the compiler loads
|
|
5
|
+
// BOTH addresses before dereferencing either — it needs two registers live across the argument
|
|
6
|
+
// setup, so it emits the two pool loads first:
|
|
7
|
+
//
|
|
8
|
+
// ldr r0, .L4 <- both addresses
|
|
9
|
+
// ldr r1, .L4+0x4
|
|
10
|
+
// ldrb r0, [r0] <- then both loads
|
|
11
|
+
// ldrb r1, [r1, #0x8]
|
|
12
|
+
//
|
|
13
|
+
// Spelling the derefs INLINE in the argument list (`f(*(u8 *)0x4000006, gEntityArray[8])`) makes
|
|
14
|
+
// agbcc finish argument 0 before starting argument 1 — `ldr; ldrb; ldr; ldrb` — which is the same
|
|
15
|
+
// four instructions in a different order, and a nonmatch. The source that produces the target's
|
|
16
|
+
// order names the bases first (`vu8 *p = ®_VCOUNT_L; u8 *e = gEntityArray; f(*p, e[8])`), which
|
|
17
|
+
// is what a decomp author writes and what this pass reproduces.
|
|
18
|
+
//
|
|
19
|
+
// A LEVER, not a rewrite: it is emitted as an ADDITIONAL candidate (rank.ts `/argbase`) and the
|
|
20
|
+
// differ referees, so the inline spelling is always still there to win. That is what bounds the
|
|
21
|
+
// risk — a lever that replaced the primary could lose a match, this one cannot.
|
|
22
|
+
//
|
|
23
|
+
// SEMANTICS ARE PRESERVED BY CONSTRUCTION, which matters because on a NONMATCH row the
|
|
24
|
+
// best-scoring candidate is what the user is shown. Only a PURE leaf base is eligible — a global's
|
|
25
|
+
// address (`addr`), a numeric pointer (`const`), or the bare name of a declared global — so
|
|
26
|
+
// evaluating it earlier can be neither observable nor faulting. A local variable is excluded: it
|
|
27
|
+
// may be assigned between the hoist point and the call, which would change what is dereferenced.
|
|
28
|
+
//
|
|
29
|
+
// KNOWN LIMITATION: the hoisted local is a plain `T *` — `IrType` models no cv-qualifier at all,
|
|
30
|
+
// so naming a VOLATILE cell through it drops the qualifier that macros.ts goes out of its way to
|
|
31
|
+
// carry. Pre-existing and not introduced here (every pointer local in the tower has it), but the
|
|
32
|
+
// two features meet on exactly the MMIO shape this lever targets, so it is written down rather
|
|
33
|
+
// than left to be rediscovered.
|
|
34
|
+
//
|
|
35
|
+
// GATE: at least TWO arguments of the same call must qualify, with DISTINCT bases. The reordering
|
|
36
|
+
// this reproduces only exists when two addresses compete for registers during argument setup — with
|
|
37
|
+
// ONE base there is nothing to interleave, so the compiler emits the same sequence either way and
|
|
38
|
+
// naming it is pure churn. (Evidence: on kleod:UpdateFadeEffect, hoisting only the first base
|
|
39
|
+
// leaves the diff at 2; both together take it to 0.)
|
|
40
|
+
import { type IrType, T, scalarTypeForAccess } from '../ir/types';
|
|
41
|
+
import type { Expr, SFn, Stmt } from './ast';
|
|
42
|
+
import { mapExprChildren, stmtExprs } from './ast';
|
|
43
|
+
import { nameAllocator } from './hoist';
|
|
44
|
+
|
|
45
|
+
/** A base this pass may evaluate early: pure, and not something a store can change under us.
|
|
46
|
+
*
|
|
47
|
+
* NO shadowing filter, unlike scopebase.ts's `isLeaf`, and the asymmetry is deliberate: that pass
|
|
48
|
+
* re-spells the base as `&g`, which under a shadowing local names a different object, while this
|
|
49
|
+
* one keeps the base expression verbatim and places the assignment immediately before the same
|
|
50
|
+
* statement. Adding the filter here only loses hoists (test/addr-placement.test.ts). */
|
|
51
|
+
function eligibleBase(base: Expr, globals: ReadonlySet<string>): boolean {
|
|
52
|
+
return base.k === 'addr' || base.k === 'const' || (base.k === 'var' && globals.has(base.name));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** THE identity of an eligible base — what makes two accesses "the same address".
|
|
56
|
+
*
|
|
57
|
+
* A global reaches this pass under TWO spellings: `addr g` (its address) and, when the symbol map
|
|
58
|
+
* types it as an array of the access width, the bare `var g`. They denote the same cell, so a
|
|
59
|
+
* structural comparison would count them as two addresses and defeat the gate on
|
|
60
|
+
* `f(*(u8 *)&g, g[4])` — the exact churn the gate exists to reject, reached by a different route.
|
|
61
|
+
* Named bases therefore key on the NAME alone, whichever node kind carries it. */
|
|
62
|
+
function baseIdentity(base: Expr): string {
|
|
63
|
+
return base.k === 'const' ? `c:${base.value}` : `n:${(base as Extract<Expr, { k: 'var' | 'addr' }>).name}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The `index` nodes directly under a call's arguments whose base is eligible — the candidates for
|
|
67
|
+
* materialization. Only the argument's OWN top-level deref counts: a base buried inside arbitrary
|
|
68
|
+
* argument arithmetic is not what the compiler is loading up front. */
|
|
69
|
+
function argBases(call: Extract<Expr, { k: 'call' }>, globals: ReadonlySet<string>): Extract<Expr, { k: 'index' }>[] {
|
|
70
|
+
const out: Extract<Expr, { k: 'index' }>[] = [];
|
|
71
|
+
for (const a of call.args) {
|
|
72
|
+
if (a.k === 'index' && !a.lead?.length && eligibleBase(a.base, globals)) {
|
|
73
|
+
out.push(a);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Distinct bases, in first-appearance order — the compiler loads each ADDRESS once.
|
|
80
|
+
*
|
|
81
|
+
* Keyed on the base alone, deliberately, even though the naming below keys on width too: the gate
|
|
82
|
+
* counts how many addresses compete for registers during argument setup, and two accesses of the
|
|
83
|
+
* same address at different widths are still ONE address. Counting them separately would pass the
|
|
84
|
+
* gate on `callee(*(u8 *)&g, *(u16 *)&g)`, where nothing reorders and there is nothing to fix. */
|
|
85
|
+
function distinctBases(nodes: Extract<Expr, { k: 'index' }>[]): Extract<Expr, { k: 'index' }>[] {
|
|
86
|
+
const out: Extract<Expr, { k: 'index' }>[] = [];
|
|
87
|
+
for (const n of nodes) {
|
|
88
|
+
if (!out.some((o) => baseIdentity(o.base) === baseIdentity(n.base))) {
|
|
89
|
+
out.push(n);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The (base, width, signedness) key an `index` shares with every other access through the same
|
|
96
|
+
* base — so ALL of a base's uses in one call rewrite to the same local, not just the first. */
|
|
97
|
+
function baseKey(n: Extract<Expr, { k: 'index' }>): string {
|
|
98
|
+
return `${baseIdentity(n.base)} ${n.width} ${n.signed}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The `/argbase` re-spelling, or null when no statement qualifies (the caller then adds no
|
|
103
|
+
* candidate at all rather than a duplicate of the primary).
|
|
104
|
+
*/
|
|
105
|
+
export function materializeArgBases(sfn: SFn): SFn | null {
|
|
106
|
+
const globals = new Set((sfn.globals ?? []).map((g) => g.name));
|
|
107
|
+
const fresh = nameAllocator(sfn);
|
|
108
|
+
const newLocals: { name: string; type: IrType }[] = [];
|
|
109
|
+
let fired = false;
|
|
110
|
+
|
|
111
|
+
// Rewrite ONE statement into the list that replaces it: the naming assignments, then the
|
|
112
|
+
// statement with its qualifying bases pointed at them. The naming goes immediately BEFORE the
|
|
113
|
+
// statement holding the call, not at the function top — the compiler loads these addresses where
|
|
114
|
+
// it needs them, and hoisting further extends live ranges the original never had (the
|
|
115
|
+
// register-pressure failure basecse.ts's loop gate exists for).
|
|
116
|
+
//
|
|
117
|
+
// NOTE the shape: recursion happens per FIELD, through an exhaustive switch, and the `pre`
|
|
118
|
+
// insertion happens INSIDE it. Rebuilding a statement from a flattened `stmtChildren` list cannot
|
|
119
|
+
// work — inserting statements shifts the boundary the rebuild would have to split at, and
|
|
120
|
+
// `stmtChildren('for')` is `[init, inc, ...body]`, which is not a body. Both mistakes produce
|
|
121
|
+
// COMPILING but wrong C (a call migrating across an if/else boundary; a `for` init duplicated
|
|
122
|
+
// into its body), which no boundary contract checks: they check resolution and spellability, not
|
|
123
|
+
// statement placement.
|
|
124
|
+
const rewrite = (s: Stmt): Stmt[] => {
|
|
125
|
+
const pre: Stmt[] = [];
|
|
126
|
+
const localFor = new Map<string, string>();
|
|
127
|
+
// Only the statement's OWN expressions can carry a call this pass names bases for; nested
|
|
128
|
+
// statement lists get their own `pre`, in their own scope, via the recursion below.
|
|
129
|
+
//
|
|
130
|
+
// A LOOP's own expression is its CONDITION, which runs every iteration — but `pre` lands
|
|
131
|
+
// BEFORE the loop, which would make this a loop-invariant hoist to a point the original never
|
|
132
|
+
// had. That is the register-pressure failure basecse.ts's `inLoop` gate exists to refuse, and
|
|
133
|
+
// it would contradict this pass's own placement rule two comments down. So a loop's condition
|
|
134
|
+
// is left alone; only its body (via the recursion) is eligible.
|
|
135
|
+
const ownExprs = s.k === 'while' || s.k === 'dowhile' || s.k === 'for' ? [] : stmtExprs(s);
|
|
136
|
+
for (const e of ownExprs) {
|
|
137
|
+
const scan = (x: Expr): void => {
|
|
138
|
+
if (x.k === 'call') {
|
|
139
|
+
const bases = distinctBases(argBases(x, globals));
|
|
140
|
+
if (bases.length >= 2) {
|
|
141
|
+
for (const b of bases) {
|
|
142
|
+
const key = baseKey(b);
|
|
143
|
+
if (localFor.has(key)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const ptrType = T.ptr(scalarTypeForAccess(b.width, b.signed));
|
|
147
|
+
const nm = fresh();
|
|
148
|
+
localFor.set(key, nm);
|
|
149
|
+
newLocals.push({ name: nm, type: ptrType });
|
|
150
|
+
pre.push({ k: 'assign', name: nm, value: { k: 'cast', to: ptrType, e: b.base } });
|
|
151
|
+
}
|
|
152
|
+
fired = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
mapExprChildren(x, (c) => {
|
|
156
|
+
scan(c);
|
|
157
|
+
return c;
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
scan(e);
|
|
161
|
+
}
|
|
162
|
+
const point = (e: Expr): Expr => {
|
|
163
|
+
if (e.k === 'index' && !e.lead?.length && eligibleBase(e.base, globals)) {
|
|
164
|
+
const nm = localFor.get(baseKey(e));
|
|
165
|
+
if (nm) {
|
|
166
|
+
return { ...e, base: { k: 'var', name: nm }, idx: point(e.idx) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return mapExprChildren(e, point);
|
|
170
|
+
};
|
|
171
|
+
const kids = (list: Stmt[]): Stmt[] => list.flatMap(rewrite);
|
|
172
|
+
let out: Stmt;
|
|
173
|
+
switch (s.k) {
|
|
174
|
+
case 'assign':
|
|
175
|
+
out = { ...s, value: point(s.value) };
|
|
176
|
+
break;
|
|
177
|
+
case 'store':
|
|
178
|
+
out = { ...s, lval: point(s.lval), value: point(s.value) };
|
|
179
|
+
break;
|
|
180
|
+
case 'exprstmt':
|
|
181
|
+
out = { ...s, value: point(s.value) };
|
|
182
|
+
break;
|
|
183
|
+
case 'return':
|
|
184
|
+
out = s.value === undefined ? s : { ...s, value: point(s.value) };
|
|
185
|
+
break;
|
|
186
|
+
case 'if':
|
|
187
|
+
out = { ...s, cond: point(s.cond), then: kids(s.then), else: kids(s.else) };
|
|
188
|
+
break;
|
|
189
|
+
case 'while':
|
|
190
|
+
case 'dowhile':
|
|
191
|
+
out = { ...s, cond: point(s.cond), body: kids(s.body) };
|
|
192
|
+
break;
|
|
193
|
+
case 'for': {
|
|
194
|
+
// `init`/`inc` are single statements. A `pre` produced inside either has nowhere legal to
|
|
195
|
+
// go (before the loop changes when it runs; inside the body repeats it), so this pass
|
|
196
|
+
// declines to fire there and leaves them alone.
|
|
197
|
+
out = { ...s, cond: point(s.cond), body: kids(s.body) };
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case 'switch':
|
|
201
|
+
out = {
|
|
202
|
+
...s,
|
|
203
|
+
scrutinee: point(s.scrutinee),
|
|
204
|
+
cases: s.cases.map((c) => ({ ...c, body: kids(c.body) })),
|
|
205
|
+
...(s.default ? { default: kids(s.default) } : {}),
|
|
206
|
+
};
|
|
207
|
+
break;
|
|
208
|
+
case 'break':
|
|
209
|
+
case 'continue':
|
|
210
|
+
out = s;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (pre.length === 0) {
|
|
214
|
+
return [out];
|
|
215
|
+
}
|
|
216
|
+
return [...pre, out];
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const body = sfn.body.flatMap(rewrite);
|
|
220
|
+
return fired ? { ...sfn, body, locals: [...sfn.locals, ...newLocals] } : null;
|
|
221
|
+
}
|
package/src/l3/ast.ts
CHANGED
|
@@ -38,7 +38,13 @@ export type Expr =
|
|
|
38
38
|
// Variable-index `a[i]` is recovered at the IR level (`aload`/`astore` carry elemSize;
|
|
39
39
|
// raise/arrays.ts) but still LOWERS to this one C-shaped `index` node, so it stays C-only
|
|
40
40
|
// (a Pascal array-access spelling is future work). Treat `index` with idx ≠ 0 as C-shaped.
|
|
41
|
-
|
|
41
|
+
// `lead` prefixes CONSTANT subscripts before `idx` — `g[0][i]` rather than `g[i]`. It exists for
|
|
42
|
+
// exactly one inhabitant: the bare-name spelling of a MULTIDIMENSIONAL array global, where one
|
|
43
|
+
// subscript reaches a row and the element needs the leading dimensions pinned first. The node
|
|
44
|
+
// still denotes ONE `width`-byte element, so its type, its legalization and its stride contract
|
|
45
|
+
// are unchanged — this is a spelling of the same address, not a new kind of access. Absent for
|
|
46
|
+
// every rank-1 access, which is why it is optional rather than an empty array.
|
|
47
|
+
| { k: 'index'; base: Expr; idx: Expr; width: number; signed: boolean; lead?: number[] }
|
|
42
48
|
// A named struct-field access `base->name` (raise/structs.ts recovered `base` as a struct
|
|
43
49
|
// pointer, so the byte offset resolves to a named field instead of a scaled array index).
|
|
44
50
|
// Unlike `index`, this carries the field NAME (which encodes the byte offset, `field_<off>`),
|
|
@@ -53,8 +59,45 @@ export type Expr =
|
|
|
53
59
|
// default) never produces this node; it keeps the `"?"` sentinel → ContractError behavior.
|
|
54
60
|
| { k: 'marker'; reason: string; args: Expr[] };
|
|
55
61
|
|
|
62
|
+
// `>>` is the ARITHMETIC right shift and `>>>` the LOGICAL one. C spells both `>>` and picks from
|
|
63
|
+
// the left operand's type, so the C backend synthesizes the cast that pins the choice — exactly as
|
|
64
|
+
// it already synthesizes scalar deref casts from an `index` node's width. A backend with no
|
|
65
|
+
// spelling for one of them (IDO Pascal) declines LOUDLY on the operation itself, rather than on
|
|
66
|
+
// whatever artifact another language's spelling happened to leave in the tree.
|
|
67
|
+
//
|
|
68
|
+
// WHY THIS ONE SPLIT AND NOT THE OTHERS. "The machine distinguishes them" is NOT the rule — the
|
|
69
|
+
// machine distinguishes `divu`/`div` and `sltu`/`slt` too, and ARITH_TO_BIN deliberately collapses
|
|
70
|
+
// `udiv`→`/`, `umod`→`%`, `icmp_u*`→`<` etc., noting that "unsignedness is in the operand types".
|
|
71
|
+
// Taking the machine as the rule would license four more splits with no inhabitant, which is what
|
|
72
|
+
// "earn the level" forbids. The rule is the repo's own: the shift split because a real,
|
|
73
|
+
// byte-load-bearing divergence HAD inhabitants (~20 rows, 5 projects, 4 compilers) and no other
|
|
74
|
+
// channel could carry it — the operand type could not, since a promoted narrow value is signed
|
|
75
|
+
// whatever it was loaded as.
|
|
76
|
+
//
|
|
77
|
+
// The collapsed operators lean on exactly that channel, so they carry the same latent hazard:
|
|
78
|
+
// `*(u16 *)p / 3` renders as a signed division of a promoted `int` where the asm did `divu`. It is
|
|
79
|
+
// tolerated because no row has produced such a divergence. When one does, the fix is this same
|
|
80
|
+
// split — not a per-site patch.
|
|
56
81
|
export type BinOp =
|
|
57
|
-
|
|
82
|
+
| '+'
|
|
83
|
+
| '-'
|
|
84
|
+
| '*'
|
|
85
|
+
| '/'
|
|
86
|
+
| '%'
|
|
87
|
+
| '<'
|
|
88
|
+
| '<='
|
|
89
|
+
| '>'
|
|
90
|
+
| '>='
|
|
91
|
+
| '=='
|
|
92
|
+
| '!='
|
|
93
|
+
| '&'
|
|
94
|
+
| '|'
|
|
95
|
+
| '^'
|
|
96
|
+
| '<<'
|
|
97
|
+
| '>>'
|
|
98
|
+
| '>>>'
|
|
99
|
+
| '&&'
|
|
100
|
+
| '||';
|
|
58
101
|
|
|
59
102
|
export type Stmt =
|
|
60
103
|
| { k: 'assign'; name: string; value: Expr }
|
|
@@ -110,7 +153,7 @@ export interface SwitchCase {
|
|
|
110
153
|
export interface SFn {
|
|
111
154
|
name: string;
|
|
112
155
|
params: { name: string; type: IrType }[];
|
|
113
|
-
locals: { name: string; type: IrType }[]; // recovered locals, declared at function top
|
|
156
|
+
locals: { name: string; type: IrType; volatile?: true }[]; // recovered locals, declared at function top
|
|
114
157
|
/** project globals referenced with a known declaration shape (symbol map) — typed for the
|
|
115
158
|
* legalization env (exprCType) but NEVER declared by a backend: the project's own headers
|
|
116
159
|
* declare them, exactly like every other global name asmlift emits. */
|
|
@@ -189,11 +232,27 @@ export function exprEquals(a: Expr, b: Expr): boolean {
|
|
|
189
232
|
}
|
|
190
233
|
case 'index': {
|
|
191
234
|
const bb = b as typeof a;
|
|
192
|
-
|
|
235
|
+
// `lead` is part of the ADDRESS (`g[0][i]` and `g[1][i]` are different elements), so it
|
|
236
|
+
// must be compared — an omission here would let CSE/dedup collapse two distinct accesses.
|
|
237
|
+
const lead = a.lead ?? [];
|
|
238
|
+
const bLead = bb.lead ?? [];
|
|
239
|
+
return (
|
|
240
|
+
a.width === bb.width &&
|
|
241
|
+
a.signed === bb.signed &&
|
|
242
|
+
lead.length === bLead.length &&
|
|
243
|
+
lead.every((v, i) => v === bLead[i]) &&
|
|
244
|
+
exprEquals(a.base, bb.base) &&
|
|
245
|
+
exprEquals(a.idx, bb.idx)
|
|
246
|
+
);
|
|
193
247
|
}
|
|
194
248
|
case 'field': {
|
|
195
249
|
const bb = b as typeof a;
|
|
196
|
-
|
|
250
|
+
// `dot` is part of the SPELLING, and for the same reason `lead` is compared above: a CSE or
|
|
251
|
+
// dedup that treats these as equal keeps one node and discards the other, silently respelling
|
|
252
|
+
// `p->field_4` as `p.field_4` (or the reverse). Both compile only for the base type each
|
|
253
|
+
// belongs to, so collapsing them is how a valid access becomes an invalid one — or worse, a
|
|
254
|
+
// valid one against a different object.
|
|
255
|
+
return a.name === bb.name && (a.dot ?? false) === (bb.dot ?? false) && exprEquals(a.base, bb.base);
|
|
197
256
|
}
|
|
198
257
|
case 'marker': {
|
|
199
258
|
const bb = b as typeof a;
|
|
@@ -212,6 +271,14 @@ export function exprEquals(a: Expr, b: Expr): boolean {
|
|
|
212
271
|
// recognizeForLoops) rightly keep their own switches.
|
|
213
272
|
|
|
214
273
|
/** The direct sub-expressions of `e`, in syntactic order. */
|
|
274
|
+
/** THE spelling of an unmodelled instruction's gap reason, in one place: `structure.ts` writes it
|
|
275
|
+
* into the marker, `contracts.ts` matches on it to prove the gap was not dropped, and the benchmark
|
|
276
|
+
* classifies declines by it. Two spellings make that contract silently vacuous — enforced-looking
|
|
277
|
+
* and never firing. `?` when a frontend stamps no mnemonic. */
|
|
278
|
+
export function gapReasonFor(mnemonic: unknown): string {
|
|
279
|
+
return `unmodelled instruction '${typeof mnemonic === 'string' ? mnemonic : '?'}'`;
|
|
280
|
+
}
|
|
281
|
+
|
|
215
282
|
export function exprChildren(e: Expr): Expr[] {
|
|
216
283
|
switch (e.k) {
|
|
217
284
|
case 'var':
|
|
@@ -305,3 +372,58 @@ export function stmtChildren(s: Stmt): Stmt[] {
|
|
|
305
372
|
return [...s.cases.flatMap((c) => c.body), ...(s.default ?? [])];
|
|
306
373
|
}
|
|
307
374
|
}
|
|
375
|
+
|
|
376
|
+
// THE negation of a CONDITION — the one implementation, shared by every L3 pass that flips one.
|
|
377
|
+
//
|
|
378
|
+
// There were two, and they drifted: structure.ts's empty-then peephole learned to distribute over
|
|
379
|
+
// the short-circuit connectives while l3/dce.ts's copy kept wrapping in `!`, and because
|
|
380
|
+
// `eliminateDeadStores` runs AFTER structuring it re-introduced the very spelling the other one had
|
|
381
|
+
// just removed. That is the l3/hoist.ts failure mode verbatim — a copied helper silently losing the
|
|
382
|
+
// newer rule — so this lives with the AST vocabulary and the passes call it.
|
|
383
|
+
//
|
|
384
|
+
// Three rules, in order:
|
|
385
|
+
// 1. a relational operator flips directly (`!=` → `==`, `<` → `>=`, …), exact over C's total
|
|
386
|
+
// integer order;
|
|
387
|
+
// 2. DE MORGAN — `!(a && b)` becomes `!a || !b`. Sound including EVALUATION ORDER: `a && b` runs
|
|
388
|
+
// `b` only when `a` holds, and `!a || !b` runs `!b` only when `!a` is false, i.e. when `a`
|
|
389
|
+
// holds. Same operands, same inputs — which is what makes it safe over a `b` that loads. It
|
|
390
|
+
// matters because a source `&&` and its dual `||` compile to the SAME branch graph, so the
|
|
391
|
+
// recognizers in raise/shortcircuit.ts can only pick whichever the asm's branch senses spell;
|
|
392
|
+
// distributing is what lets the `/flip-branch` candidate reach the other one;
|
|
393
|
+
// 3. `!!x` collapses to `x`, reachable only from a double flip that rule 2 now produces.
|
|
394
|
+
//
|
|
395
|
+
// CONTEXT REQUIREMENT, and it is the reason this is `negateCond` and not `negate`: rule 3 is valid
|
|
396
|
+
// only in a TRUTH-VALUE context, where `x` and `!!x` are interchangeable. `!!5` is 1 and `5` is 5,
|
|
397
|
+
// so this must never be used to negate a general integer expression — only an `if`/loop test or an
|
|
398
|
+
// operand of one of the connectives above.
|
|
399
|
+
//
|
|
400
|
+
// SCOPE of rule 2: it only gives the differ a second spelling where a candidate lever already flips
|
|
401
|
+
// the condition, and `preserveDivergentBranchSense` covers divergent `if`s ONLY. A connective that
|
|
402
|
+
// ended up as a LOOP test therefore has no dual candidate at all — the differ never sees the other
|
|
403
|
+
// form, so on such a row this rule changes how the code READS and nothing else. Widening the
|
|
404
|
+
// branch-sense lever to loop tests is what would make it a matching lever there, and that is a
|
|
405
|
+
// separate change.
|
|
406
|
+
const NEGATE_REL: Partial<Record<BinOp, BinOp>> = {
|
|
407
|
+
'<': '>=',
|
|
408
|
+
'>=': '<',
|
|
409
|
+
'>': '<=',
|
|
410
|
+
'<=': '>',
|
|
411
|
+
'==': '!=',
|
|
412
|
+
'!=': '==',
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
export function negateCond(e: Expr): Expr {
|
|
416
|
+
if (e.k === 'bin') {
|
|
417
|
+
const flipped = NEGATE_REL[e.op];
|
|
418
|
+
if (flipped) {
|
|
419
|
+
return { ...e, op: flipped };
|
|
420
|
+
}
|
|
421
|
+
if (e.op === '&&' || e.op === '||') {
|
|
422
|
+
return { ...e, op: e.op === '&&' ? '||' : '&&', l: negateCond(e.l), r: negateCond(e.r) };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (e.k === 'un' && e.op === '!') {
|
|
426
|
+
return e.e;
|
|
427
|
+
}
|
|
428
|
+
return { k: 'un', op: '!', e };
|
|
429
|
+
}
|