@asmlift/core 0.4.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.
@@ -15,9 +15,10 @@
15
15
  // computation via read/writeVar, push its terminator op last (successors referencing
16
16
  // `irBlocks`, args left empty — phi wiring appends them), then call `markFilled(b)`. When all
17
17
  // blocks are filled, call `finish()` to remove trivial phis.
18
- import { Block, Fn, Value, mkValue } from '../ir/core';
18
+ import { Block, Fn, Op, Value, mkValue } from '../ir/core';
19
19
  import { simplifyTrivialPhis } from '../ir/simplify';
20
20
  import { T } from '../ir/types';
21
+ import { FrontendUnsupportedError } from './errors';
21
22
 
22
23
  export interface SsaBuilder {
23
24
  fn: Fn;
@@ -28,15 +29,44 @@ export interface SsaBuilder {
28
29
  writeVar(reg: string, b: number, v: Value): void;
29
30
  /** Mark block `b` fully emitted (terminator pushed); seals any now-ready successors. */
30
31
  markFilled(b: number): void;
31
- /** Live-in parameter value → the ABI register it arrived on (for calling-convention order). */
32
+ /** Live-in parameter value → the key it arrived on (for calling-convention order). Usually an
33
+ * ABI register name, but a frontend's virtual key (see the module header) ranks here too. */
32
34
  paramReg: Map<Value, string>;
35
+ /** Assert that block `b` takes a parameter for `key`, whether or not anything reads it.
36
+ *
37
+ * `readVar` cannot express this. It asks "what value does `key` hold here?", so a key the block
38
+ * DEFINES before any read answers with that local definition and no parameter is created — to it
39
+ * "never read" and "written before first read" are the same thing. When a calling convention
40
+ * proves an argument exists, that is an obligation on the SIGNATURE, independent of whether the
41
+ * body happens to use it, so it needs its own verb.
42
+ *
43
+ * Never touches the block's definitions: the parameter is added and left unused, so any local
44
+ * value already flowing keeps flowing. Only meaningful on a block with no predecessors —
45
+ * elsewhere a parameter is a phi whose position is aligned with its predecessors' terminator
46
+ * args, and appending an unpaired one would corrupt that. */
47
+ ensureParam(key: string, b: number): void;
33
48
  /** Whether `reg` has a definition reaching block `b` (best-effort call-arity heuristic). */
34
49
  hasReachingDef(reg: string, b: number, seen?: Set<number>): boolean;
35
- /** Remove trivial phis; call once every block is filled. */
50
+ /** Record that block `b` makes a call HERE: the ABI's caller-saved registers stop being ones the
51
+ * caller set up. Call it AFTER `recordGuessedCall` for the same instruction, and before writing
52
+ * the call's own result. */
53
+ noteCall(b: number): void;
54
+ /** Register a `call` op whose arity was GUESSED (no prototype), so `finish` can cut it back to the
55
+ * argument registers that were actually set up on every path (see {@link trimClobberedCallArgs}).
56
+ * `argRegs` is the target's argument-register order. */
57
+ recordGuessedCall(op: Op, b: number, argRegs: string[]): void;
58
+ /** Remove trivial phis and enforce the frontend's postconditions; call once every block is
59
+ * filled. Throws FrontendUnsupportedError if a stack slot escaped as an entry parameter. */
36
60
  finish(): void;
37
61
  }
38
62
 
39
63
  /** `preds` is per-EDGE (see the module header): one entry per CFG edge into each block. */
64
+ // VARIABLE NAMES ARE NOT ALWAYS MACHINE REGISTERS. `readVar`/`writeVar` key on an arbitrary string,
65
+ // and frontends mint VIRTUAL keys for storage the ISA has no register for — MIPS `sp@<off>` for a
66
+ // stack slot (frontend/mips.ts), Thumb `@sarg<k>` for an incoming stack argument (frontend/thumb.ts).
67
+ // A virtual key must be outside its ISA's register grammar so it cannot collide with a real one, and
68
+ // a key read with no reaching def becomes a function PARAMETER by the live-in path below — which is
69
+ // how both of those capabilities get their parameters without a new opcode or pass.
40
70
  export function makeSsaBuilder(name: string, blockCount: number, preds: number[][]): SsaBuilder {
41
71
  const irBlocks: Block[] = Array.from({ length: blockCount }, () => ({ params: [] as Value[], ops: [] }));
42
72
  const fn: Fn = { name, blocks: irBlocks };
@@ -46,18 +76,43 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
46
76
  const filled: boolean[] = irBlocks.map(() => false);
47
77
  const incompletePhis: Array<Map<string, Value>> = irBlocks.map(() => new Map());
48
78
  const phiBlock = new Map<Value, number>();
79
+ // The key each phi stands for. `paramReg` covers live-ins only, so without this a slot that
80
+ // arrives as a PHI — which is what happens when the entry block is itself a loop header — is
81
+ // invisible to the escape check below. Braun's construction gives no other way to tell.
82
+ const phiKey = new Map<Value, string>();
49
83
  const paramReg = new Map<Value, string>();
84
+ // Parameters created by ensureParam that nothing has read yet. They are deliberately NOT in
85
+ // `defs`: a parameter asserted because a calling convention proves it exists is not evidence that
86
+ // a VALUE reaches anything, and writing one into `defs` would say it does. That distinction is
87
+ // load-bearing — `hasReachingDef` feeds `fallbackArgc`, so a def here silently raises the guessed
88
+ // arity of every prototype-less call in the function, making it pass registers the calling block
89
+ // never set up (`unknown(1)` became `unknown(1, a1, a2, a3)`). The first read adopts the value
90
+ // from here instead of minting a second parameter for the same key.
91
+ const obligedParams: Array<Map<string, Value>> = irBlocks.map(() => new Map());
50
92
 
51
93
  // `preds` lists an entry per CFG EDGE; these are the distinct predecessor BLOCKS.
52
94
  const distinctPreds = (b: number): number[] => [...new Set(preds[b])];
53
95
 
54
- const writeVar = (reg: string, b: number, v: Value) => defs[b].set(reg, v);
96
+ // CALLER-SAVED CLOBBER, for guessed call arities (see trimClobberedCallArgs). Tracked HERE
97
+ // because every register write in every frontend already goes through `writeVar`: a frontend
98
+ // that gathered this itself would be sound only while it remembered to route each write past a
99
+ // wrapper, and a MISSED write under-counts an arity — which drops a real argument silently.
100
+ const writtenSinceCall: Array<Set<string>> = irBlocks.map(() => new Set());
101
+ const callsIn = new Set<number>();
102
+ const guessedCalls: GuessedCallSite[] = [];
103
+ let argRegsSeen: string[] = [];
104
+
105
+ const writeVar = (reg: string, b: number, v: Value) => {
106
+ writtenSinceCall[b].add(reg);
107
+ defs[b].set(reg, v);
108
+ };
55
109
  const readVar = (reg: string, b: number): Value => defs[b].get(reg) ?? readRecursive(reg, b);
56
110
 
57
111
  const newPhi = (reg: string, b: number): Value => {
58
112
  const phi = mkValue(T.unk(32));
59
113
  irBlocks[b].params.push(phi);
60
114
  phiBlock.set(phi, b);
115
+ phiKey.set(phi, reg);
61
116
  defs[b].set(reg, phi); // set before wiring operands to break cycles
62
117
  return phi;
63
118
  };
@@ -74,6 +129,14 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
74
129
  const ps = distinctPreds(b);
75
130
  if (ps.length === 0) {
76
131
  // live-in with no predecessor: an incoming argument register → function parameter.
132
+ // If one was already asserted for this key (ensureParam), adopt it — minting a second
133
+ // parameter for the same key would put the key in the signature twice.
134
+ const obliged = obligedParams[b].get(reg);
135
+ if (obliged !== undefined) {
136
+ obligedParams[b].delete(reg);
137
+ defs[b].set(reg, obliged);
138
+ return obliged;
139
+ }
77
140
  const p = mkValue(T.unk(32));
78
141
  irBlocks[b].params.push(p);
79
142
  defs[b].set(reg, p);
@@ -130,6 +193,25 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
130
193
  };
131
194
  sealReadyBlocks(); // seals the entry (no predecessors) up front
132
195
 
196
+ // See the interface docs. Two cases, and the split is the whole point: when nothing defines the
197
+ // key, the ordinary live-in path already does exactly the right thing; when something does, a
198
+ // parameter still has to exist for the signature, and it must be added WITHOUT redirecting the
199
+ // dataflow to it.
200
+ const ensureParam = (key: string, b: number): void => {
201
+ if (preds[b].length > 0) {
202
+ return; // a parameter here is a phi; see the precondition on the interface
203
+ }
204
+ for (const p of irBlocks[b].params) {
205
+ if (paramReg.get(p) === key) {
206
+ return; // already a parameter, however it got there
207
+ }
208
+ }
209
+ const p = mkValue(T.unk(32));
210
+ irBlocks[b].params.push(p);
211
+ paramReg.set(p, key); // ranked by the ABI sort like any other parameter
212
+ obligedParams[b].set(key, p);
213
+ };
214
+
133
215
  const hasReachingDef = (reg: string, b: number, seen = new Set<number>()): boolean => {
134
216
  if (defs[b].has(reg)) {
135
217
  return true;
@@ -147,12 +229,71 @@ export function makeSsaBuilder(name: string, blockCount: number, preds: number[]
147
229
  readVar,
148
230
  writeVar,
149
231
  paramReg,
232
+ ensureParam,
150
233
  hasReachingDef,
234
+ noteCall: (b: number) => {
235
+ callsIn.add(b);
236
+ writtenSinceCall[b] = new Set(); // the callee clobbers the caller-saved registers
237
+ },
238
+ recordGuessedCall: (op: Op, b: number, argRegs: string[]) => {
239
+ argRegsSeen = argRegs;
240
+ guessedCalls.push({
241
+ block: b,
242
+ op,
243
+ freshBefore: new Set(writtenSinceCall[b]),
244
+ afterCallInBlock: callsIn.has(b), // `noteCall` runs after this, so this means an EARLIER call
245
+ });
246
+ },
151
247
  markFilled: (b: number) => {
152
248
  filled[b] = true;
153
249
  sealReadyBlocks();
154
250
  },
155
- finish: () => simplifyTrivialPhis(fn, (p) => phiBlock.delete(p)),
251
+ finish: () => {
252
+ // Guessed arities counted argument registers by reaching definition alone; now that every
253
+ // block's calls are known, drop the ones an intervening call had already clobbered.
254
+ if (guessedCalls.length) {
255
+ trimClobberedCallArgs({
256
+ argRegs: argRegsSeen,
257
+ preds,
258
+ freshAtEnd: writtenSinceCall,
259
+ callsIn,
260
+ sites: guessedCalls,
261
+ });
262
+ }
263
+ simplifyTrivialPhis(fn, (p) => {
264
+ phiBlock.delete(p);
265
+ phiKey.delete(p);
266
+ });
267
+ // A STACK SLOT MAY NEVER LEAVE AS AN ENTRY PARAMETER. A slot is memory the function itself
268
+ // allocated, so its value can only come from a store the function made; arriving as a live-in
269
+ // instead means it was read on a path that never stored it, and the signature has grown an
270
+ // argument the function does not take, standing in for uninitialised stack.
271
+ //
272
+ // Checked here, of the FINISHED function, rather than as a precondition at each read. The
273
+ // per-read test available during construction (`hasReachingDef`) asks whether a store reaches
274
+ // on SOME path, which a diamond defeats; strengthening it to "every path" is not answerable
275
+ // mid-fill, because a loop's back-edge predecessor is not filled yet and the query would
276
+ // report "unassigned" for a slot initialised before the loop — the commonest real shape.
277
+ // Asking about the symptom instead costs one pass and cannot be defeated by fill order.
278
+ //
279
+ // It is total because in Braun's construction a value undefined on some path can surface only
280
+ // as a live-in of a block with no predecessors — and BOTH spellings of that are checked:
281
+ // `paramReg` for the live-in path, `phiKey` for the case where the entry block is itself a
282
+ // loop header and the fabricated value arrives as a phi instead. Missing the second is what
283
+ // let this survive on MIPS.
284
+ //
285
+ // In `finish()` and not a helper each frontend remembers to call: this is the frontend's only
286
+ // semantic postcondition, and a postcondition enforced by convention is not enforced.
287
+ for (const p of irBlocks[0].params) {
288
+ const key = paramReg.get(p) ?? phiKey.get(p);
289
+ if (key?.startsWith(SLOT_PREFIX)) {
290
+ throw new FrontendUnsupportedError(
291
+ `cannot lift '${name}': stack slot ${key} is read on a path that never stores it ` +
292
+ `(partially-initialised local) — not modelled`,
293
+ );
294
+ }
295
+ }
296
+ },
156
297
  };
157
298
  }
158
299
 
@@ -174,6 +315,109 @@ export function fallbackArgc(
174
315
  return n;
175
316
  }
176
317
 
318
+ /** One call site whose arity was GUESSED by {@link fallbackArgc}, with what the lifting scan saw
319
+ * of its own block up to that instruction. */
320
+ export interface GuessedCallSite {
321
+ block: number;
322
+ /** the `call` op — its operands are the guessed arguments, in argument-register order */
323
+ op: Op;
324
+ /** argument registers written between the last call in this block (or the block's start) and here */
325
+ freshBefore: Set<string>;
326
+ /** did this block already make a call before this one? */
327
+ afterCallInBlock: boolean;
328
+ }
329
+
330
+ export interface CallArgTrim {
331
+ argRegs: string[];
332
+ /** one entry per CFG edge, as passed to {@link makeSsaBuilder} */
333
+ preds: number[][];
334
+ /** per block: the keys written since its LAST call (since its start if it makes none). Indexed by
335
+ * block, and it holds every key the builder saw, not only argument registers. */
336
+ freshAtEnd: Array<Set<string>>;
337
+ /** blocks that make at least one call */
338
+ callsIn: Set<number>;
339
+ sites: GuessedCallSite[];
340
+ }
341
+
342
+ /** Cut a GUESSED call arity down by the ABI's caller-saved clobber.
343
+ *
344
+ * `fallbackArgc` counts argument registers that merely have a reaching definition. A call clobbers
345
+ * r0..r3, so a definition the call sits between cannot be an argument the caller set up — correct
346
+ * compiled code would have re-materialized it. Counting it anyway INVENTS arguments
347
+ * (`m4aSongNumStart(0x89, 30, x, &g)` for a one-argument callee) — a hard compile error where the
348
+ * project's own header is in scope, and silently wrong code where C89's implicit declaration
349
+ * covers for it.
350
+ *
351
+ * SCOPE: this closes the arguments an intervening CALL disproves, which is the common case in real
352
+ * code. It does not close the rest — a dead value the compiler happened to leave in the next
353
+ * argument register with no call in between still reads as an argument, and nothing about the
354
+ * register file can say otherwise. Only a declared prototype closes those.
355
+ *
356
+ * A must-analysis: a register is FRESH at a point iff on EVERY path reaching it, it was written
357
+ * after the last call. The entry block starts all-fresh (those are the caller's own arguments).
358
+ * The result only ever SHRINKS an arity — a register the analysis cannot prove clobbered stays an
359
+ * argument — so no real argument can be dropped by it.
360
+ *
361
+ * Frontend-agnostic: the caller supplies what its own lifting scan observed, so nothing here
362
+ * re-derives which instruction writes which register. */
363
+ export function trimClobberedCallArgs(inp: CallArgTrim): void {
364
+ const { argRegs, preds, freshAtEnd, callsIn, sites } = inp;
365
+ const blockCount = freshAtEnd.length;
366
+ const all = () => new Set(argRegs);
367
+ const localEnd = (b: number) => freshAtEnd[b] ?? new Set<string>();
368
+ // freshOut[b]: registers fresh where b ends. A block that calls forgets everything before its
369
+ // last call; one that does not passes its input through, plus what it wrote.
370
+ const freshOut: Set<string>[] = Array.from({ length: blockCount }, () => all());
371
+ const freshIn: Set<string>[] = Array.from({ length: blockCount }, () => all());
372
+ const inOf = (b: number): Set<string> => {
373
+ // A block with NO predecessors is the function entry (or unreachable): its argument registers
374
+ // are the ones the caller set up. An entry that DOES have predecessors — an entry that is also
375
+ // a loop header — gets the ordinary intersection instead, because on the back edge the caller's
376
+ // setup is long gone and an intervening call may have clobbered it.
377
+ const ps = [...new Set(preds[b] ?? [])];
378
+ if (ps.length === 0) {
379
+ return all();
380
+ }
381
+ const acc = new Set(freshOut[ps[0]]);
382
+ for (const p of ps.slice(1)) {
383
+ for (const r of [...acc]) {
384
+ if (!freshOut[p].has(r)) {
385
+ acc.delete(r);
386
+ }
387
+ }
388
+ }
389
+ return acc;
390
+ };
391
+ for (let changed = true; changed;) {
392
+ changed = false;
393
+ for (let b = 0; b < blockCount; b++) {
394
+ const fin = inOf(b);
395
+ const fout = callsIn.has(b) ? localEnd(b) : new Set([...fin, ...localEnd(b)]);
396
+ if (fout.size !== freshOut[b].size || [...fout].some((r) => !freshOut[b].has(r))) {
397
+ changed = true;
398
+ }
399
+ freshIn[b] = fin;
400
+ freshOut[b] = fout;
401
+ }
402
+ }
403
+ for (const s of sites) {
404
+ const fresh = s.afterCallInBlock ? s.freshBefore : new Set([...freshIn[s.block], ...s.freshBefore]);
405
+ let n = 0;
406
+ while (n < argRegs.length && fresh.has(argRegs[n])) {
407
+ n++;
408
+ }
409
+ if (n < s.op.operands.length) {
410
+ s.op.operands.length = n;
411
+ }
412
+ }
413
+ }
414
+
415
+ /** The stack-slot key both the MIPS and Thumb frontends use for a word-sized local in the
416
+ * function's own frame. Shared so the two spell it identically and `assertNoSlotEscaped` can
417
+ * recognise either frontend's slots. See the virtual-key note in the module header. */
418
+ const SLOT_PREFIX = 'sp@';
419
+ export const stackSlotKey = (off: number): string => `${SLOT_PREFIX}${off}`;
420
+
177
421
  /** Order the TRUE entry block's parameters by ABI argument register, so downstream naming
178
422
  * (`a0`, `a1`, …) matches the calling convention, not first-read order (a callee-saved copy can
179
423
  * read a later argument register first). No-op when the entry has predecessors — a loop