@asmlift/core 0.2.0 → 0.4.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.
Files changed (43) hide show
  1. package/README.md +5 -3
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +154 -5
  4. package/src/backend/cpp.ts +3 -1
  5. package/src/backend/pascal.ts +11 -0
  6. package/src/contracts.ts +37 -5
  7. package/src/declare.ts +251 -0
  8. package/src/frontend/frontend.ts +12 -2
  9. package/src/frontend/mips.ts +24 -23
  10. package/src/frontend/opaque.ts +39 -2
  11. package/src/frontend/ssa.ts +32 -53
  12. package/src/frontend/thumb.ts +420 -32
  13. package/src/ir/opcodes.ts +44 -0
  14. package/src/ir/simplify.ts +72 -0
  15. package/src/l3/argbase.ts +216 -0
  16. package/src/l3/ast.ts +126 -6
  17. package/src/l3/basecse.ts +3 -40
  18. package/src/l3/coalesce.ts +146 -0
  19. package/src/l3/dce.ts +2 -23
  20. package/src/l3/hoist.ts +65 -0
  21. package/src/l3/reindex.ts +7 -0
  22. package/src/l3/scopebase.ts +436 -0
  23. package/src/l3/symbol-refs.ts +61 -0
  24. package/src/l3/tailmerge.ts +120 -0
  25. package/src/l3/typing.ts +4 -0
  26. package/src/macros.ts +335 -0
  27. package/src/pattern/engine.ts +99 -6
  28. package/src/pipeline.ts +20 -6
  29. package/src/proto.ts +55 -0
  30. package/src/raise/divpow2.ts +226 -0
  31. package/src/raise/gvn.ts +141 -0
  32. package/src/raise/pre-recovery.ts +37 -3
  33. package/src/raise/recover.ts +24 -7
  34. package/src/raise/retsink.ts +36 -7
  35. package/src/raise/shortcircuit.ts +264 -22
  36. package/src/raise/structs.ts +12 -2
  37. package/src/rank.ts +370 -79
  38. package/src/structure/analysis.ts +42 -1
  39. package/src/structure/structure.ts +852 -67
  40. package/src/structure/switch-recover.ts +21 -3
  41. package/src/symbols.ts +541 -0
  42. package/src/target.ts +4 -2
  43. package/src/trace.ts +17 -2
@@ -1,4 +1,16 @@
1
- // asmlift — boolean-value short-circuit recovery (F-CFG; successor-aware, agbcc-class).
1
+ // asmlift — short-circuit connective recovery (F-CFG; successor-aware, agbcc-class).
2
+ //
3
+ // A `&&`/`||` reaches the IR in two shapes, and this module recovers BOTH into the SAME pair of
4
+ // opcodes (`logic_and`/`logic_or`) the backend already prints as `&&`/`||`:
5
+ //
6
+ // - the VALUE form (`return a && b`) — a diamond whose merge phi is the boolean. That is
7
+ // `recognizeShortCircuit`, described below.
8
+ // - the CONTROL-FLOW form (`if (a || b) X else Y`) — no value at all, just two `cond_br` blocks
9
+ // that share a target. That is `recognizeBranchShortCircuit`, at the bottom of this file.
10
+ //
11
+ // They are one concept and stay in one file, but they are separate passes because their inputs do
12
+ // not overlap: the value form needs the second block to end in `br` carrying a phi argument, the
13
+ // branch form needs it to end in `cond_br`.
2
14
  //
3
15
  // `return a && b` compiles (agbcc) to a value-producing diamond: `if (a==0) result=0; else result=(b!=0)`,
4
16
  // where the merge block returns the phi. The structurer lowers that as `if (a==0){v0=0}else{v0=(-b|b)>>31}
@@ -27,26 +39,10 @@
27
39
  // Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, the head condition is a
28
40
  // negatable icmp, and any deviation falls through untouched (a miss, never a miscompile).
29
41
  import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
30
- import type { Opcode } from '../ir/opcodes';
31
- import { EFFECTFUL_OPS } from '../ir/opcodes';
42
+ import { HOIST_UNSAFE_OPS, NEGATED_ICMP } from '../ir/opcodes';
32
43
  import { T } from '../ir/types';
33
44
 
34
- const NEGATE_ICMP: Record<string, Opcode> = {
35
- icmp_eq: 'icmp_ne',
36
- icmp_ne: 'icmp_eq',
37
- icmp_slt: 'icmp_sge',
38
- icmp_sge: 'icmp_slt',
39
- icmp_sgt: 'icmp_sle',
40
- icmp_sle: 'icmp_sgt',
41
- icmp_ult: 'icmp_uge',
42
- icmp_uge: 'icmp_ult',
43
- icmp_ugt: 'icmp_ule',
44
- icmp_ule: 'icmp_ugt',
45
- };
46
- const BOOL_OPS = new Set([...Object.keys(NEGATE_ICMP), 'logic_and', 'logic_or']);
47
- // Ops with an observable side effect — unsafe to HOIST out of a short-circuit's conditional arm
48
- // (they would run unconditionally). Derived from the ONE effect table in ir/opcodes.ts.
49
- const SIDE_EFFECT = EFFECTFUL_OPS;
45
+ const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
50
46
 
51
47
  /** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
52
48
  // NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
@@ -124,6 +120,16 @@ export function recognizeShortCircuit(fn: Fn): boolean {
124
120
  if (bp.length !== 1) {
125
121
  continue;
126
122
  }
123
+ // The ENTRY block is never a feeder, for the same reason it is never ^g in the branch form
124
+ // below: `predecessors()` walks successor edges only, so an entry block that is also a loop
125
+ // header shows one predecessor while actually running BEFORE it on the first iteration.
126
+ // Hoisting its body then reorders it and deleting it moves `fn.blocks[0]`. Silent — verify,
127
+ // assertResolved and assertDerefsTyped all pass. PRE-EXISTING (this fold predates the branch
128
+ // form and `main` miscompiles the same MIPS input); fixed here because the branch form's
129
+ // note used to assert this one was safe.
130
+ if (bfeed === fn.blocks[0]) {
131
+ continue;
132
+ }
127
133
  const h = bp[0];
128
134
  const ht = term(h);
129
135
  if (ht.opcode !== 'cond_br') {
@@ -157,7 +163,7 @@ export function recognizeShortCircuit(fn: Fn): boolean {
157
163
  }
158
164
  const cond = ht.operands[0];
159
165
  const condDef = defs.get(cond);
160
- if (!condDef || !NEGATE_ICMP[condDef.opcode]) {
166
+ if (!condDef || !NEGATED_ICMP[condDef.opcode]) {
161
167
  continue;
162
168
  } // head condition must be a negatable icmp
163
169
 
@@ -171,14 +177,14 @@ export function recognizeShortCircuit(fn: Fn): boolean {
171
177
  // expression, where C's own short-circuit re-guards them. Any side effect ⇒ DECLINE the fold — the
172
178
  // merge-variable spelling the fall-through leaves is correct (the side effect stays in B's block),
173
179
  // just possibly non-matching.
174
- if (bfeed.ops.slice(0, -1).some((op) => SIDE_EFFECT.has(op.opcode))) {
180
+ if (bfeed.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
175
181
  continue;
176
182
  }
177
183
  bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
178
184
  let condSide = cond;
179
185
  if (wantNeg) {
180
186
  condSide = mkValue(T.unk(32));
181
- before(mkOp(NEGATE_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
187
+ before(mkOp(NEGATED_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
182
188
  }
183
189
  // Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
184
190
  let res = condSide;
@@ -205,3 +211,239 @@ export function recognizeShortCircuit(fn: Fn): boolean {
205
211
  }
206
212
  return changed;
207
213
  }
214
+
215
+ // ── the CONTROL-FLOW form ───────────────────────────────────────────────────────────────────────
216
+ //
217
+ // `if (a || b) X else Y` produces no value: it is two `cond_br` blocks that SHARE a target.
218
+ //
219
+ // ^h: cond_br c1, ^X, ^g <- `a`
220
+ // ^g: … ; cond_br c2, ^Y, ^X <- `b` (sole predecessor ^h)
221
+ //
222
+ // Nothing in the tower recognizes that today, so the structurer reaches ^X from two arms and
223
+ // TAIL-DUPLICATES it — `if (a) X else { if (b') Y else X }`. The duplicate is correct C but it is
224
+ // not the C the compiler compiled, and the duplicated tail costs every byte it contains.
225
+ //
226
+ // The fold rewrites ^h's terminator to one `cond_br` over a connective and drops ^g. Which
227
+ // connective, and which successor slot, follows from WHICH of ^h's edges leads to ^g:
228
+ //
229
+ // ^g is ^h's FALL → ^g runs iff !c1, so the SHARED block is taken iff `c1 || cShared`
230
+ // ⇒ cond_br(logic_or(c1, cShared))[shared, other]
231
+ // ^g is ^h's TAKEN → ^g runs iff c1, so the OTHER block is taken iff `c1 && cOther`
232
+ // ⇒ cond_br(logic_and(c1, cOther))[other, shared]
233
+ //
234
+ // where `cShared`/`cOther` is ^g's own condition ORIENTED at that block — ^g's `cond_br` operand
235
+ // when it already branches there, otherwise its negation (so ^g's condition must be a negatable
236
+ // icmp, exactly as in the value form). Both spellings keep ^h's original successor ORDER for the
237
+ // edge that did not change, so the branch sense the frontend read out of the asm is preserved.
238
+ //
239
+ // REFUSALS (each one a real way this could be wrong, not a hypothetical):
240
+ //
241
+ // - ^g is the ENTRY block. `predecessors()` walks successor edges only — it does not model the
242
+ // implicit edge into `fn.blocks[0]` — so an entry block that is ALSO a loop header (its one
243
+ // real predecessor being its own latch) passes the sole-predecessor test below while the whole
244
+ // soundness argument fails for it: on the first iteration ^g runs BEFORE ^h, so hoisting ^g's
245
+ // body into ^h reorders it, and deleting ^g moves the entry to another block entirely. That
246
+ // turns an entry-guarded `while` into a `do…while` whose body runs once unconditionally —
247
+ // silent wrong code, caught by no contract (verify, assertResolved and assertDerefsTyped all
248
+ // pass). MIPS and PPC reach this: only thumb.ts inserts a synthetic preheader that would give
249
+ // the header a second predecessor. `retsink.ts` guards the same way (`fn.blocks[0] !== m`).
250
+ // - ^g has a predecessor other than ^h — folding would delete a block still reachable elsewhere.
251
+ // - ^g has block params — ^h's edge binds them, and dropping the edge drops the binding.
252
+ // - ^h and ^g both test the SAME value against CONSTANTS — that is a comparison-tree `switch`,
253
+ // not a hand-written `||`. switch-recover.ts requires every test's `cond_br` operand to be an
254
+ // `icmp` (its `isCmpOpcode` gate), and a `logic_or` is not one, so folding first PERMANENTLY
255
+ // disqualifies the recovery and a clean `switch (x) { case 1: case 2: … }` degrades to a chain
256
+ // of nested `if`s. The switch is the better recovery and it is the more specific one, so it
257
+ // wins the shape. Cost: a genuine source-level `x == 1 || x == 2` that is NOT part of a wider
258
+ // tree also declines — the same conservative trade loops.ts makes when it refuses to infer a
259
+ // header from `cond_br` shape.
260
+ // - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
261
+ // would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
262
+ // - a value defined in ^g is used outside ^g, or used more than once. Then the structurer
263
+ // MATERIALIZES it into a local, which renders as a statement BEFORE the `if` — turning `b`'s
264
+ // conditional computation into an unconditional one. Single-use-and-local is precisely the
265
+ // shape analysis.ts inlines into the connective's right operand, where C's own short-circuit
266
+ // re-guards it. This is what keeps a load in `b` from being hoisted across the guard in `a`.
267
+ // - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
268
+ // so it can only carry one argument list; picking either would silently drop the other path's
269
+ // phi input.
270
+ // - ^g's two successors are the same block, or ^g's condition is not a negatable icmp when the
271
+ // orientation needs negating.
272
+ //
273
+ // Every refusal falls through untouched, leaving the tail-duplicated spelling — a miss, never a
274
+ // miscompile. Applied ITERATIVELY, so `a || b || c` folds left-to-right, each round consuming one
275
+ // more condition block.
276
+ export function recognizeBranchShortCircuit(fn: Fn): boolean {
277
+ let changed = false;
278
+ const term = (b: Block) => b.ops[b.ops.length - 1];
279
+ let progress = true;
280
+ while (progress) {
281
+ progress = false;
282
+ const defs = defOpMap(fn);
283
+ const preds = predecessors(fn);
284
+ outer: for (const h of fn.blocks) {
285
+ const ht = term(h);
286
+ if (ht.opcode !== 'cond_br') {
287
+ continue;
288
+ }
289
+ const [taken, fall] = ht.successors;
290
+ // Try ^g = the fall edge, then ^g = the taken edge. `gIsFall` picks the connective.
291
+ for (const gIsFall of [true, false]) {
292
+ const gEdge = gIsFall ? fall : taken;
293
+ const sharedFromH = gIsFall ? taken : fall;
294
+ const g = gEdge.block;
295
+ if (g === h || g === sharedFromH.block || g.params.length > 0) {
296
+ continue;
297
+ }
298
+ // The ENTRY block is never ^g — see the REFUSALS note. `predecessors()` cannot see the
299
+ // implicit entry edge, so this is the only thing standing between an entry-block loop
300
+ // header and a silently reordered function body.
301
+ if (g === fn.blocks[0]) {
302
+ continue;
303
+ }
304
+ if ((preds.get(g) ?? []).length !== 1) {
305
+ continue;
306
+ }
307
+ const gt = term(g);
308
+ if (gt.opcode !== 'cond_br') {
309
+ continue;
310
+ }
311
+ const [gTaken, gFall] = gt.successors;
312
+ if (gTaken.block === gFall.block) {
313
+ continue;
314
+ }
315
+ // A comparison TREE over one scrutinee belongs to switch recovery, not to this fold.
316
+ if (sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0])) {
317
+ continue;
318
+ }
319
+ // ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
320
+ // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
321
+ // short circuit.
322
+ // HOIST_UNSAFE_OPS, not EFFECTFUL_OPS: a live `opaque` is an instruction asmlift could not
323
+ // model, and moving it out of the arm that guards it is the reordering this refuses. Loud
324
+ // either way today (a decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under
325
+ // `annotate`, the CLI and benchmark default), so this closes a model gap rather than fixing
326
+ // an observed bug.
327
+ const body = g.ops.slice(0, -1);
328
+ if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
329
+ continue;
330
+ }
331
+ if (!definedValuesStayLocal(fn, g)) {
332
+ continue;
333
+ }
334
+ // Which of ^g's edges rejoins ^h's other successor? That is the shared block.
335
+ const sharedEdge =
336
+ gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
337
+ if (!sharedEdge) {
338
+ continue;
339
+ }
340
+ const otherEdge = sharedEdge === gTaken ? gFall : gTaken;
341
+ if (!sameArgs(sharedFromH.args, sharedEdge.args)) {
342
+ continue;
343
+ }
344
+ // The second operand, oriented at the block whose slot it decides: `logic_or` asks "does ^g
345
+ // reach the SHARED block", `logic_and` asks "does ^g reach the OTHER block".
346
+ const wantEdge = gIsFall ? sharedEdge : otherEdge;
347
+ const c2 = gt.operands[0];
348
+ const c2Def = defs.get(c2);
349
+ let second = c2;
350
+ const negated: Op[] = [];
351
+ if (wantEdge !== gTaken) {
352
+ if (!c2Def || !NEGATED_ICMP[c2Def.opcode]) {
353
+ continue;
354
+ }
355
+ second = mkValue(T.unk(32));
356
+ negated.push(mkOp(NEGATED_ICMP[c2Def.opcode], { operands: [...c2Def.operands], results: [second] }));
357
+ }
358
+ const res = mkValue(T.unk(32));
359
+ const connective = mkOp(gIsFall ? 'logic_or' : 'logic_and', {
360
+ operands: [ht.operands[0], second],
361
+ results: [res],
362
+ });
363
+ // ^g's body moves ahead of ^h's terminator; ^h keeps the successor SLOT that did not change
364
+ // (taken=shared for `||`, taken=other for `&&`), so the frontend's branch sense survives.
365
+ h.ops.splice(h.ops.length - 1, 1, ...body, ...negated, connective, {
366
+ ...mkOp('cond_br', { operands: [res] }),
367
+ successors: gIsFall
368
+ ? [
369
+ { block: sharedEdge.block, args: [...sharedEdge.args] },
370
+ { block: otherEdge.block, args: [...otherEdge.args] },
371
+ ]
372
+ : [
373
+ { block: otherEdge.block, args: [...otherEdge.args] },
374
+ { block: sharedEdge.block, args: [...sharedEdge.args] },
375
+ ],
376
+ });
377
+ fn.blocks = fn.blocks.filter((x) => x !== g);
378
+ changed = true;
379
+ progress = true;
380
+ break outer; // defs/preds are stale after the mutation — recompute on the next round
381
+ }
382
+ }
383
+ }
384
+ return changed;
385
+ }
386
+
387
+ /** Do `c1` and `c2` compare the SAME value against CONSTANTS? That is the signature of a
388
+ * comparison-tree `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests
389
+ * only: a switch tree dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the
390
+ * range check) is a genuine connective this fold should still take. */
391
+ function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
392
+ const eqTest = (v: Value): { scrutinee: Value } | null => {
393
+ const d = defs.get(v);
394
+ if (!d || (d.opcode !== 'icmp_eq' && d.opcode !== 'icmp_ne')) {
395
+ return null;
396
+ }
397
+ const [x, y] = d.operands;
398
+ const xc = defs.get(x)?.opcode === 'const';
399
+ const yc = defs.get(y)?.opcode === 'const';
400
+ // exactly one side constant — `x == y` between two variables is no switch test
401
+ return xc === yc ? null : { scrutinee: xc ? y : x };
402
+ };
403
+ const a = eqTest(c1);
404
+ const b = eqTest(c2);
405
+ return a !== null && b !== null && a.scrutinee === b.scrutinee;
406
+ }
407
+
408
+ /** True when every value `g` defines is read at most once, and any read is inside `g`.
409
+ *
410
+ * The VALUE form above needs no such check, and the asymmetry is real rather than drift: its feeder
411
+ * ends in `br M`, so the feeder has no successor of its own to dominate and every value it defines
412
+ * is either read in the feeder or carried to `M` as the phi argument the fold consumes. Here ^g
413
+ * ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
414
+ * escape, and only this check stops it.
415
+ *
416
+ * An earlier version of this note justified the asymmetry by "the feeder dominates nothing but
417
+ * itself because M has 2+ predecessors", and told the reader not to unify the guards. That was
418
+ * WRONG — the entry block dominates every block whatever M's predecessor count — and it was wrong
419
+ * about the one guard the two folds genuinely DO share, the `fn.blocks[0]` refusal, which the value
420
+ * form was missing entirely. Both now have it. When changing either fold, check the other. */
421
+ function definedValuesStayLocal(fn: Fn, g: Block): boolean {
422
+ const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
423
+ if (defined.size === 0) {
424
+ return true;
425
+ }
426
+ const uses = new Map<Value, number>();
427
+ for (const b of fn.blocks) {
428
+ for (const op of b.ops) {
429
+ for (const v of [...op.operands, ...op.successors.flatMap((s) => s.args)]) {
430
+ if (!defined.has(v)) {
431
+ continue;
432
+ }
433
+ if (b !== g) {
434
+ return false; // escapes ^g — the structurer would render it before the `if`
435
+ }
436
+ uses.set(v, (uses.get(v) ?? 0) + 1);
437
+ }
438
+ }
439
+ }
440
+ // ZERO uses is fine — a dead op renders nothing at all, so it cannot escape the short circuit.
441
+ // TWO or more is not: analysis.ts materializes a multi-consumer value into a local, which is a
442
+ // statement, and a statement lands before the `if`.
443
+ return [...defined].every((v) => (uses.get(v) ?? 0) <= 1);
444
+ }
445
+
446
+ /** Two successor argument lists that a fold may collapse into one: same values, same order. */
447
+ function sameArgs(a: Value[], b: Value[]): boolean {
448
+ return a.length === b.length && a.every((v, i) => v === b[i]);
449
+ }
@@ -196,8 +196,18 @@ export function recognizeStructs(fn: Fn): number {
196
196
  return count;
197
197
  }
198
198
 
199
- /** The distinct struct types this function references (unwrapping struct pointers on every value),
200
- * deduped by name and sorted, for the backend to declare above the function. */
199
+ /** The distinct struct types this function's L2 GRAPH mentions (unwrapping struct pointers on every
200
+ * value), deduped by name and sorted, for the backend to declare above the function.
201
+ *
202
+ * "Mentions", not "references": this walks `fn.blocks` at the moment structuring runs, and the
203
+ * result is CACHED on the SFn (`structure.ts`) and carried by every later `{...sfn}` pass, so a
204
+ * struct whose last use a subsequent L3 pass removed would still be declared. Sibling `locals` is
205
+ * reference-pruned after dead-store elimination (`l3/dce.ts`) for exactly that reason; this list is
206
+ * not. No pass drops such a use today — the IR-level DCE runs long before recognition, and l3/dce's
207
+ * `mustKeep` never drops a `field`/`index` — so the staleness is LATENT, not live, which is why it
208
+ * is recorded here rather than fixed speculatively. The fix, if a pass ever makes it reachable, is
209
+ * the one l3/symbol-refs.ts already applies to symbol references: derive at the consumption point
210
+ * (`backend/cfamily.ts` structDecls) instead of caching. */
201
211
  export function collectStructs(fn: Fn): StructType[] {
202
212
  const seen = new Map<string, StructType>();
203
213
  const consider = (t: IrType) => {