@asmlift/core 0.8.1 → 0.10.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 (130) hide show
  1. package/README.md +36 -31
  2. package/package.json +1 -1
  3. package/src/aggregate.ts +397 -0
  4. package/src/backend/c.ts +6 -2
  5. package/src/backend/cfamily.ts +370 -56
  6. package/src/backend/cpp.ts +116 -10
  7. package/src/backend/pascal.ts +69 -11
  8. package/src/cdecl/constant.ts +59 -0
  9. package/src/cdecl/lex.ts +209 -0
  10. package/src/cdecl/parse.ts +975 -0
  11. package/src/cdecl/spell.ts +69 -0
  12. package/src/codegen-flags.ts +38 -6
  13. package/src/compiler-diagnostics.ts +194 -0
  14. package/src/contracts.ts +777 -84
  15. package/src/declare.ts +132 -13
  16. package/src/frontend/asmdata.ts +26 -6
  17. package/src/frontend/call-plan.ts +1590 -0
  18. package/src/frontend/dead-reads.ts +628 -0
  19. package/src/frontend/device-pins.ts +296 -0
  20. package/src/frontend/emit.ts +4 -3
  21. package/src/frontend/errors.ts +5 -0
  22. package/src/frontend/flags-edge.ts +101 -0
  23. package/src/frontend/fpu.ts +374 -0
  24. package/src/frontend/frame-objects.ts +3171 -0
  25. package/src/frontend/frontend.ts +64 -3
  26. package/src/frontend/high-half.ts +55 -5
  27. package/src/frontend/jump-table.ts +530 -0
  28. package/src/frontend/local-object.ts +616 -0
  29. package/src/frontend/mips.ts +2392 -208
  30. package/src/frontend/opaque.ts +97 -18
  31. package/src/frontend/own-struct-return.ts +500 -0
  32. package/src/frontend/ppc.ts +3137 -267
  33. package/src/frontend/register-struct-call.ts +484 -0
  34. package/src/frontend/reloc-symbol.ts +109 -13
  35. package/src/frontend/splat.ts +108 -5
  36. package/src/frontend/ssa.ts +2098 -89
  37. package/src/frontend/stackargs.ts +172 -42
  38. package/src/frontend/thumb.ts +4563 -2091
  39. package/src/ir/alias.ts +71 -0
  40. package/src/ir/bits.ts +681 -1
  41. package/src/ir/core.ts +199 -6
  42. package/src/ir/discipline.ts +313 -0
  43. package/src/ir/entry.ts +159 -0
  44. package/src/ir/float-bits.ts +47 -0
  45. package/src/{structure → ir}/loops.ts +6 -2
  46. package/src/ir/opcodes.ts +193 -121
  47. package/src/ir/parse.ts +1 -0
  48. package/src/ir/types.ts +202 -2
  49. package/src/ir/verify.ts +219 -3
  50. package/src/l3/address.ts +5 -0
  51. package/src/l3/argcopy.ts +38 -6
  52. package/src/l3/ast.ts +467 -23
  53. package/src/l3/basecse.ts +33 -5
  54. package/src/l3/coalesce.ts +21 -13
  55. package/src/l3/constinit.ts +382 -0
  56. package/src/l3/dce.ts +34 -12
  57. package/src/l3/gates.ts +9 -3
  58. package/src/l3/hoist.ts +8 -7
  59. package/src/l3/initfirst.ts +68 -19
  60. package/src/l3/intcell.ts +98 -0
  61. package/src/l3/mentions.ts +10 -4
  62. package/src/l3/narrowdecl.ts +259 -0
  63. package/src/l3/nearbase.ts +10 -5
  64. package/src/l3/parkfirst.ts +8 -5
  65. package/src/l3/pollguard.ts +11 -4
  66. package/src/l3/ptrcell.ts +354 -0
  67. package/src/l3/ptrfield.ts +123 -8
  68. package/src/l3/regspell.ts +26 -7
  69. package/src/l3/reindex.ts +293 -103
  70. package/src/l3/storage.ts +98 -1
  71. package/src/l3/symbol-refs.ts +182 -23
  72. package/src/l3/tailmerge.ts +9 -20
  73. package/src/l3/typing.ts +237 -32
  74. package/src/l3/unreduce.ts +10 -9
  75. package/src/l3/volatileptr.ts +17 -54
  76. package/src/l3/volstore.ts +96 -7
  77. package/src/mangle.ts +41 -1
  78. package/src/pattern/engine.ts +373 -82
  79. package/src/pipeline.ts +589 -27
  80. package/src/proto-context.ts +1648 -0
  81. package/src/proto.ts +1203 -49
  82. package/src/raise/aggmembers.ts +290 -0
  83. package/src/raise/arrays.ts +8 -3
  84. package/src/raise/declared-volatile.ts +307 -0
  85. package/src/raise/divpow2.ts +2 -2
  86. package/src/raise/errors.ts +17 -0
  87. package/src/raise/floathelpers.ts +464 -0
  88. package/src/raise/globalshape.ts +11 -5
  89. package/src/raise/gvn.ts +29 -8
  90. package/src/raise/latch.ts +162 -6
  91. package/src/raise/memberarrays.ts +10 -4
  92. package/src/raise/narrow.ts +29 -2
  93. package/src/raise/narrowlocal.ts +11 -6
  94. package/src/raise/offsetnames.ts +494 -0
  95. package/src/raise/pairparams.ts +114 -0
  96. package/src/raise/paramwidth.ts +203 -6
  97. package/src/raise/pre-recovery.ts +174 -17
  98. package/src/raise/pre-structure.ts +54 -0
  99. package/src/raise/recover.ts +26 -15
  100. package/src/raise/reroll.ts +694 -0
  101. package/src/raise/retsink.ts +1 -1
  102. package/src/raise/shortcircuit.ts +66 -30
  103. package/src/raise/softdiv.ts +21 -23
  104. package/src/raise/storesign.ts +153 -0
  105. package/src/raise/struct-arrays.ts +34 -14
  106. package/src/raise/structs.ts +674 -98
  107. package/src/raise/truncload.ts +367 -0
  108. package/src/raise/unthread.ts +400 -0
  109. package/src/raise/widehelpers.ts +106 -0
  110. package/src/rank-declare.ts +143 -12
  111. package/src/rank-variations.ts +117 -31
  112. package/src/rank.ts +563 -122
  113. package/src/runtime-helpers.ts +405 -0
  114. package/src/stillborn.ts +289 -0
  115. package/src/structure/analysis.ts +1449 -449
  116. package/src/structure/arith-ops.ts +23 -0
  117. package/src/structure/globalaccess.ts +111 -9
  118. package/src/structure/hazards.ts +641 -114
  119. package/src/structure/local-statics.ts +517 -0
  120. package/src/structure/namecoalesce.ts +2 -2
  121. package/src/structure/pointer-spelling.ts +1106 -0
  122. package/src/structure/redundant-test.ts +99 -0
  123. package/src/structure/structure.ts +3478 -975
  124. package/src/structure/switch-recover.ts +403 -178
  125. package/src/symbols.ts +44 -3
  126. package/src/target.ts +1387 -71
  127. package/src/trace.ts +66 -28
  128. package/src/variation-definitions.ts +243 -13
  129. package/src/variation-gates.ts +2 -0
  130. package/src/variation-tokens.ts +25 -7
@@ -8,8 +8,16 @@
8
8
  // the pure defs the homing rules claim);
9
9
  // • the HOMING-VARIATION ENUMERATION GATES — one export per structure variation, each answering
10
10
  // "does this function hold a value the variation would home at all" so rank.ts can skip a
11
- // variation whose candidate would only duplicate the default. Each mirrors its variation's scope inside `analyze`
11
+ // variation whose candidate would only duplicate the default. Each mirrors its variation's scope in `namePureOp`
12
12
  // and states where it DIVERGES from it, in which direction, and what that costs.
13
+ //
14
+ // `analyze` runs stages — `analysisStages` wires `indexUses`, `edgeArgUses`, `makeRenderModel`,
15
+ // `makeInlineReach`, `makeLoopRules` and `homeScopes` — and then the rules over them: `namePureOp`,
16
+ // `nameAccess`, `namePreUpdateHelpers`. The fixpoint driver stays in `analyze` because it IS the
17
+ // order: reverse program order, the escape phase once every other rule has settled, the render
18
+ // caches cleared at the head of each sweep. `MaterializeState.materialize` is shared LIVE with the
19
+ // render model, the inline reach and the loop rules: a rule's verdict for one op turns on the names
20
+ // the same sweep has already given the ops after it.
13
21
  import { disjointConstSlots, globalCellOf, mayWriteGlobal } from '../ir/alias';
14
22
  import {
15
23
  Block,
@@ -23,7 +31,8 @@ import {
23
31
  predecessors,
24
32
  successorsOf,
25
33
  } from '../ir/core';
26
- import { EFFECTFUL_OPS, MEM_BASE_OPS, ORDER_SENSITIVE_OPS, REEVAL_UNSAFE_OPS } from '../ir/opcodes';
34
+ import { counted, effectful, orderSensitive, placedAt, reevalUnsafe } from '../ir/discipline';
35
+ import { MEM_BASE_OPS, opSig } from '../ir/opcodes';
27
36
 
28
37
  export interface UseSite {
29
38
  blk: Block;
@@ -47,8 +56,11 @@ function rendersAsAddress(op: Op): boolean {
47
56
  * branch on the contract that the structurer inlines it back under C's own short circuit, so the
48
57
  * def block of anything in it is a FOLD ARTIFACT — a rule that names one of these values there
49
58
  * emits it above the guard the source wrote (`p != 0 && *p != 0` becoming `v0 = *p;` above its own
50
- * null check). Asked by the def-block placement rule and by the merge-feed-home scope. */
51
- function shortCircuitGuardedValues(fn: Fn, defOf: Map<Value, Op>): Set<Value> {
59
+ * null check). Asked by the def-block placement rule and by the merge-feed-home scope; the
60
+ * guarded-call rule asks the same set for the opposite answer, since a counted op is
61
+ * `speculationUnsafe` and so was never folded into the cone in the first place — and
62
+ * structure.ts asks it for a read placed only as structuring starts, which a fold saw as plain. */
63
+ export function shortCircuitGuardedValues(fn: Fn, defOf: Map<Value, Op>): Set<Value> {
52
64
  const guarded = new Set<Value>();
53
65
  const work: Value[] = [];
54
66
  for (const b of fn.blocks) {
@@ -260,7 +272,7 @@ function readCone(op0: Op, defOf: Map<Value, Op>): Op[] | null {
260
272
 
261
273
  /** rank.ts's enumeration gate for the `/addr-home` variation: does the function HAVE a value the
262
274
  * variation would home — a non-const pure def whose merge class is a shared base, with no gaddr/laddr in
263
- * its cone? Mirrors the variation's scope rule in `analyze` (the same `sharedBaseClasses` call), minus
275
+ * its cone? Mirrors the variation's scope rule in `namePureOp` (the same `sharedBaseClasses` call), minus
264
276
  * the loop-header seat refusal (that needs the loop model; a false positive costs one
265
277
  * duplicate-collapsed candidate, never a wrong one).
266
278
  *
@@ -375,8 +387,7 @@ export function hasDerivedReadHome(fn: Fn): boolean {
375
387
  }
376
388
  /** any op that writes memory strictly between two ops of one block — the rule's `memWriteBetween`
377
389
  * over the straight line the same-block requirement already pins */
378
- const writeBetween = (b: Block, lo: number, hi: number): boolean =>
379
- b.ops.slice(lo + 1, hi).some((x) => EFFECTFUL_OPS.has(x.opcode));
390
+ const writeBetween = (b: Block, lo: number, hi: number): boolean => b.ops.slice(lo + 1, hi).some(effectful);
380
391
  const standsOnRead = (op0: Op): boolean => {
381
392
  const reads = readCone(op0, defOf);
382
393
  if (reads === null) {
@@ -402,15 +413,73 @@ export function hasDerivedReadHome(fn: Fn): boolean {
402
413
  return false;
403
414
  }
404
415
 
416
+ /** The `/escape-home` variation's SCOPE, as one definition the variation's rule and its enumeration
417
+ * gate both read: a `zext`/`sext` with 2+ distinct consumers, none of them in its own block. A
418
+ * successor ARG counts as a use in the block whose terminator carries it, which is where the edge
419
+ * copy is emitted, so an edge-fed value is never mistaken for one that escaped.
420
+ *
421
+ * `ignoreRet` is the one fact the gate cannot know, on the same terms as `sharedBaseClasses`':
422
+ * `analyze` drops a void function's `ret` operand as a phantom, and a dropped operand moves this
423
+ * answer in BOTH directions — it can take a value below two consumers, and it can remove the
424
+ * at-home use that disqualified one. Which is why the gate asks under both readings rather than
425
+ * picking the one that happens to admit more.
426
+ *
427
+ * The seat refusal (`multiBlockHeaders`) is NOT here: it needs the loop model, it is a clause of
428
+ * the rule rather than of the scope, and over-admitting at the gate costs one duplicate-collapsed
429
+ * candidate. */
430
+ export function escapingExtensions(fn: Fn, ignoreRet: boolean): Set<Op> {
431
+ const consumers = new Map<Value, Set<Op>>();
432
+ const blockOf = new Map<Op, Block>();
433
+ for (const b of fn.blocks) {
434
+ for (const op of b.ops) {
435
+ blockOf.set(op, b);
436
+ if (ignoreRet && op.opcode === 'ret') {
437
+ continue;
438
+ }
439
+ const use = (v: Value) => (consumers.get(v) ?? consumers.set(v, new Set()).get(v)!).add(op);
440
+ for (const o of op.operands) {
441
+ use(o);
442
+ }
443
+ for (const sx of op.successors) {
444
+ for (const a of sx.args) {
445
+ use(a);
446
+ }
447
+ }
448
+ }
449
+ }
450
+ const out = new Set<Op>();
451
+ for (const b of fn.blocks) {
452
+ for (const op of b.ops) {
453
+ if (op.opcode !== 'zext' && op.opcode !== 'sext') {
454
+ continue;
455
+ }
456
+ const cs = consumers.get(op.results[0]);
457
+ if (cs !== undefined && cs.size >= 2 && [...cs].every((c) => blockOf.get(c) !== b)) {
458
+ out.add(op);
459
+ }
460
+ }
461
+ }
462
+ return out;
463
+ }
464
+
465
+ /** rank.ts's enumeration gate for `/escape-home`: does the function HAVE a value the variation
466
+ * would home? It RUNS the scope rather than restating it — see `escapingExtensions` for the one
467
+ * divergence left, and `sharedBaseClasses` for the shape this follows. */
468
+ export function hasEscapingExtension(fn: Fn): boolean {
469
+ return escapingExtensions(fn, false).size > 0 || escapingExtensions(fn, true).size > 0;
470
+ }
471
+
472
+ interface NaturalLoop {
473
+ header: Block;
474
+ latch: Block;
475
+ body: Set<Block>;
476
+ }
477
+
405
478
  /** Natural loops: a back edge is `latch → header` with the header dominating the latch, and the
406
479
  * body is the backward closure from the latch. Shared by the rules inside `analyze` and by the
407
480
  * `/merge-home` gate below, so the two cannot disagree about what "inside a loop" means. */
408
- function naturalLoops(
409
- fn: Fn,
410
- dom: Map<Block, Set<Block>>,
411
- predsOf: Map<Block, Block[]>,
412
- ): { header: Block; body: Set<Block> }[] {
413
- const loops: { header: Block; body: Set<Block> }[] = [];
481
+ function naturalLoops(fn: Fn, dom: Map<Block, Set<Block>>, predsOf: Map<Block, Block[]>): NaturalLoop[] {
482
+ const loops: NaturalLoop[] = [];
414
483
  for (const latch of fn.blocks) {
415
484
  for (const header of successorsOf(latch)) {
416
485
  if (!dom.get(latch)?.has(header)) {
@@ -425,7 +494,7 @@ function naturalLoops(
425
494
  work.push(...(predsOf.get(x) ?? []));
426
495
  }
427
496
  }
428
- loops.push({ header, body });
497
+ loops.push({ header, latch, body });
429
498
  }
430
499
  }
431
500
  return loops;
@@ -456,11 +525,11 @@ function naturalLoops(
456
525
  * re-derive the byte cast (`v0 = (u8 *)*a1 + 6;` then `v0 = (u8 *)v0 + 2;`). Address-shaped
457
526
  * bases belong to the cast-aware machinery in l3/basecse.ts, scopebase.ts and nearbase.ts;
458
527
  * this variation does not offer a second spelling of them.
459
- * • an op whose answer depends on WHERE it runs, or that can TRAP — `REEVAL_UNSAFE_OPS`, read
528
+ * • an op whose answer depends on WHERE it runs, or that can TRAP — `reevalUnsafe`, read
460
529
  * from the registry rather than re-listed. Both halves carry: for a read WHERE it happens is
461
530
  * the read rules' question, and a homed divide becomes an unconditional statement at a def
462
531
  * block raise/shortcircuit.ts may have made, on paths C's own `&&` would have re-guarded —
463
- * the KNOWN GAP `ir/opcodes.ts` books against `HOIST_UNSAFE_OPS`.
532
+ * the gap ir/discipline.ts books against `speculationUnsafe`.
464
533
  * • an `undef` — the variation's premise is a value the source COMPUTED once above the branch, and
465
534
  * an uninitialised register was never computed at all: homed, it spells `v0 = uninit_r5;`,
466
535
  * a copy of a value nothing wrote, which no asm can have.
@@ -481,7 +550,7 @@ function naturalLoops(
481
550
  * joins with params) is refused by this clause and no other, and lifting it takes that
482
551
  * function's map-less enumeration 75264 → 150528 candidates, 102s → 207s. A null there is
483
552
  * this refusal, not an absent idiom.
484
- * The `analyze` scope adds nothing to this list — the whole predicate is here, so the variation's
553
+ * The `namePureOp` scope adds nothing to this list — the whole predicate is here, so the variation's
485
554
  * enumeration gate can run it rather than approximate it. */
486
555
  function mergeFeedHomes(fn: Fn, dom: Map<Block, Set<Block>>, defOf: Map<Value, Op>, inLoop: Set<Block>): Set<Op> {
487
556
  // Every block's incoming COPY SITES — the places its edge assignments render, each once. Neither
@@ -537,8 +606,10 @@ function mergeFeedHomes(fn: Fn, dom: Map<Block, Set<Block>>, defOf: Map<Value, O
537
606
  // Every value an edge ARGUMENT renders — the argument itself plus its inlined operand cone,
538
607
  // memoized per value so one walk serves every slot. The walk stops DESCENDING at an
539
608
  // order-sensitive def: under a call or a memory read the value renders at that op's own position,
540
- // never at this edge. It crosses a trapping divide, which nothing materializes — that renders
541
- // inline at the copy site and its operands render there with it. The stopping op is still
609
+ // never at this edge. It crosses a trapping divide, which renders inline at the copy site with its
610
+ // operands. `analyze` names some divides at their def (the helper clause, the pre-update exit
611
+ // rule), after this walk, so their operands still count as rendered here: an extra render, which
612
+ // costs a candidate that homes a value rendered once. The stopping op is still
542
613
  // recorded, so a membership test over the cone sees it.
543
614
  const coneCache = new Map<Value, Set<Value>>();
544
615
  const coneOf = (root: Value): Set<Value> => {
@@ -562,7 +633,7 @@ function mergeFeedHomes(fn: Fn, dom: Map<Block, Set<Block>>, defOf: Map<Value, O
562
633
  continue;
563
634
  }
564
635
  const d = defOf.get(x);
565
- if (d && !ORDER_SENSITIVE_OPS.has(d.opcode)) {
636
+ if (d && !orderSensitive(d)) {
566
637
  stack.push(...d.operands);
567
638
  }
568
639
  }
@@ -576,14 +647,14 @@ function mergeFeedHomes(fn: Fn, dom: Map<Block, Set<Block>>, defOf: Map<Value, O
576
647
  const coneHoldsReevalUnsafe = (v: Value): boolean =>
577
648
  [...coneOf(v)].some((x) => {
578
649
  const d = defOf.get(x);
579
- return d !== undefined && REEVAL_UNSAFE_OPS.has(d.opcode);
650
+ return d !== undefined && reevalUnsafe(d);
580
651
  });
581
652
  /** may this op's result be materialized at its def — and is this variation the one to do it? */
582
653
  const eligible = (op: Op): boolean => {
583
654
  const v = op.results[0];
584
655
  return (
585
656
  v !== undefined &&
586
- !REEVAL_UNSAFE_OPS.has(op.opcode) &&
657
+ !reevalUnsafe(op) &&
587
658
  op.opcode !== 'undef' &&
588
659
  !(scGuarded.has(v) && coneHoldsReevalUnsafe(v)) &&
589
660
  !rendersAsAddress(op) &&
@@ -667,6 +738,9 @@ export interface StructureAnalysis {
667
738
  /** defs that must emit as named temps at their own position — calls/loads for effect order,
668
739
  * plus the pure defs the homing rules claim */
669
740
  materialize: Set<Op>;
741
+ /** the members of `materialize` that name a pre-update read: the escape rule's
742
+ * (`escapesAheadOfUpdate`), and a helper op or exit divide named over one */
743
+ preUpdateHomes: Set<Op>;
670
744
  /** cached forward reachability (successors-transitive, excluding the start block itself) */
671
745
  reachFrom: (b: Block) => Set<Block>;
672
746
  /** where a value's expression ultimately renders — the anchored consumer it inlines into,
@@ -709,12 +783,6 @@ export interface AnalyzeOptions {
709
783
  * materializes first). That is a pre-emption, not a conflict: a per-arm source read compiles to
710
784
  * a per-arm load on that compiler, so the sunk spelling is one it did not emit from this asm. */
711
785
  rereadGlobals?: boolean;
712
- /** "does the project declare this global volatile?" — a read of a volatile object may NOT be
713
- * duplicated or moved, so the variation above refuses on one. Answers false for a symbol the map
714
- * does not carry (and for no map at all), which is the same posture the multi-render rule has
715
- * always had: without a declaration nothing here can know, and the differ referees the extra
716
- * load. Where the map DOES know, the variation is silent about it rather than wrong. */
717
- volatileGlobal?: (name: string) => boolean;
718
786
  /** The in-place-join variation (rank.ts `/inplace`). A load whose result is a `cond_br` successor
719
787
  * ARG feeds a merge: rendered inline it has no name, so the merge param mints a fresh variable
720
788
  * and BOTH arms must assign it. Materialized, the naming walk can home the merge in the load's
@@ -781,12 +849,39 @@ export interface AnalyzeOptions {
781
849
  * address (rendered standalone the byte-stride cast lands outside the sum — see
782
850
  * `rendersAsAddress`). And the multi-block-loop-header seat the sibling variations refuse. */
783
851
  homeDerivedReads?: boolean;
852
+ /** Name a float product a float add or subtract reads, so a contracting compiler cannot fuse the
853
+ * two (StructureOptions.contractsFloatProducts). */
854
+ contractsFloatProducts?: boolean;
784
855
  /** The merge-feed-home variation (rank.ts `/merge-home`). A pure value one join's incoming edges
785
856
  * render into the SAME parameter slot from 2+ places materializes at its def: the copy machinery
786
857
  * has no name to reference, so the default re-derives the whole expression per arm
787
858
  * (`m = (-(b & 1) | b & 1) >> 31 & 0x400;` in both) and agbcc if-converts each copy. The scope
788
859
  * and every refusal it states are `mergeFeedHomes` above, which the enumeration gate also runs. */
789
860
  homeMergeFeeds?: boolean;
861
+ /** The escaping-extension-home variation (rank.ts `/escape-home`). A `zext`/`sext` with 2+ distinct
862
+ * consumers, none of them in its own block, renders by default re-evaluated at every consumer —
863
+ * `(u16)v` spelled once per use — where the asm narrowed once into a register and every later
864
+ * block read that register. Materialized, the truncation is a local computed at the def, which
865
+ * is where the asm computed it.
866
+ *
867
+ * The MIRROR of `/expr-home`, which wants a def OUTSIDE a loop with a consumer inside: here every
868
+ * consumer is outside the def's block, and neither scope reaches the other's shape. Straight-line
869
+ * values whose def block also consumes them stay with the siblings — a consumer beside the def is
870
+ * what says the compiler could re-derive there. What is left reaches six corpus rows over two
871
+ * toolchains, which `bench diff`'s FAN section names, plus the synthetic row that pins it.
872
+ *
873
+ * A THIRD SPELLING of the shape `raise/narrowlocal.ts` tabulates. That pass reads one extension
874
+ * over raw in-edges and picks between `u16 v` and `s32 v` + `(u16)v` off branch-shape evidence;
875
+ * this one leaves the width alone and gives the truncation a name where it happened. They do not
876
+ * overlap — narrowlocal judges a block PARAMETER, this an extension's result — and they are
877
+ * neighbours rather than alternatives.
878
+ *
879
+ * A differ-refereed variation, never a default: `pokeemerald:AcroBikeHandleInputTurning` is a
880
+ * MATCH inside this scope, so forced on the spelling would be REPLACED across the fan rather than
881
+ * added to it. Adding materialization preserves semantics for the admitted values — an extension's
882
+ * own result is never an address, so `rendersAsAddress` has nothing to refuse here — and the
883
+ * multi-block-loop-header seat refusal is the same decline-avoidance the siblings make. */
884
+ homeEscapingExtensions?: boolean;
790
885
  /** DEF-BLOCK PLACEMENT for memory reads — WHERE the read happens, not where the value lives.
791
886
  * The sibling of the homing variations above: there the question is which register or offset holds a
792
887
  * value, here which BLOCK performs the read. A read whose every render sits in a block its own
@@ -797,7 +892,7 @@ export interface AnalyzeOptions {
797
892
  * A compiler behavior (TargetDescription.compilerBehaviors `readsStayWhereWritten`), not
798
893
  * a differ-refereed variation, and that distinction is the argument: a variation exists where the asm
799
894
  * UNDERDETERMINES the source, some pass having collapsed two spellings onto one output
800
- * (`/uns-cmp`'s non-negativity proof is the type case). Where the compiler emits a read in the
895
+ * (`/signed-cmp`'s non-negativity proof is the type case). Where the compiler emits a read in the
801
896
  * block the source spelled it in, re-spelling it at the block the asm read in reproduces that
802
897
  * asm while the sunk spelling is one it emits only for a source that read per arm — nothing for
803
898
  * the differ to referee, and the extra candidate is pure cost. Which compilers may declare
@@ -1057,10 +1152,6 @@ function makeReach(): {
1057
1152
  reachCache.set(b, r);
1058
1153
  return r;
1059
1154
  };
1060
- // Reachability that never passes THROUGH `avoid` — the def-block-avoiding form for
1061
- // per-iteration path checks: a path that re-enters the def's block re-executes the def, so
1062
- // writes on it belong to the NEXT dynamic instance (which re-renders anyway) and must not
1063
- // count against this one. Uncached (per-decision graphs are small).
1064
1155
  const reachAvoiding = (from: Block, avoid: Block): Set<Block> => {
1065
1156
  const r = new Set<Block>();
1066
1157
  const stack = successorsOf(from).filter((s) => s !== avoid);
@@ -1081,26 +1172,25 @@ function makeReach(): {
1081
1172
  return { reachFrom, reachAvoiding };
1082
1173
  }
1083
1174
 
1084
- export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {}): StructureAnalysis {
1085
- const {
1086
- defs,
1087
- dom,
1088
- rereadGlobals = false,
1089
- volatileGlobal,
1090
- materializeJoinFeeds = false,
1091
- homeSharedAddresses = false,
1092
- homeLoopExprs = false,
1093
- homeDerivedReads = false,
1094
- homeMergeFeeds = false,
1095
- readsStayWhereWritten = false,
1096
- } = opts;
1097
- // ── use registry ────────────────────────────────────────────────────────────────────────
1098
- // Every use of a value, POSITIONED: the consuming op and its block/index. Successor args are
1099
- // uses AT the terminator (they render in argAssigns at block end). A void function's `ret`
1100
- // operand is a phantom, not a real use — skipping it lets a call whose result ONLY flows into
1101
- // the suppressed return read as a dead (side-effect) call, so `sideEffects()` emits it.
1102
- // One operand SLOT = one entry (an op reading a value twice records two uses — that count is
1103
- // what decides whether an inlined call would EXECUTE twice).
1175
+ /** THE USE REGISTRY. Every use of a value, POSITIONED: the consuming op and its block/index.
1176
+ * Successor args are uses AT the terminator (they render in argAssigns at block end). A void
1177
+ * function's `ret` operand is a phantom, not a real use — skipping it lets a call whose result
1178
+ * ONLY flows into the suppressed return read as a dead (side-effect) call, so `sideEffects()`
1179
+ * emits it. One operand SLOT = one entry (an op reading a value twice records two uses — that
1180
+ * count is what decides whether an inlined call would EXECUTE twice). */
1181
+ interface UseIndex {
1182
+ /** every positioned use of a value; a value absent here is dead */
1183
+ useSitesOf: Map<Value, UseSite[]>;
1184
+ opIndex: Map<Op, number>;
1185
+ opBlock: Map<Op, Block>;
1186
+ blockPos: Map<Block, number>;
1187
+ /** True if `def`'s value is still needed after a call — a call lies strictly between the def and
1188
+ * one of its `consumers`. Such a value survives in a callee-saved register (a local), which is
1189
+ * what materializing it reproduces. */
1190
+ liveAcrossCall(def: Op, consumers: Op[]): boolean;
1191
+ }
1192
+
1193
+ function indexUses(fn: Fn, returnsVoid: boolean): UseIndex {
1104
1194
  const useSitesOf = new Map<Value, UseSite[]>();
1105
1195
  const opIndex = new Map<Op, number>();
1106
1196
  const opBlock = new Map<Op, Block>();
@@ -1132,36 +1222,6 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1132
1222
  }
1133
1223
  }
1134
1224
  }
1135
- // Which values ride a branch edge as a successor ARG, in two scopes with two different readers.
1136
- //
1137
- // `branchArgFed` is EVERY multi-successor terminator's, and it is a correctness fact: an edge
1138
- // argument is emitted INSIDE the arm it belongs to, so a value that renders only there runs only
1139
- // on that path. For a call that is an effect the IR performs unconditionally and the C performs
1140
- // sometimes, and a `switch_br` does it as readily as a `cond_br`. Plain `br` args are excluded
1141
- // because their edge copy is on the one path that reaches the successor, so nothing conditional
1142
- // happens to them.
1143
- //
1144
- // `condBrArgFed` is the `/inplace` variation's narrower reading — a preference about where a load's
1145
- // value is homed, whose own scope note is on AnalyzeOptions.materializeJoinFeeds.
1146
- const branchArgFed = new Set<Value>();
1147
- const condBrArgFed = new Set<Value>();
1148
- for (const b of fn.blocks) {
1149
- for (const op of b.ops) {
1150
- if (op.successors.length > 1) {
1151
- for (const s of op.successors) {
1152
- for (const a of s.args) {
1153
- branchArgFed.add(a);
1154
- if (op.opcode === 'cond_br') {
1155
- condBrArgFed.add(a);
1156
- }
1157
- }
1158
- }
1159
- }
1160
- }
1161
- }
1162
- /** True if `def`'s value is still needed after a call — a call lies strictly between the def and
1163
- * one of its `consumers`. Such a value survives in a callee-saved register (a local), which is
1164
- * what materializing it reproduces. */
1165
1225
  const liveAcrossCall = (def: Op, consumers: Op[]): boolean => {
1166
1226
  const dp = linPos(def);
1167
1227
  const usePos = consumers.map(linPos);
@@ -1191,25 +1251,124 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1191
1251
  }
1192
1252
  });
1193
1253
  }
1254
+ return { useSitesOf, opIndex, opBlock, blockPos, liveAcrossCall };
1255
+ }
1194
1256
 
1195
- const liveIn = blockLiveIn(fn, returnsVoid);
1257
+ /** Which values ride a branch edge as a successor ARG, in two scopes with two different readers. */
1258
+ interface EdgeArgUses {
1259
+ /** EVERY multi-successor terminator's, and it is a correctness fact: an edge argument is emitted
1260
+ * INSIDE the arm it belongs to, so a value that renders only there runs only on that path. For a
1261
+ * call that is an effect the IR performs unconditionally and the C performs sometimes, and a
1262
+ * `switch_br` does it as readily as a `cond_br`. Plain `br` args are excluded because their edge
1263
+ * copy is on the one path that reaches the successor, so nothing conditional happens to them. */
1264
+ branchArgFed: Set<Value>;
1265
+ /** the `/inplace` variation's narrower reading — a preference about where a load's value is
1266
+ * homed, whose own scope note is on AnalyzeOptions.materializeJoinFeeds */
1267
+ condBrArgFed: Set<Value>;
1268
+ /** How many times each value rides a BACK edge (to a block that dominates its own: a loop's
1269
+ * header), which `ridesEdge` weighs apart. Decided by dominance, not layout: a return tail laid
1270
+ * out above the branch into it is still a forward edge, and its copy renders in the arm.
1271
+ *
1272
+ * And only from a latch that is its loop's ONLY exit. Then the latch's test is the bottom test
1273
+ * and its back-edge copy is the update the body runs on every iteration. When another block
1274
+ * leaves the loop too, the structurer may keep that exit as the loop's own test and render the
1275
+ * latch's branch as an `if` in the body, with the copy in one arm: agbcc's `while (n-- > 0) { q
1276
+ * = q - 1; if (u == k) return *q; t = cg(t) + H[n & 3] & cb(q + 1); }` (with `u` 0) runs `bl
1277
+ * cb` ahead of the latch's `bgt`, and inlined into the copy it ran only on the iterations that
1278
+ * went round again. Such an edge weighs as a forward one. */
1279
+ backArgFed: Map<Value, number>;
1280
+ }
1196
1281
 
1197
- // ── the effect-ordering model — inline-at-use barriers ────────────────────────────────────
1198
- // `expr()` renders a def's computation AT ITS USE, which silently MOVES it: a call executes
1199
- // once per rendered copy (`foo(a0)+foo(a0)`), a load reads memory at the render point (it can
1200
- // textually sink past an aliasing store). The model: a call/load/aload def may inline ONLY
1201
- // when rendering cannot change behavior — exactly one render position, and the program-order
1202
- // gap between def and render crosses no memory write (loads) / no memory access at all (calls,
1203
- // whose own reads+writes must not reorder against anything). Every other case gets a NAMED
1204
- // TEMP assigned at the def's own program position (sideEffects) — which is precisely the
1205
- // register the compiler used.
1206
- const materialize = new Set<Op>();
1207
- const { reachFrom, reachAvoiding } = makeReach();
1208
- // Where a value's expression is ultimately EMITTED: the anchored consumer (statement op,
1209
- // terminator, materialized def) it inlines into, transitively through single-use pure ops.
1210
- // null = renders in several places / unresolvable (treated conservatively by the caller).
1211
- const emitPosCache = new Map<Op, { blk: Block; idx: number } | null>();
1282
+ function edgeArgUses(fn: Fn, domOf: Map<Block, Set<Block>>): EdgeArgUses {
1283
+ const branchArgFed = new Set<Value>();
1284
+ const condBrArgFed = new Set<Value>();
1285
+ const backArgFed = new Map<Value, number>();
1286
+ const soleExitLatch = new Set<Block>();
1287
+ for (const L of naturalLoops(fn, domOf, predecessors(fn))) {
1288
+ if (![...L.body].some((x) => x !== L.latch && successorsOf(x).some((t) => !L.body.has(t)))) {
1289
+ soleExitLatch.add(L.latch);
1290
+ }
1291
+ }
1292
+ const backEdge = (from: Block, to: Block): boolean => (domOf.get(from)?.has(to) ?? false) && soleExitLatch.has(from);
1293
+ for (const b of fn.blocks) {
1294
+ for (const op of b.ops) {
1295
+ if (op.successors.length > 1) {
1296
+ for (const s of op.successors) {
1297
+ for (const a of s.args) {
1298
+ branchArgFed.add(a);
1299
+ if (op.opcode === 'cond_br') {
1300
+ condBrArgFed.add(a);
1301
+ }
1302
+ if (backEdge(b, s.block)) {
1303
+ backArgFed.set(a, (backArgFed.get(a) ?? 0) + 1);
1304
+ }
1305
+ }
1306
+ }
1307
+ }
1308
+ }
1309
+ }
1310
+ return { branchArgFed, condBrArgFed, backArgFed };
1311
+ }
1312
+
1313
+ /** THE EFFECT-ORDERING MODEL — inline-at-use barriers. `expr()` renders a def's computation AT ITS
1314
+ * USE, which silently MOVES it: a call executes once per rendered copy (`foo(a0)+foo(a0)`), a load
1315
+ * reads memory at the render point (it can textually sink past an aliasing store). The model: a
1316
+ * call/load/aload def may inline ONLY when rendering cannot change behavior — exactly one render
1317
+ * position, and the program-order gap between def and render crosses no memory write (loads) / no
1318
+ * memory access at all (calls, whose own reads+writes must not reorder against anything). Every
1319
+ * other case gets a NAMED TEMP assigned at the def's own program position (sideEffects) — which is
1320
+ * precisely the register the compiler used. */
1321
+ interface MaterializeState {
1322
+ /** defs that must emit as named temps at their own position */
1323
+ materialize: Set<Op>;
1324
+ /** the members of `materialize` that name a pre-update read */
1325
+ preUpdateHomes: Set<Op>;
1326
+ /** the divides the pre-update exit rule named (`rebuiltPast`) */
1327
+ exitDivides: Set<Op>;
1328
+ /** result values the address-home variation materialized — the load rule's admission key */
1329
+ addressHomedBases: Set<Value>;
1330
+ }
1331
+
1332
+ /** an op the asm reached by CALLING a runtime helper */
1333
+ function isHelper(op: Op): boolean {
1334
+ return placedAt(op) === 'helper';
1335
+ }
1336
+
1337
+ /** one the helper clause named: a statement sequenced as a named call is */
1338
+ function namedHelper(materialize: ReadonlySet<Op>, op: Op): boolean {
1339
+ return materialize.has(op) && isHelper(op);
1340
+ }
1341
+
1342
+ /** WHERE A VALUE'S EXPRESSION RENDERS, read off `materialize` as it stands when asked. The two
1343
+ * position caches hold until `invalidate()`. */
1344
+ interface RenderModel {
1212
1345
  /** an op that renders AT ITS OWN position: a statement, a terminator, a materialized or dead def */
1346
+ anchored(op: Op): boolean;
1347
+ consumersOf(op: Op): Op[];
1348
+ /** Does `op`'s value reach a float add or subtract, directly or through negations only? */
1349
+ feedsFloatAdd(op: Op): boolean;
1350
+ /** Where a value's expression is ultimately EMITTED: the anchored consumer (statement op,
1351
+ * terminator, materialized def) it inlines into, transitively through single-use pure ops.
1352
+ * null = renders in several places / unresolvable (treated conservatively by the caller). */
1353
+ emitPos(op: Op): { blk: Block; idx: number } | null;
1354
+ /** EVERY position a value's expression renders at — `emitPos` generalized to the whole set (it
1355
+ * answers one place or gives up), by following ALL consumers transitively. That matters for the
1356
+ * value-home variation: a pure expression with two consumers (`gOut = e; return e;`) has no
1357
+ * single emit position, so `emitPos` answers null and every memory read feeding it is forced
1358
+ * into a local — even when re-reading at both places is provably equivalent. Null only for a
1359
+ * genuine cycle (defensive: SSA use-def is acyclic through ops), which the caller treats as
1360
+ * unresolvable.
1361
+ *
1362
+ * NEVER for a call: two render positions mean two executions, so a call whose consumer renders
1363
+ * in several places must keep answering null and materialize. */
1364
+ emitPositions(op: Op): { blk: Block; idx: number }[] | null;
1365
+ /** clears both position caches */
1366
+ invalidate(): void;
1367
+ }
1368
+
1369
+ function makeRenderModel(uses: UseIndex, materialize: ReadonlySet<Op>): RenderModel {
1370
+ const { useSitesOf, opIndex, opBlock, blockPos } = uses;
1371
+ const emitPosCache = new Map<Op, { blk: Block; idx: number } | null>();
1213
1372
  const anchored = (op: Op): boolean =>
1214
1373
  op.successors.length > 0 ||
1215
1374
  op.opcode === 'ret' ||
@@ -1219,6 +1378,10 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1219
1378
  !op.results.length ||
1220
1379
  !useSitesOf.has(op.results[0]);
1221
1380
  const consumersOf = (op: Op): Op[] => [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))];
1381
+ const feedsFloatAdd = (op: Op): boolean =>
1382
+ consumersOf(op).some(
1383
+ (c) => c.opcode === 'fadd' || c.opcode === 'fsub' || (c.opcode === 'fneg' && feedsFloatAdd(c)),
1384
+ );
1222
1385
  const emitPos = (op: Op): { blk: Block; idx: number } | null => {
1223
1386
  if (emitPosCache.has(op)) {
1224
1387
  return emitPosCache.get(op)!;
@@ -1233,15 +1396,6 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1233
1396
  emitPosCache.set(op, res);
1234
1397
  return res;
1235
1398
  };
1236
- // EVERY position a value's expression renders at — `emitPos` generalized to the whole set (it
1237
- // answers one place or gives up), by following ALL consumers transitively. That matters for
1238
- // the value-home variation: a pure expression with two consumers (`gOut = e; return e;`) has no single
1239
- // emit position, so `emitPos` answers null and every memory read feeding it is forced into a
1240
- // local — even when re-reading at both places is provably equivalent. Null only for a genuine
1241
- // cycle (defensive: SSA use-def is acyclic through ops), which the caller treats as unresolvable.
1242
- //
1243
- // NEVER for a call: two render positions mean two executions, so a call whose consumer renders in
1244
- // several places must keep answering null and materialize.
1245
1399
  const emitPosSetCache = new Map<Op, { blk: Block; idx: number }[] | null>();
1246
1400
  const emitPositions = (op: Op, visiting: Set<Op> = new Set()): { blk: Block; idx: number }[] | null => {
1247
1401
  const hit = emitPosSetCache.get(op);
@@ -1278,21 +1432,119 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1278
1432
  emitPosSetCache.set(op, res);
1279
1433
  return res;
1280
1434
  };
1281
- const memWriteBetween = makeMemWriteBetween({ opBlock, opIndex, reachAvoiding });
1282
- const defOf = defs ?? defOpMap(fn);
1283
- const copyInterdependent = copyInterdependentValues(fn, defOf);
1284
- // ── natural loops, for the live-across-a-loop rule ────────────────────────────────────────
1285
- // From the caller's dominators (a back edge is `latch → header` with the header dominating the
1286
- // latch); the body is the backward closure from the latch. With no `dom` the rule stands
1287
- // down — the same posture as the `defs`-carried rules.
1288
- const predsOf = predecessors(fn);
1435
+ const invalidate = (): void => {
1436
+ emitPosCache.clear();
1437
+ emitPosSetCache.clear();
1438
+ };
1439
+ return { anchored, consumersOf, feedsFloatAdd, emitPos, emitPositions, invalidate };
1440
+ }
1441
+
1442
+ interface InlineReach {
1443
+ ridesEdge(call: Op): boolean;
1444
+ spelledTwice(call: Op): boolean;
1445
+ }
1446
+
1447
+ function makeInlineReach(uses: UseIndex, edges: EdgeArgUses, materialize: ReadonlySet<Op>): InlineReach {
1448
+ const { useSitesOf } = uses;
1449
+ const { branchArgFed, backArgFed } = edges;
1450
+ /** Does `hit` hold of `call`'s value, or of any value it reaches through the ops it would be
1451
+ * inlined into? Such an op renders where its consumer does. A load is a read, not an effect, so
1452
+ * the walk passes through it: `*f()` read twice spells `f()` twice. The walk stops at a named op,
1453
+ * which renders at its own position, and at another effect, which these same rules place. That
1454
+ * makes it narrower than `!anchored`: a call whose value is used is not anchored, and the walk
1455
+ * still stops there. */
1456
+ const reachesThroughInline = (call: Op, hit: (x: Value, sites: readonly UseSite[]) => boolean): boolean => {
1457
+ const seen = new Set<Value>();
1458
+ const walk = (x: Value): boolean => {
1459
+ if (seen.has(x)) {
1460
+ return false;
1461
+ }
1462
+ seen.add(x);
1463
+ const sites = useSitesOf.get(x) ?? [];
1464
+ return (
1465
+ hit(x, sites) ||
1466
+ sites.some(
1467
+ (u) =>
1468
+ !effectful(u.op) &&
1469
+ u.op.successors.length === 0 &&
1470
+ !materialize.has(u.op) &&
1471
+ u.op.results.length > 0 &&
1472
+ walk(u.op.results[0]),
1473
+ )
1474
+ );
1475
+ };
1476
+ return walk(call.results[0]);
1477
+ };
1478
+ /** Does `call`'s value reach an edge argument through the ops it would be inlined into, and
1479
+ * so render where that edge copy does? Such an op renders where its consumer does, so a call under
1480
+ * `f(x) + 1` or `*f(x)` rides the copy exactly as a bare `f(x)` does.
1481
+ *
1482
+ * A FORWARD edge's copy renders in an arm or after the loop, and a value two back-edge args carry
1483
+ * renders twice, so reaching either is enough. A value ONE back-edge arg carries renders once, in
1484
+ * the update copy at the foot of the body, and a forward edge carrying it too reads the loop
1485
+ * variable that copy wrote. That copy moves the call only past what the latch runs after it, and
1486
+ * the barrier scan in `nameAccess` already weighs exactly that: a store, a call or a read in
1487
+ * another part of the terminator bars the call and names it, and a read in the same copy renders
1488
+ * after the call, as every compiler evaluates it. So `s = s + g(i)` stays inline (`for (…) s = s
1489
+ * + g(i);`). */
1490
+ const ridesEdge = (call: Op): boolean =>
1491
+ reachesThroughInline(call, (x) => {
1492
+ const carried = backArgFed.get(x) ?? 0;
1493
+ return carried > 1 || (carried === 0 && branchArgFed.has(x));
1494
+ });
1495
+ /** Does `call`'s value, through the ops it would be inlined into, reach ONE op that reads it
1496
+ * twice? That op spells it twice — both operands of `s * s`, or both edge copies of a `br
1497
+ * ^bb3(%9, %9)` — and the call runs once per spelling. A multi-successor terminator's arguments
1498
+ * are `ridesEdge`'s question, which weighs a do-while's exit copy reading the loop variable its
1499
+ * update copy wrote. */
1500
+ const spelledTwice = (call: Op): boolean =>
1501
+ reachesThroughInline(call, (_, sites) =>
1502
+ sites.some((u, i) => u.op.successors.length <= 1 && sites.findIndex((v) => v.op === u.op) !== i),
1503
+ );
1504
+ return { ridesEdge, spelledTwice };
1505
+ }
1506
+
1507
+ /** A natural loop whose latch's terminator branches back to the header: the test sits at the
1508
+ * bottom of the body. */
1509
+ type BottomTestedLoop = NaturalLoop & { term: Op; back: Successor };
1510
+
1511
+ /** The natural loops, from the caller's dominators, and the loop rules over them. With no `dom`
1512
+ * there are no loops and every rule stands down — the same posture as the `defs`-carried rules. */
1513
+ interface LoopRules {
1514
+ loopBodies: NaturalLoop[];
1515
+ /** Never seat a materialized def in a MULTI-BLOCK loop header: a test-at-top `while`'s condition
1516
+ * has no seat for a materialized temp (the structurer's headerPure gate), so materializing there
1517
+ * trades a structuring function for a decline. Self-loop headers stay eligible — their
1518
+ * kept-guard do-while form hosts the temp. */
1519
+ multiBlockHeaders: Set<Block>;
1520
+ bottomTested: BottomTestedLoop[];
1521
+ /** The def's value enters some loop's header live and every consumer sits outside that loop, as
1522
+ * does the def: the value is carried ACROSS the loop, not into it. */
1523
+ liveAcrossLoop(def: Op, r: Value, consumers: Op[]): boolean;
1524
+ /** `liveAcrossLoop`, and neither the def nor any consumer sits in any loop, enclosing ones
1525
+ * included: never a value the compiler could have hoisted out of one. */
1526
+ carriedPastLoops(def: Op, r: Value, consumers: Op[]): boolean;
1527
+ /** Does `r`, computed by `op` in `L`'s body, read a loop variable the update overwrites? */
1528
+ readsPreUpdate(L: BottomTestedLoop, op: Op, r: Value): boolean;
1529
+ escapesAheadOfUpdate(op: Op, r: Value, consumers: Op[]): boolean;
1530
+ rebuiltPast(op: Op, r: Value): boolean;
1531
+ /** Is `r` read where the update has run: after the loop, or by its bottom test? */
1532
+ readAfterUpdate(L: BottomTestedLoop, r: Value, consumers: Op[]): boolean;
1533
+ }
1534
+
1535
+ function makeLoopRules(a: {
1536
+ fn: Fn;
1537
+ dom: Map<Block, Set<Block>> | undefined;
1538
+ predsOf: Map<Block, Block[]>;
1539
+ liveIn: Map<Block, Set<Value>>;
1540
+ uses: UseIndex;
1541
+ defOf: Map<Value, Op>;
1542
+ render: RenderModel;
1543
+ materialize: ReadonlySet<Op>;
1544
+ }): LoopRules {
1545
+ const { fn, dom, predsOf, liveIn, defOf, render, materialize } = a;
1546
+ const { opBlock } = a.uses;
1289
1547
  const loopBodies = dom ? naturalLoops(fn, dom, predsOf) : [];
1290
- /** The def's value enters some loop's header live and every consumer sits outside that loop,
1291
- * as does the def: the value is carried ACROSS the loop, not into it. */
1292
- // Never for a def in a MULTI-BLOCK loop header: a test-at-top `while`'s condition has no seat
1293
- // for a materialized temp (the structurer's headerPure gate), so materializing there trades a
1294
- // structuring function for a decline. Self-loop headers stay eligible — their kept-guard
1295
- // do-while form hosts the temp.
1296
1548
  const multiBlockHeaders = new Set(loopBodies.filter((L) => L.body.size > 1).map((L) => L.header));
1297
1549
  const liveAcrossLoop = (def: Op, r: Value, consumers: Op[]): boolean =>
1298
1550
  !multiBlockHeaders.has(opBlock.get(def)!) &&
@@ -1302,46 +1554,207 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1302
1554
  !L.body.has(opBlock.get(def)!) &&
1303
1555
  consumers.every((c) => !L.body.has(opBlock.get(c)!)),
1304
1556
  );
1557
+ const carriedPastLoops = (def: Op, r: Value, consumers: Op[]): boolean =>
1558
+ liveAcrossLoop(def, r, consumers) &&
1559
+ loopBodies.every((L) => !L.body.has(opBlock.get(def)!) && consumers.every((c) => !L.body.has(opBlock.get(c)!)));
1560
+ /** THE PRE-UPDATE ESCAPE. A value a bottom-tested loop computes and the code after it reads,
1561
+ * whose expression reads a loop variable — `do { s = &f->v; f = f->next; } while (--n); *out =
1562
+ * s;`. Inlined at the post-loop read it re-reads that variable's NAME, which by then holds the
1563
+ * value the update wrote, one iteration past the one the value was computed from. agbcc kept the
1564
+ * computed value in its own register instead (`add r3, r0, #4` inside the loop, `str r3, [r2]`
1565
+ * after it), and naming the def at its own position is that register. Without the name the
1566
+ * structurer declines the loop (`loopEscapeHazard`, hazards.ts); an exit-edge ARG carrying the
1567
+ * same value is the sink's.
1568
+ *
1569
+ * Naming a def at its own position never changes a value, and `loopEscapeHazard` stays the
1570
+ * check. What the name does change is which functions structure at all, and a function this rule
1571
+ * lets through is spelled by the rest of the structurer, whose naming has shapes it gets wrong.
1572
+ * So it is kept to the shape the listing has, A SELF-LOOP: in a body of more than one block a
1573
+ * differential fuzz over generated IR found functions it unlocked that were then spelled wrong —
1574
+ * a loop variable's name adopted in place ahead of a read of its old value, the calls of a bottom
1575
+ * test reordered — each wrong on shapes that already structure, too. In a self-loop every reader
1576
+ * outside the body lies past the latch's exit, where the update has run. The structurer adds one
1577
+ * refusal of its own, on names (`preUpdateHomes`, structure.ts).
1578
+ *
1579
+ * Asked only once every other rule has settled (`escapePhase` in `analyze`), so the walk stops at
1580
+ * each def those rules name: a materialized def renders as its name. It stops at a BACK-EDGE ARG
1581
+ * too — reading it is reading the post-update value, which is what the name holds. */
1582
+ const bottomTested = loopBodies.flatMap((L) => {
1583
+ const term = L.latch.ops[L.latch.ops.length - 1];
1584
+ const back = term.successors.find((sc) => sc.block === L.header);
1585
+ return back ? [{ ...L, term, back }] : [];
1586
+ });
1587
+ const escapeLoops = bottomTested.filter((L) => L.body.size === 1);
1588
+ const readsPreUpdate = (L: BottomTestedLoop, op: Op, r: Value): boolean => {
1589
+ const seen = new Set<Value>();
1590
+ const readsUpdated = (x: Value): boolean => {
1591
+ if (seen.has(x) || L.back.args.includes(x)) {
1592
+ return false;
1593
+ }
1594
+ seen.add(x);
1595
+ if (L.header.params.includes(x)) {
1596
+ return true;
1597
+ }
1598
+ const d = defOf.get(x);
1599
+ if (!d || !L.body.has(opBlock.get(d)!) || (d !== op && materialize.has(d))) {
1600
+ return false;
1601
+ }
1602
+ return d.operands.some(readsUpdated);
1603
+ };
1604
+ return readsUpdated(r);
1605
+ };
1606
+ const escapesAheadOfUpdate = (op: Op, r: Value, consumers: Op[]): boolean =>
1607
+ escapeLoops.some(
1608
+ (L) =>
1609
+ L.body.has(opBlock.get(op)!) &&
1610
+ !consumers.every((c) => L.body.has(opBlock.get(c)!)) &&
1611
+ readsPreUpdate(L, op, r),
1612
+ );
1613
+ /** Would a value that reads `op`'s result `r` past the update be rebuilt BEHIND a memory access
1614
+ * or an effect the asm ran after `op`? `op` sits in a block every iteration runs (the latch, or
1615
+ * one dominating it), and `r` reads a loop variable, so where that value renders the variable's
1616
+ * name no longer holds what `op` read, and the value is rebuilt from `op`'s operands:
1617
+ * • an arg of the latch's exit edge, rebuilt at the latch op computing it (`preUpdateCopyHome`,
1618
+ * hazards.ts) — by the sink, or where it is the back edge's arg too, by the update copy at
1619
+ * the foot of the body, which runs later still;
1620
+ * • a latch op read after the loop, rebuilt there. One the escape rule names first
1621
+ * (`escapesAheadOfUpdate`, a self-loop) is a materialized def this walk stops at, so it is
1622
+ * spelled at its own position with the divide inline.
1623
+ * Either way what `op` is carried past is everything the iteration runs between `op` and the
1624
+ * latch op the value is computed at.
1625
+ *
1626
+ * Never for a divide in a multi-block loop header with an edge out of `L`: named, it is a
1627
+ * pre-update home (`preUpdateHomes`), and the structurer refuses a home in a loop whose
1628
+ * variable's name a block after the loop also holds (`sharesALoopName`), where the loop lifts
1629
+ * with the divide unnamed. mwcc's `do { t = k / n; if (*q == 5) break; … } while (--n);` puts
1630
+ * the `divw` ahead of the header's `beq` out. */
1631
+ const rebuiltPast = (op: Op, r: Value): boolean =>
1632
+ bottomTested.some((L) => {
1633
+ const ob = opBlock.get(op)!;
1634
+ if (!L.body.has(ob) || !dom!.get(L.latch)!.has(ob) || !readsPreUpdate(L, op, r)) {
1635
+ return false;
1636
+ }
1637
+ if (ob !== L.latch && multiBlockHeaders.has(ob) && successorsOf(ob).some((s) => !L.body.has(s))) {
1638
+ return false;
1639
+ }
1640
+ const seen = new Set<Value>();
1641
+ const reaches = (x: Value): boolean => {
1642
+ if (x === r) {
1643
+ return true;
1644
+ }
1645
+ if (seen.has(x)) {
1646
+ return false;
1647
+ }
1648
+ seen.add(x);
1649
+ const d = defOf.get(x);
1650
+ return !!d && L.body.has(opBlock.get(d)!) && !materialize.has(d) && d.operands.some(reaches);
1651
+ };
1652
+ // The ops between `op` and latch index `to`: the rest of `op`'s block, then every body block
1653
+ // it reaches ahead of the latch, then the latch's own ops ahead of `to`.
1654
+ const between = (to: number): Op[] => {
1655
+ if (ob === L.latch) {
1656
+ return L.latch.ops.slice(L.latch.ops.indexOf(op) + 1, to);
1657
+ }
1658
+ const mid = new Set<Block>();
1659
+ const stack = successorsOf(ob);
1660
+ while (stack.length > 0) {
1661
+ const b = stack.pop()!;
1662
+ if (b !== L.header && b !== L.latch && L.body.has(b) && !mid.has(b)) {
1663
+ mid.add(b);
1664
+ stack.push(...successorsOf(b));
1665
+ }
1666
+ }
1667
+ return [
1668
+ ...ob.ops.slice(ob.ops.indexOf(op) + 1),
1669
+ ...[...mid].flatMap((b) => b.ops),
1670
+ ...L.latch.ops.slice(0, to),
1671
+ ];
1672
+ };
1673
+ const crossesAt = (home: Op): boolean =>
1674
+ home !== op &&
1675
+ opBlock.get(home) === L.latch &&
1676
+ between(L.latch.ops.indexOf(home)).some((x) => orderSensitive(x) || namedHelper(materialize, x));
1677
+ const exitArg = L.term.successors.some(
1678
+ (sc) =>
1679
+ !L.body.has(sc.block) &&
1680
+ sc.args.some((a) => {
1681
+ const home = defOf.get(a);
1682
+ return !!home && reaches(a) && crossesAt(home);
1683
+ }),
1684
+ );
1685
+ return (
1686
+ exitArg ||
1687
+ L.latch.ops.some(
1688
+ (h) =>
1689
+ h.results[0] !== undefined &&
1690
+ render.consumersOf(h).some((c) => !L.body.has(opBlock.get(c)!)) &&
1691
+ reaches(h.results[0]) &&
1692
+ crossesAt(h),
1693
+ )
1694
+ );
1695
+ });
1696
+ const readAfterUpdate = (L: BottomTestedLoop, r: Value, consumers: Op[]): boolean => {
1697
+ if (!consumers.every((c) => L.body.has(opBlock.get(c)!))) {
1698
+ return true;
1699
+ }
1700
+ const seen = new Set<Value>();
1701
+ const inTest = (x: Value): boolean => {
1702
+ if (x === r) {
1703
+ return true;
1704
+ }
1705
+ if (seen.has(x)) {
1706
+ return false;
1707
+ }
1708
+ seen.add(x);
1709
+ const d = defOf.get(x);
1710
+ return !!d && L.body.has(opBlock.get(d)!) && !materialize.has(d) && d.operands.some(inTest);
1711
+ };
1712
+ return L.term.opcode === 'cond_br' && inTest(L.term.operands[0]);
1713
+ };
1714
+ return {
1715
+ loopBodies,
1716
+ multiBlockHeaders,
1717
+ bottomTested,
1718
+ liveAcrossLoop,
1719
+ carriedPastLoops,
1720
+ readsPreUpdate,
1721
+ escapesAheadOfUpdate,
1722
+ rebuiltPast,
1723
+ readAfterUpdate,
1724
+ };
1725
+ }
1726
+
1727
+ /** The variation scopes and the placement sets the rules read, each settled before the fixpoint:
1728
+ * none reads `materialize`. */
1729
+ interface HomeScopes {
1305
1730
  /** Would naming THIS op's own result change its value? Yes when the result is an ADDRESS built
1306
1731
  * over a gaddr/laddr: rendered standalone an `&g + i` loses the memAccess's inline byte-stride
1307
1732
  * cast, and the cast-aware base machinery in l3/ serves those bases instead. Asked by the rules
1308
1733
  * that home an address; a rule homing the SCALAR a load returns is not this case — the name
1309
1734
  * holds the loaded value and the address stays inline at the deref, which is why the load rules
1310
1735
  * (live-across-a-loop, join feeds, /addr-home's, def-block placement) do not ask it. */
1311
- const addressCone = (op0: Op): boolean => coneHoldsAddr(op0, defOf);
1312
- // ── the address-home variation's scope ────────────────────────────────────────────────────
1313
- // Over the MERGE CLASS, so a base the arms derive and the join dereferences counts as the one
1314
- // register it is — see `sharedBaseClasses`. Computed once, and only under the variation.
1315
- const sharedBaseClass = homeSharedAddresses ? sharedBaseClasses(fn, returnsVoid) : new Set<Value>();
1316
- // The same question asked of the VALUE ALONE, which is what the two variations below need: their
1317
- // "shared bases stay the address-home scope's" exclusion is a hand-off between scopes, and
1318
- // widening it to the class would make them refuse values the address-home scope only claims
1319
- // when its own variation is ON — a candidate lost with no candidate gained. Where both variations run the
1320
- // two scopes may claim one value, which is a no-op: `materialize` is a set and the address-home
1321
- // scope, running first, is what registers `addressHomedBases`.
1322
- const usedOnlyAsSharedBase = (v: Value): boolean => {
1323
- const sites = useSitesOf.get(v) ?? [];
1324
- const consumers = new Set(sites.map((s) => s.op));
1325
- return (
1326
- consumers.size >= 2 &&
1327
- [...consumers].every(
1328
- (c) => MEM_BASE_OPS.has(c.opcode) && c.operands[0] === v && !c.operands.some((o, i) => i > 0 && o === v),
1329
- )
1330
- );
1331
- };
1332
- // The merge-feed-home variation's ops, settled BEFORE the fixpoint: the scope reads the IR alone (no
1333
- // render positions, no `materialize`), so it cannot change as the set grows. Unlike the rules
1334
- // that stand down without the caller's `dom`, this one computes its own: rank.ts has already
1335
- // admitted the candidate on `hasMergeFeedHome`, which runs the same scope, so standing down here
1336
- // would be a refusal nothing reports.
1337
- let mergeFeedOps = new Set<Op>();
1338
- if (homeMergeFeeds) {
1339
- const mdom = dom ?? dominators(fn);
1340
- const mloops = mdom === dom ? loopBodies : naturalLoops(fn, mdom, predsOf);
1341
- mergeFeedOps = mergeFeedHomes(fn, mdom, defOf, new Set(mloops.flatMap((L) => [...L.body])));
1342
- }
1343
- /** result values the address-home variation materialized — the load rule's admission key */
1344
- const addressHomedBases = new Set<Value>();
1736
+ addressCone(op0: Op): boolean;
1737
+ /** The address-home variation's scope. Over the MERGE CLASS, so a base the arms derive and the
1738
+ * join dereferences counts as the one register it is — see `sharedBaseClasses`. Computed only
1739
+ * under the variation. */
1740
+ sharedBaseClass: Set<Value>;
1741
+ /** The seventh scope's members, read the same way and for the same reason — one definition, run
1742
+ * under this function's own `returnsVoid` where the gate must ask under both. */
1743
+ escapeHomeOps: Set<Op>;
1744
+ /** The address-home question asked of the VALUE ALONE, which is what the loop-expression and
1745
+ * derived-read scopes need: their "shared bases stay the address-home scope's" exclusion is a
1746
+ * hand-off between scopes, and widening it to the class would make them refuse values the
1747
+ * address-home scope only claims when its own variation is ON — a candidate lost with no
1748
+ * candidate gained. Where both variations run the two scopes may claim one value, which is a
1749
+ * no-op: `materialize` is a set and the address-home scope, running first, is what registers
1750
+ * `addressHomedBases`. */
1751
+ usedOnlyAsSharedBase(v: Value): boolean;
1752
+ /** The merge-feed-home variation's ops: the scope reads the IR alone (no render positions, no
1753
+ * `materialize`), so it cannot change as the set grows. Unlike the rules that stand down without
1754
+ * the caller's `dom`, this one computes its own: rank.ts has already admitted the candidate on
1755
+ * `hasMergeFeedHome`, which runs the same scope, so standing down here would be a refusal nothing
1756
+ * reports. */
1757
+ mergeFeedOps: Set<Op>;
1345
1758
  /** The loop-expression-home variation's scope: 2+ distinct consumers of the value, at least one of
1346
1759
  * them inside a loop the def's block is outside (loop model = the caller's dominators; absent ⇒
1347
1760
  * never).
@@ -1353,13 +1766,7 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1353
1766
  * single-use value inlines at its one use with the same bytes either way, so homing it can only
1354
1767
  * add a copy. Values consumed only OUTSIDE any loop are the straight-line class `/derived-home`
1355
1768
  * serves on its own evidence. */
1356
- const loopSharedConsumers = (v: Value, defBlk: Block): boolean => {
1357
- const consumers = [...new Set((useSitesOf.get(v) ?? []).map((s) => s.op))];
1358
- return (
1359
- consumers.length >= 2 &&
1360
- loopBodies.some((L) => !L.body.has(defBlk) && consumers.some((c) => L.body.has(opBlock.get(c)!)))
1361
- );
1362
- };
1769
+ loopSharedConsumers(v: Value, defBlk: Block): boolean;
1363
1770
  /** The derived-read-home variation's scope: does `op0` stand on a memory READ that may render at
1364
1771
  * `op0`'s own position?
1365
1772
  *
@@ -1387,7 +1794,7 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1387
1794
  * And each read's value must go NOWHERE BUT the cone: exactly one use site. Homing resolves a
1388
1795
  * render position for a read that had none, so a SECOND use resolves a second one, and the
1389
1796
  * multi-render load rule then inlines the read at BOTH — two accesses where the asm has one
1390
- * `ldrh`, which for a volatile cell is precisely the duplication `volatileGlobal` refuses. Two
1797
+ * `ldrh`, which for a volatile cell is precisely the duplication the `declared` placement refuses. Two
1391
1798
  * homed values over one read is the same shape from the other side (each is the other's second
1392
1799
  * use), so one test covers both. This is what makes "renders once, inside the home" a property
1393
1800
  * rather than an aspiration: without it the variation silently doubles a hardware read.
@@ -1399,39 +1806,37 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1399
1806
  * run, a value inside a loop pulls it in to run per iteration. Neither is a write, so no
1400
1807
  * barrier sees either; for an ordinary cell they are worse spellings, and for a volatile one
1401
1808
  * they are a missing access and a duplicated one. */
1402
- const standsOnMovableRead = (op0: Op, blk: Block): boolean => {
1403
- const reads = readCone(op0, defOf);
1404
- if (reads === null) {
1405
- return false;
1406
- }
1407
- const at = { blk, idx: opIndex.get(op0)! };
1408
- const coneReads = new Set(reads);
1409
- const bars = (x: Op): boolean =>
1410
- EFFECTFUL_OPS.has(x.opcode) || ((x.opcode === 'load' || x.opcode === 'aload') && !coneReads.has(x));
1411
- return (
1412
- reads.length > 0 &&
1413
- reads.every(
1414
- (r) =>
1415
- opBlock.get(r) === blk && (useSitesOf.get(r.results[0])?.length ?? 0) === 1 && !memWriteBetween(r, at, bars),
1416
- )
1417
- );
1418
- };
1809
+ standsOnMovableRead(op0: Op, blk: Block): boolean;
1419
1810
  /** 2+ distinct consuming ops — the multi-use the pure-op rule reads as a reused register. */
1420
- const multiConsumer = (v: Value): boolean => new Set((useSitesOf.get(v) ?? []).map((s) => s.op)).size >= 2;
1421
- /** THE def-block placement rule's short-circuit refusal: values C evaluates only under a
1422
- * `&&`/`||` — the SECOND operand of every `logic_and`/`logic_or`, and everything it reads.
1811
+ multiConsumer(v: Value): boolean;
1812
+ /** Values C evaluates only under a `&&`/`||` — the SECOND operand of every
1813
+ * `logic_and`/`logic_or`, and everything it reads. Two rules read it and they read it the
1814
+ * opposite way round, because the fold that built the connective treats a READ and an EFFECT
1815
+ * differently.
1423
1816
  *
1424
1817
  * raise/shortcircuit.ts recovers a connective by hoisting the guarded arm's whole pure body,
1425
1818
  * memory reads included, into the block ABOVE the branch (its value form and its control-flow
1426
- * form both splice that body into the head). ir/opcodes.ts states the safety argument as the
1427
- * reason a read is deliberately absent from HOIST_UNSAFE_OPS: the structurer inlines it back
1428
- * into the `&&`/`||` right-hand side, where C's own short circuit re-guards it. So for a value
1429
- * in that cone the def block is a FOLD ARTIFACT rather than the block the asm read in, and the
1430
- * whole premise this rule reads placement under does not hold there. Naming it also breaks the
1431
- * re-guard: `p != 0 && *p != 0` would emit `v0 = *p;` above its own null check.
1819
+ * form both splice that body into the head). ir/discipline.ts states the safety argument as the
1820
+ * reason a plain read is not `speculationUnsafe`: the structurer inlines it back
1821
+ * into the `&&`/`||` right-hand side, where C's own short circuit re-guards it. So for a READ
1822
+ * the def block is a FOLD ARTIFACT rather than the block the asm read in, and the def-block
1823
+ * placement rule stands down. Naming it also breaks the re-guard: `p != 0 && *p != 0` would
1824
+ * emit `v0 = *p;` above its own null check. That argument is about which SPELLING matches, and
1825
+ * a plain read is the only one it covers.
1432
1826
  *
1433
- * An operand[0] cone is unconditional and keeps the rule; only the guarded side is collected. */
1434
- const shortCircuitGuarded = readsStayWhereWritten ? shortCircuitGuardedValues(fn, defOf) : new Set<Value>();
1827
+ * A COUNTED op — a call, a qualified read — is `speculationUnsafe`, so no fold ever lifted one
1828
+ * out of the arm it guards: one that reached this cone ran ABOVE the branch, unconditionally, and
1829
+ * the guarded-call rule materializes it there rather than letting C's short circuit skip it.
1830
+ * agbcc emits `int t = gVolReg; if (a > 0 && t != 0)` with the `ldrh` above the `cmp`, and
1831
+ * `if (a > 0 && gVolReg != 0)` with it past the `ble`, where the fold leaves it in its arm and
1832
+ * no connective is built. The same holds of an op
1833
+ * the asm reached by calling a runtime helper and the fold did not hoist — one it hoists loses
1834
+ * the stamp — and the helper clause names it by the same argument.
1835
+ *
1836
+ * Only the guarded side is SEEDED. A connective's own operand[0] is evaluated whenever the
1837
+ * connective is, so neither rule wants it — but an inner connective sitting under an outer guard
1838
+ * is reached through the outer's cone, operand[0] included, which is what C does with it. */
1839
+ shortCircuitGuarded: Set<Value>;
1435
1840
  /** THE def-block placement rule's copy refusal: is every use of the value a successor ARGUMENT,
1436
1841
  * i.e. is the value nothing but a block parameter's incoming copy?
1437
1842
  *
@@ -1446,11 +1851,72 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1446
1851
  * Note what this does NOT claim: those reads really do sit above the loop guard in the asm, so
1447
1852
  * the placement inference was right and the SPELLING is what fails. Seating the read above the
1448
1853
  * guard AND as the loop variable is loop-init hoisting, a capability this rule does not have. */
1854
+ onlyFeedsBlockParams(v: Value): boolean;
1855
+ }
1856
+
1857
+ function homeScopes(a: {
1858
+ fn: Fn;
1859
+ returnsVoid: boolean;
1860
+ opts: AnalyzeOptions;
1861
+ uses: UseIndex;
1862
+ defOf: Map<Value, Op>;
1863
+ predsOf: Map<Block, Block[]>;
1864
+ loops: LoopRules;
1865
+ memWriteBetween: StructureAnalysis['memWriteBetween'];
1866
+ }): HomeScopes {
1867
+ const { fn, returnsVoid, opts, defOf, predsOf, loops, memWriteBetween } = a;
1868
+ const { useSitesOf, opIndex, opBlock } = a.uses;
1869
+ const { dom } = opts;
1870
+ const addressCone = (op0: Op): boolean => coneHoldsAddr(op0, defOf);
1871
+ const sharedBaseClass = opts.homeSharedAddresses ? sharedBaseClasses(fn, returnsVoid) : new Set<Value>();
1872
+ const escapeHomeOps = opts.homeEscapingExtensions ? escapingExtensions(fn, returnsVoid) : new Set<Op>();
1873
+ const usedOnlyAsSharedBase = (v: Value): boolean => {
1874
+ const sites = useSitesOf.get(v) ?? [];
1875
+ const consumers = new Set(sites.map((s) => s.op));
1876
+ return (
1877
+ consumers.size >= 2 &&
1878
+ [...consumers].every(
1879
+ (c) => MEM_BASE_OPS.has(c.opcode) && c.operands[0] === v && !c.operands.some((o, i) => i > 0 && o === v),
1880
+ )
1881
+ );
1882
+ };
1883
+ let mergeFeedOps = new Set<Op>();
1884
+ if (opts.homeMergeFeeds) {
1885
+ const mdom = dom ?? dominators(fn);
1886
+ const mloops = mdom === dom ? loops.loopBodies : naturalLoops(fn, mdom, predsOf);
1887
+ mergeFeedOps = mergeFeedHomes(fn, mdom, defOf, new Set(mloops.flatMap((L) => [...L.body])));
1888
+ }
1889
+ const loopSharedConsumers = (v: Value, defBlk: Block): boolean => {
1890
+ const consumers = [...new Set((useSitesOf.get(v) ?? []).map((s) => s.op))];
1891
+ return (
1892
+ consumers.length >= 2 &&
1893
+ loops.loopBodies.some((L) => !L.body.has(defBlk) && consumers.some((c) => L.body.has(opBlock.get(c)!)))
1894
+ );
1895
+ };
1896
+ const standsOnMovableRead = (op0: Op, blk: Block): boolean => {
1897
+ const reads = readCone(op0, defOf);
1898
+ if (reads === null) {
1899
+ return false;
1900
+ }
1901
+ const at = { blk, idx: opIndex.get(op0)! };
1902
+ const coneReads = new Set(reads);
1903
+ const bars = (x: Op): boolean =>
1904
+ effectful(x) || ((x.opcode === 'load' || x.opcode === 'aload') && !coneReads.has(x));
1905
+ return (
1906
+ reads.length > 0 &&
1907
+ reads.every(
1908
+ (r) =>
1909
+ opBlock.get(r) === blk && (useSitesOf.get(r.results[0])?.length ?? 0) === 1 && !memWriteBetween(r, at, bars),
1910
+ )
1911
+ );
1912
+ };
1913
+ const multiConsumer = (v: Value): boolean => new Set((useSitesOf.get(v) ?? []).map((s) => s.op)).size >= 2;
1914
+ const shortCircuitGuarded = shortCircuitGuardedValues(fn, defOf);
1449
1915
  const argUsedValues = new Set<Value>();
1450
1916
  const operandUsedValues = new Set<Value>();
1451
- // Both sets serve that rule alone, so they are built only where it can fire — the same posture
1452
- // `condBrArgFed` takes above (every other target pays nothing for a behavior it never declares).
1453
- if (readsStayWhereWritten) {
1917
+ // Both sets serve the copy refusal alone, so they are built only where it can fire — the same
1918
+ // posture `condBrArgFed` takes (every other target pays nothing for a behavior it never declares).
1919
+ if (opts.readsStayWhereWritten) {
1454
1920
  for (const b of fn.blocks) {
1455
1921
  for (const op of b.ops) {
1456
1922
  for (const v of op.operands) {
@@ -1465,15 +1931,761 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1465
1931
  }
1466
1932
  }
1467
1933
  const onlyFeedsBlockParams = (v: Value): boolean => argUsedValues.has(v) && !operandUsedValues.has(v);
1934
+ return {
1935
+ addressCone,
1936
+ sharedBaseClass,
1937
+ escapeHomeOps,
1938
+ usedOnlyAsSharedBase,
1939
+ mergeFeedOps,
1940
+ loopSharedConsumers,
1941
+ standsOnMovableRead,
1942
+ multiConsumer,
1943
+ shortCircuitGuarded,
1944
+ onlyFeedsBlockParams,
1945
+ };
1946
+ }
1947
+
1948
+ /** Every stage output the materialization rules read. */
1949
+ interface MaterializeRules {
1950
+ opts: AnalyzeOptions;
1951
+ uses: UseIndex;
1952
+ edges: EdgeArgUses;
1953
+ render: RenderModel;
1954
+ reach: InlineReach;
1955
+ loops: LoopRules;
1956
+ scopes: HomeScopes;
1957
+ defOf: Map<Value, Op>;
1958
+ predsOf: Map<Block, Block[]>;
1959
+ memWriteBetween: StructureAnalysis['memWriteBetween'];
1960
+ copyInterdependent: Set<Value>;
1961
+ }
1962
+
1963
+ /** The rules for a PURE value-producing op (a constant, an address computation, arithmetic — NOT a
1964
+ * memory access), in order. The helper clause and the exit-divide rule end the walk when they name
1965
+ * the op; every rule after them runs whatever an earlier one decided, so the address-home scope
1966
+ * registers `addressHomedBases` even for an op a rule ahead of it already named.
1967
+ *
1968
+ * A value with ≥2 distinct-STATEMENT uses in the SSA is one the compiler kept in a register and
1969
+ * reused: the frontend never dedups, so multi-use exists ONLY because the asm loaded/computed the
1970
+ * value once and read the same register again. Inlining it re-derives the value at each use (a
1971
+ * fresh pool load / repeated address arithmetic) — which the compiler did NOT do — so materialize
1972
+ * it into a local instead, reproducing that register. Pure ⇒ every render is value-identical, so
1973
+ * (unlike a load) no intervening memory write can invalidate a later render: multi-consumer
1974
+ * suffices, no barrier scan. Scope: a `const` that is LIVE ACROSS A CALL. A value the compiler
1975
+ * needs after a call must survive in a CALLEE-SAVED register — i.e. a local — because the call
1976
+ * clobbers the caller-saved ones; the compiler therefore loads it ONCE and keeps it, exactly what
1977
+ * materializing into a local reproduces (the base of `((s32 *)C)[i]` reused across `foo(...)`
1978
+ * calls). WITHOUT a call in its live range the const is instead cheaply re-materialized at each
1979
+ * use (a bare `movs r, #0` per init), so materializing it would ADD pointless copies and MISS —
1980
+ * hence the call gate (the small-constant regression). Cheap deref casts still land on the `index`
1981
+ * node at the use, preserving byte strides. Second scope: a NON-const read by a sibling
1982
+ * parallel-copy arg (`copyInterdependent`) — the one place a pure value's inlining is not
1983
+ * value-identical rendering but a re-derivation the copy machinery is forced into. Address cones
1984
+ * are excluded from it (an `&g + i` rendered standalone loses the memAccess's inline `(u8 *)` cast
1985
+ * — cast-aware base materialization is separate), and consts are not in it at all: a re-derived
1986
+ * const is re-materialization, which is the compiler's own behavior. */
1987
+ function namePureOp(rules: MaterializeRules, state: MaterializeState, op: Op, b: Block, escapePhase: boolean): void {
1988
+ const { opts, uses, render, reach, loops, scopes, memWriteBetween, copyInterdependent } = rules;
1989
+ const { useSitesOf } = uses;
1990
+ const { materialize } = state;
1991
+ const pr = op.results[0];
1992
+ // AN OP THE ASM CALLED A HELPER FOR is placed as the call it was (`isHelper`: `bl
1993
+ // __divsi3` for `k / n` and `k / 5` on agbcc, `bl __ashrdi3` for a 64-bit `>>`, `bl
1994
+ // __div2i` for a 64-bit `/` on mwcc). A call runs once, where the asm ran it, and agbcc,
1995
+ // with no scheduler, EMITS the call where the source spells the operation, so naming it
1996
+ // where the asm ran it recompiles to that asm; inlined at its use it moves there. One
1997
+ // direction only, as target.ts `readsStayWhereWritten` says of reads: the asm's position
1998
+ // is not always where the source computed it — loop.c hoists an invariant `bl __divsi3`
1999
+ // into the loop's guarded preheader, and naming it there is what recompiles. So it is
2000
+ // named at its def when:
2001
+ // • an effect, or another helper named there, lies between the def and a place it
2002
+ // renders — `t = k / n; *q = n; return t + 1;` is `bl; str`, and inlined it comes
2003
+ // back `*q = n; return k / n + 1;`, `str; bl`. A named one bars what the barrier
2004
+ // scan in `nameAccess` places, as a named call does, so a call the asm ran ahead of it
2005
+ // stays ahead of it;
2006
+ // • it renders in a block other than its own, or rides a branch's edge copy
2007
+ // (`ridesEdge`): then it runs on the paths that render it, not where the asm ran it
2008
+ // once. `t = k / n; if (c) return t + 1; return t - 3;` is one `bl`
2009
+ // above the `cmp`, and inlined into both arms it recompiles to one per arm;
2010
+ // • it sits in a `&&`/`||` guarded cone. raise/shortcircuit.ts drops the stamp from a
2011
+ // helper op it hoists out of the arm it guards (`forgetHelperPlacement`), so one that
2012
+ // still carries it ran above the branch, and C's short circuit would skip it (the
2013
+ // guarded-call rule in `nameAccess`).
2014
+ // A divide the ISA computes is none of these: kmc, IDO and mwcc compile `t = k / n; *q
2015
+ // = n;` and `*q = n; … k / n` to the same object, so naming it buys no byte. The rule
2016
+ // after this one names it only where the loop's exit value needs the name.
2017
+ if (isHelper(op) && pr && useSitesOf.has(pr)) {
2018
+ const at = render.emitPositions(op);
2019
+ if (
2020
+ !at ||
2021
+ scopes.shortCircuitGuarded.has(pr) ||
2022
+ reach.ridesEdge(op) ||
2023
+ at.some((p) => p.blk !== b) ||
2024
+ at.some((p) => memWriteBetween(op, p, (x) => effectful(x) || namedHelper(materialize, x)))
2025
+ ) {
2026
+ materialize.add(op);
2027
+ return;
2028
+ }
2029
+ }
2030
+ // A DIVIDE THE ISA COMPUTES, in a value read past the loop's update that reads a loop
2031
+ // variable, is named at its def when that value would be rebuilt behind a memory access
2032
+ // or an effect the asm ran after it (`rebuiltPast`) — once every other rule has settled
2033
+ // (`escapePhase`), as the escape rule is. kmc's `do { t = k / n; *q = n; r = t + 1; }
2034
+ // while (--n);` is `div; …; sw; …; addiu v0, v1, 1`. Unnamed, the loop declines —
2035
+ // `arg-safe-to-reevaluate` (hazards.ts) refuses the sink's move, and a read after the loop
2036
+ // re-reads the updated counter — or, where the back edge carries the value too, the
2037
+ // update copy rebuilds the divide behind the store. Named, the value reads the name and
2038
+ // the divide stays where the asm ran it.
2039
+ if (escapePhase && opSig(op.opcode)?.traps && pr && useSitesOf.has(pr) && loops.rebuiltPast(op, pr)) {
2040
+ materialize.add(op);
2041
+ state.exitDivides.add(op);
2042
+ return;
2043
+ }
2044
+ if (op.opcode === 'const' && pr && useSitesOf.has(pr)) {
2045
+ const cons = [...new Set((useSitesOf.get(pr) ?? []).map((s) => s.op))];
2046
+ if (cons.length > 1 && uses.liveAcrossCall(op, cons)) {
2047
+ materialize.add(op);
2048
+ }
2049
+ } else if (op.opcode !== 'const' && pr && copyInterdependent.has(pr) && !scopes.addressCone(op)) {
2050
+ materialize.add(op);
2051
+ }
2052
+ // A PURE VALUE CARRIED ACROSS A LOOP, computed and read outside every loop, as `nameAccess` names
2053
+ // a read (`carriedPastLoops`; a value read inside a loop is `/expr-home`'s question, where loop.c's
2054
+ // invariant motion may have put it). `j = n * 3; for (…) { if (t[i] == 7) { gHit = j; break; } }`
2055
+ // computes `j` ahead of the loop and holds it in a register to the break arm, which sits outside
2056
+ // the natural loop. Inlined, `gHit = n * 3` moves the computation into the arm, and every profile
2057
+ // compiles it there (IDO scores the two alike). A `const` is re-materialized where it is used, and
2058
+ // an address loses its inline byte-stride cast standing alone and is what a compiler CSEs and
2059
+ // moves freely, so neither is named.
2060
+ if (op.opcode !== 'const' && pr && useSitesOf.has(pr) && !scopes.addressCone(op) && !rendersAsAddress(op)) {
2061
+ const cons = [...new Set((useSitesOf.get(pr) ?? []).map((s) => s.op))];
2062
+ if (loops.carriedPastLoops(op, pr, cons)) {
2063
+ materialize.add(op);
2064
+ }
2065
+ }
2066
+ // A float PRODUCT read by a float add or subtract, directly or through a negation, is named
2067
+ // on a compiler that contracts (target.ts `contractsFloatProducts`): it fuses only WITHIN
2068
+ // one expression, so the inline `a * b + c` recompiles to one `fmadds` — one rounding, a
2069
+ // different value — while `t = a * b; t + c` is the unfused pair the object holds.
2070
+ // KNOWN GAP: not at a multi-block loop header, the seat the scopes below refuse too.
2071
+ if (
2072
+ opts.contractsFloatProducts &&
2073
+ op.opcode === 'fmul' &&
2074
+ render.feedsFloatAdd(op) &&
2075
+ !loops.multiBlockHeaders.has(b)
2076
+ ) {
2077
+ materialize.add(op);
2078
+ }
2079
+ // Folding the FIVE VARIATION scopes below into one predicate-parameterized scope is BOOKED
2080
+ // in docs/level-tower.md and deliberately unpaid; what it cannot absorb is named there,
2081
+ // along with the gate duplication that is its price.
2082
+ // Third scope, under the address-home variation only (see AnalyzeOptions.homeSharedAddresses):
2083
+ // a non-const pure value whose MERGE CLASS is used only as the base of 2+ memory
2084
+ // accesses.
2085
+ if (
2086
+ opts.homeSharedAddresses &&
2087
+ op.opcode !== 'const' &&
2088
+ pr &&
2089
+ scopes.sharedBaseClass.has(pr) &&
2090
+ !scopes.addressCone(op) &&
2091
+ !loops.multiBlockHeaders.has(b)
2092
+ ) {
2093
+ materialize.add(op);
2094
+ state.addressHomedBases.add(pr);
2095
+ }
2096
+ // Fourth scope, under the loop-expression-home variation (AnalyzeOptions.homeLoopExprs): a
2097
+ // pure non-const value with 2+ distinct consumers, at least one of them inside a loop the
2098
+ // def sits outside. Shared bases stay the previous scope's (its load rule needs the
2099
+ // registration).
2100
+ if (
2101
+ opts.homeLoopExprs &&
2102
+ op.opcode !== 'const' &&
2103
+ pr &&
2104
+ !scopes.usedOnlyAsSharedBase(pr) &&
2105
+ scopes.loopSharedConsumers(pr, b) &&
2106
+ !scopes.addressCone(op) &&
2107
+ !loops.multiBlockHeaders.has(b)
2108
+ ) {
2109
+ materialize.add(op);
2110
+ }
2111
+ // Fifth scope, under the derived-read-home variation (AnalyzeOptions.homeDerivedReads): a
2112
+ // pure non-const value with 2+ consumers standing on a memory read. Shared bases stay
2113
+ // the third scope's and values consumed across a loop the fourth's; what this one adds
2114
+ // is the straight-line case, which neither reaches.
2115
+ if (
2116
+ opts.homeDerivedReads &&
2117
+ op.opcode !== 'const' &&
2118
+ pr &&
2119
+ !scopes.usedOnlyAsSharedBase(pr) &&
2120
+ !rendersAsAddress(op) &&
2121
+ scopes.multiConsumer(pr) &&
2122
+ scopes.standsOnMovableRead(op, b) &&
2123
+ !loops.multiBlockHeaders.has(b)
2124
+ ) {
2125
+ materialize.add(op);
2126
+ }
2127
+ // Sixth scope, under the merge-feed-home variation (AnalyzeOptions.homeMergeFeeds) —
2128
+ // `mergeFeedHomes` above. The only one of the FIVE VARIATION scopes that admits a `const`; the
2129
+ // other const clientele is the first scope, a const live across a call. For the sibling
2130
+ // variations a re-derived const is re-materialization, the compiler's own behavior, while a
2131
+ // const the arms of a branch merge is one it held in a register across them
2132
+ // (`mov r5, #0` once, not per arm).
2133
+ if (opts.homeMergeFeeds && scopes.mergeFeedOps.has(op)) {
2134
+ materialize.add(op);
2135
+ }
2136
+ // Seventh scope, under the escaping-extension-home variation
2137
+ // (AnalyzeOptions.homeEscapingExtensions) — `escapingExtensions` above, which the
2138
+ // enumeration gate also runs. The siblings all admit a value the def block also consumes;
2139
+ // this one admits only the value that left, which is the register the asm carried across
2140
+ // the boundary. The seat refusal is the rule's, not the scope's — and unlike the third
2141
+ // scope's, which `sharedBaseClasses` prices with a named row, it has NO structurable
2142
+ // inhabitant here: four constructions of a header defining a multi-consumer escaping
2143
+ // value decline before any variation is consulted, `xor` included
2144
+ // (`escape-home.test.ts`). It stands because the seat is a property of the block.
2145
+ if (opts.homeEscapingExtensions && scopes.escapeHomeOps.has(op) && !loops.multiBlockHeaders.has(b)) {
2146
+ materialize.add(op);
2147
+ }
2148
+ }
2149
+
2150
+ /** A read or call the barrier scan places at its one render position. */
2151
+ interface PlacedAccess {
2152
+ op: Op;
2153
+ /** `counted(op)`: it EXECUTES at each render */
2154
+ once: boolean;
2155
+ sites: UseSite[];
2156
+ pos: { blk: Block; idx: number };
2157
+ /** which part of its anchor the op renders in (`partOf`) */
2158
+ ownPart: string | null;
2159
+ /** under the value-home variation, the writes that may reach the global cell the op reads */
2160
+ barsThisRead: ((x: Op) => boolean) | null;
2161
+ }
2162
+
2163
+ /** Which part of its anchor `x` renders in: '' when the anchor is not a terminator, null when it
2164
+ * has no one part. A terminator renders in parts — its own operands, then one copy per edge
2165
+ * argument, each a statement of its own. */
2166
+ function partOf(render: RenderModel, x: Op): string | null {
2167
+ for (let cur = x; ;) {
2168
+ const cons = render.consumersOf(cur);
2169
+ if (cons.length !== 1) {
2170
+ return null;
2171
+ }
2172
+ const [c] = cons;
2173
+ if (c.successors.length > 0) {
2174
+ const v = cur.results[0];
2175
+ const parts = [
2176
+ ...c.operands.flatMap((o, i) => (o === v ? [`op${i}`] : [])),
2177
+ ...c.successors.flatMap((sc, si) => sc.args.flatMap((a, ai) => (a === v ? [`${si}:${ai}`] : []))),
2178
+ ];
2179
+ return parts.length === 1 ? parts[0] : null;
2180
+ }
2181
+ if (render.anchored(c)) {
2182
+ return '';
2183
+ }
2184
+ cur = c;
2185
+ }
2186
+ }
2187
+
2188
+ /** Does `x` render in the same statement as the placed access: the same anchor, and the same part
2189
+ * of it? */
2190
+ function sameStatement(render: RenderModel, access: PlacedAccess, x: Op): boolean {
2191
+ const q = render.emitPos(x);
2192
+ return (
2193
+ q !== null &&
2194
+ q.blk === access.pos.blk &&
2195
+ q.idx === access.pos.idx &&
2196
+ access.ownPart !== null &&
2197
+ partOf(render, x) === access.ownPart
2198
+ );
2199
+ }
2200
+
2201
+ /** Is every use of the placed access inside `call`'s arguments, as rendered? */
2202
+ function onlyFeedsCall(defOf: Map<Value, Op>, materialize: ReadonlySet<Op>, access: PlacedAccess, call: Op): boolean {
2203
+ const cone = new Set<Op>([call]);
2204
+ const walk = (v: Value): void => {
2205
+ const d = defOf.get(v);
2206
+ if (d !== undefined && d !== access.op && !cone.has(d) && !materialize.has(d)) {
2207
+ cone.add(d);
2208
+ d.operands.forEach(walk);
2209
+ }
2210
+ };
2211
+ call.operands.forEach(walk);
2212
+ return access.sites.every((u) => cone.has(u.op));
2213
+ }
2214
+
2215
+ /** A between-op is a BARRIER when it renders as a sequenced statement the def would cross:
2216
+ * stores/opaque always; a call/load that is dead (statement), materialized (statement), or
2217
+ * inlined into a DIFFERENT statement. Loads never bar a load (reads don't conflict).
2218
+ *
2219
+ * Inside ONE statement the order is the compiler's, and every compiler the corpus builds
2220
+ * with evaluates a call before the memory reads beside it — agbcc, IDO 7.1, both gcc 2.7.2
2221
+ * builds and mwcc all compile `*p + cb(p)`, `cb(p) - *p`, `(*p ^ 3) * cb(p)` and `*p <<
2222
+ * cb(p)` to the call, then the load. So a CALL ahead of a read it shares a statement with
2223
+ * is the order the recompile gives back, and bars nothing. A READ ahead of such a call is
2224
+ * the order no single expression gives back — `int t = *p; return t + cb(p);` compiles
2225
+ * `ldr; bl; add`, and inlined as `*p + cb(p)` it recompiles `bl; ldr` — so that call bars
2226
+ * the read, which is named where it ran. Unless the read is only the call's argument,
2227
+ * which every compiler evaluates first; read a second time beside the call (`G & cg(G)`
2228
+ * for one `ldr`), that second read comes back after it.
2229
+ *
2230
+ * TWO CALLS in one statement have no such order: agbcc calls `cg(k) - cb(p)` in operand
2231
+ * order and mwcc in its own, so `bl cb; bl cg` inlined as that comes back reversed. A call
2232
+ * ahead of another is named too, unless its value is only the later call's argument.
2233
+ *
2234
+ * AND A TERMINATOR IS NOT ONE STATEMENT. It renders in parts (`partOf`), so a read in one edge
2235
+ * copy and a call in its sibling are sequenced, whatever order the copies come out in: agbcc's
2236
+ * `bl cb; ldr r1, [r5]` into a merge came back `a1 = *a0; a2 = cb(a0) + a2;`. Two ops share a
2237
+ * statement only when they render in the same part. */
2238
+ function isBarrier(rules: MaterializeRules, materialize: ReadonlySet<Op>, access: PlacedAccess, x: Op): boolean {
2239
+ const { op, once, barsThisRead } = access;
2240
+ const isCall = op.opcode === 'call';
2241
+ // Value-home variation: a store/astore this read is PROVABLY disjoint from (a different named
2242
+ // global) does not sequence against it, so the read may still render at its use.
2243
+ if (barsThisRead && (x.opcode === 'store' || x.opcode === 'astore') && !barsThisRead(x)) {
2244
+ return false;
2245
+ }
2246
+ if (x.opcode === 'store') {
2247
+ // A store to a PROVABLY-DISJOINT slot of the same base never aliases the load
2248
+ // (`disjointConstSlots`, ir/alias.ts). Anything less certain bars — and so does every
2249
+ // store to a qualified read: a device register answers by when it is read, not only by
2250
+ // which bytes were last written (REG_IF read after the REG_IE write it preceded).
2251
+ if (!once && op.opcode === 'load' && disjointConstSlots(op, x)) {
2252
+ return false;
2253
+ }
2254
+ return true;
2255
+ }
2256
+ if (x.opcode === 'astore' || x.opcode === 'opaque' || namedHelper(materialize, x)) {
2257
+ return true;
2258
+ }
2259
+ if (x.opcode === 'call') {
2260
+ return (
2261
+ !x.results.length ||
2262
+ !rules.uses.useSitesOf.has(x.results[0]) ||
2263
+ materialize.has(x) ||
2264
+ !sameStatement(rules.render, access, x) ||
2265
+ !onlyFeedsCall(rules.defOf, materialize, access, x)
2266
+ );
2267
+ }
2268
+ // Two qualified reads are two accesses in an order. Rendered in one expression, the
2269
+ // order is the compiler's to choose (`gY = VCOUNT - TM0CNT_L` reads VCOUNT first); a
2270
+ // qualified read spelled as a bare statement because nothing uses it is an access too.
2271
+ if (once && !isCall && (x.opcode === 'load' || x.opcode === 'aload') && counted(x)) {
2272
+ return !interchangeableReads(rules, x, op);
2273
+ }
2274
+ if (!isCall) {
2275
+ return false;
2276
+ } // a plain load never bars a load
2277
+ if (x.opcode === 'load' || x.opcode === 'aload') {
2278
+ return !x.results.length || !rules.uses.useSitesOf.has(x.results[0])
2279
+ ? false // dead load: never emitted at all
2280
+ : materialize.has(x) || !sameStatement(rules.render, access, x);
2281
+ }
2282
+ return false;
2283
+ }
2284
+
2285
+ const COMMUTATIVE_OPS: ReadonlySet<string> = new Set(['add', 'mul', 'and', 'or', 'xor']);
2286
+
2287
+ /** One op's identity apart from its operands: opcode, result count and attributes, keys sorted. */
2288
+ const opShape = (op: Op): string =>
2289
+ `${op.opcode}/${op.results.length}/${JSON.stringify(Object.entries(op.attrs).sort(([a], [b]) => a.localeCompare(b)))}`;
2290
+
2291
+ /** Do two values name the same address: one value, or two `gaddr`s of one symbol? */
2292
+ function sameAddress(defOf: Map<Value, Op>, a: Value, b: Value): boolean {
2293
+ if (a === b) {
2294
+ return true;
2295
+ }
2296
+ const da = defOf.get(a);
2297
+ const db = defOf.get(b);
2298
+ return (
2299
+ da !== undefined &&
2300
+ db !== undefined &&
2301
+ da.opcode === 'gaddr' &&
2302
+ db.opcode === 'gaddr' &&
2303
+ opShape(da) === opShape(db)
2304
+ );
2305
+ }
2306
+
2307
+ /** Are two qualified reads of ONE cell interchangeable in the one expression they feed: the same
2308
+ * access of the same address, each the sole use of its value up a chain of ops alike but for it,
2309
+ * the two chains meeting as the two operands of one commutative op? Then the expression's value is
2310
+ * the same whichever read the compiler makes first, and its order is the compiler's to choose:
2311
+ * `gR + gR` over `extern volatile u32 gR;` and `*gPV + *gPV` over `u16 *volatile gPV` read the
2312
+ * object twice, in an order the source never stated, so naming the first read into a temp spells
2313
+ * an order the source did not have (ido 7.1 loads `gR + gR` in the other order). The meeting op
2314
+ * may also be a sum both reads enter with one sign, past a reassociation (`sameSignInOneSum`).
2315
+ * `gR - gR` is not symmetric — the order decides which read is the minuend — and stays sequenced.
2316
+ * `a` is the later read, `b` the earlier one placed past it. */
2317
+ function interchangeableReads(rules: MaterializeRules, a: Op, b: Op): boolean {
2318
+ const { defOf } = rules;
2319
+ if (
2320
+ a === b ||
2321
+ opShape(a) !== opShape(b) ||
2322
+ a.operands.length !== b.operands.length ||
2323
+ !a.operands.every((v, i) => sameAddress(defOf, v, b.operands[i]))
2324
+ ) {
2325
+ return false;
2326
+ }
2327
+ return mirroredChains(rules, a, b) || sameSignInOneSum(rules, b, a);
2328
+ }
2329
+
2330
+ /** The two reads' sole-use chains are alike but for them and meet as the two operands of one
2331
+ * commutative op: `(gR << 1) * (gR << 1)`. */
2332
+ function mirroredChains(rules: MaterializeRules, a: Op, b: Op): boolean {
2333
+ const { useSitesOf } = rules.uses;
2334
+ let ca = a;
2335
+ let cb = b;
2336
+ for (let depth = 0; depth < 8; depth++) {
2337
+ const va = ca.results[0];
2338
+ const vb = cb.results[0];
2339
+ const ua = va === undefined ? undefined : useSitesOf.get(va);
2340
+ const ub = vb === undefined ? undefined : useSitesOf.get(vb);
2341
+ if (ua?.length !== 1 || ub?.length !== 1) {
2342
+ return false;
2343
+ }
2344
+ const na = ua[0].op;
2345
+ const nb = ub[0].op;
2346
+ if (na === nb) {
2347
+ return (
2348
+ COMMUTATIVE_OPS.has(na.opcode) &&
2349
+ na.successors.length === 0 &&
2350
+ na.operands.length === 2 &&
2351
+ ((na.operands[0] === va && na.operands[1] === vb) || (na.operands[0] === vb && na.operands[1] === va))
2352
+ );
2353
+ }
2354
+ if (
2355
+ opShape(na) !== opShape(nb) ||
2356
+ na.successors.length > 0 ||
2357
+ nb.successors.length > 0 ||
2358
+ (MEM_BASE_OPS.has(na.opcode) && counted(na)) ||
2359
+ na.operands.length !== nb.operands.length ||
2360
+ !na.operands.every((v, i) => (v === va ? nb.operands[i] === vb : v === nb.operands[i] && v !== vb))
2361
+ ) {
2362
+ return false;
2363
+ }
2364
+ ca = na;
2365
+ cb = nb;
2366
+ }
2367
+ return false;
2368
+ }
2369
+
2370
+ /** Each read's sole-use chain up through `add`/`sub` ops, as the ops it passes and the read's sign in
2371
+ * each: entering an `add`, or a `sub` as its minuend, keeps the sign; entering a `sub` as its
2372
+ * subtrahend flips it. The value of every op on the chain is then that sign times the read, plus
2373
+ * terms the read is not in. */
2374
+ function additiveChain(rules: MaterializeRules, read: Op): Map<Op, { sign: number; operand: number }> {
2375
+ const { useSitesOf } = rules.uses;
2376
+ const chain = new Map<Op, { sign: number; operand: number }>();
2377
+ let v = read.results[0];
2378
+ let sign = 1;
2379
+ for (let depth = 0; depth < 8 && v !== undefined; depth++) {
2380
+ const uses = useSitesOf.get(v);
2381
+ if (uses?.length !== 1) {
2382
+ break;
2383
+ }
2384
+ const n = uses[0].op;
2385
+ const operand = n.operands.indexOf(v);
2386
+ if (
2387
+ (n.opcode !== 'add' && n.opcode !== 'sub') ||
2388
+ n.operands.length !== 2 ||
2389
+ n.results.length !== 1 ||
2390
+ n.successors.length > 0 ||
2391
+ n.operands.lastIndexOf(v) !== operand
2392
+ ) {
2393
+ break;
2394
+ }
2395
+ sign = n.opcode === 'sub' && operand === 1 ? -sign : sign;
2396
+ chain.set(n, { sign, operand });
2397
+ v = n.results[0];
2398
+ }
2399
+ return chain;
2400
+ }
2401
+
2402
+ /** The two reads meet in one sum with the same sign: their sole-use chains run through `add`/`sub`
2403
+ * ops only, to an op one reaches through each operand, where each counts with the same sign. The
2404
+ * sum is then the same whichever read is made first, however the compiler reassociated it:
2405
+ * ido 7.1 compiles `(gR - x) + (gR - x)` as `((gR - x) + gR) - x`, where one read passes a `sub`
2406
+ * before the `add` they meet in and the other does not, so the chains are not mirrored. `gR - gR`
2407
+ * meets with opposite signs and stays sequenced. Wrapping arithmetic keeps the sum associative.
2408
+ * The value is then right in either order, but the match is not: the earlier read must also be
2409
+ * the one the expression spells first, entering the meeting op as its left operand, or a compiler
2410
+ * that reads operands left to right reverses the asm's two loads. mwcc 2.4.2 reads
2411
+ * `gMS.f + ((t + gMS.f) + t)`'s right-hand `gMS.f` first, so spelling both in one expression
2412
+ * there puts the asm's second load first. */
2413
+ function sameSignInOneSum(rules: MaterializeRules, earlier: Op, later: Op): boolean {
2414
+ const ofEarlier = additiveChain(rules, earlier);
2415
+ if (ofEarlier.size === 0) {
2416
+ return false;
2417
+ }
2418
+ for (const [n, inLater] of additiveChain(rules, later)) {
2419
+ const inEarlier = ofEarlier.get(n);
2420
+ if (inEarlier !== undefined) {
2421
+ return inEarlier.operand === 0 && inLater.operand === 1 && inEarlier.sign === inLater.sign;
2422
+ }
2423
+ }
2424
+ return false;
2425
+ }
2426
+
2427
+ /** The rules for a memory access — a `call`, `load` or `aload` — in order; the first that names
2428
+ * the op ends the walk. */
2429
+ function nameAccess(rules: MaterializeRules, state: MaterializeState, op: Op, b: Block): void {
2430
+ const { opts, uses, edges, render, reach, loops, scopes, predsOf, memWriteBetween } = rules;
2431
+ const { useSitesOf } = uses;
2432
+ const { materialize } = state;
2433
+ const { defs, dom } = opts;
2434
+ const r = op.results[0];
2435
+ if (!r || !useSitesOf.has(r)) {
2436
+ return;
2437
+ } // dead call → exprstmt (unchanged)
2438
+ // A call, or a read whose spelling is qualified (a device read the lift pinned, a read of an
2439
+ // object the map declares volatile), EXECUTES at each render, where a plain duplicate read is
2440
+ // one agbcc CSEs away. It takes the two-site, edge, guarded-operand and one-position rules
2441
+ // below. And it stays in order against every other qualified access, which `isBarrier` says.
2442
+ const once = counted(op);
2443
+ // Under the value-home variation: which named global cell this op reads, if any. A constant-
2444
+ // offset `load` only — an `aload`'s runtime index names no single cell, and a call reads
2445
+ // everything. Null ⇒ every write bars.
2446
+ const cell =
2447
+ opts.rereadGlobals && defs && op.opcode === 'load' && !once
2448
+ ? globalCellOf(defs, op.operands[0], op.attrs.off as number)
2449
+ : null;
2450
+ const barsThisRead = cell && defs ? mayWriteGlobal(defs, cell.name) : null;
2451
+ if (opts.materializeJoinFeeds && op.opcode === 'load' && edges.condBrArgFed.has(r)) {
2452
+ materialize.add(op);
2453
+ return;
2454
+ }
2455
+ const sites = useSitesOf.get(r)!;
2456
+ const consumers = [...new Set(sites.map((s) => s.op))];
2457
+ // A call must EXECUTE once — any second operand slot duplicates it → named temp.
2458
+ if (once && sites.length > 1) {
2459
+ materialize.add(op);
2460
+ return;
2461
+ }
2462
+ // …and ONCE means once on EVERY path the asm runs it on, which the multi-site rule above
2463
+ // does not say. `anchored` calls a terminator a single render position, but a branch's edge
2464
+ // copies are emitted inside the ARMS, so a call whose only consumer is an edge argument
2465
+ // renders in one arm and is skipped on the others. Compiled: `s32 t = f2(a); if (a > 0) {
2466
+ // return t; } return 0;` gives `bl f2` ahead of the `cmp`, and inlined at the edge the
2467
+ // recovered C calls `f2` only in the `else`; a `switch_br` arm hides it the same way.
2468
+ // Materializing puts it back at the position the asm executed it. Sole-use only in
2469
+ // practice — a second use already materialized above.
2470
+ //
2471
+ // And the same under the ops it is inlined into (`ridesEdge`): `f1(a0) - v` riding a
2472
+ // do-while's exit edge renders the call after the loop, once, where the body ran it every
2473
+ // iteration; riding the back edge of a loop that also exits elsewhere it renders in an arm;
2474
+ // riding two edge args it renders twice. And one op reading it twice spells it twice
2475
+ // (`spelledTwice`).
2476
+ if (once && (edges.branchArgFed.has(r) || reach.ridesEdge(op) || reach.spelledTwice(op))) {
2477
+ materialize.add(op);
2478
+ return;
2479
+ }
2480
+ // …and a `&&`/`||` skips its guarded operand the same way, without a branch of its own to
2481
+ // give it away. Two independent facts. A def DOMINATES its uses (ir/verify.ts), so a call
2482
+ // or a qualified read the connective reads ran on every path that evaluates it, while the
2483
+ // inlined C runs it on fewer — agbcc compiles `do { r = cb(p); } while (i++ <= n && r !=
2484
+ // 0);` to a `bl cb` ahead of both compares. And the DEF is where to put it back because a
2485
+ // counted op is `speculationUnsafe`, so no fold lifted one into this cone
2486
+ // (raise/shortcircuit.ts refuses an arm that holds one; structure.ts declines on a read
2487
+ // placed after the folds ran). `opaque`, the other hoist-unsafe
2488
+ // op with a result, needs no placement — neither position spells compilable C — and a
2489
+ // bottom test holding one still declines in `testSkipsAnEffect`, which is that guard's
2490
+ // remaining population.
2491
+ if (once && scopes.shortCircuitGuarded.has(r)) {
2492
+ materialize.add(op);
2493
+ return;
2494
+ }
2495
+ // Live across a LOOP neither side belongs to: the access ran before the loop and the
2496
+ // value crossed it in a callee-saved register (hipress's `keep = p[1]`, homed in r8 and
2497
+ // touched only by `mov`). Rendering at the use would re-schedule the access to the far
2498
+ // side of the loop — the same refusal liveAcrossCall makes for a call, applied to a loop.
2499
+ if (loops.liveAcrossLoop(op, r, consumers)) {
2500
+ materialize.add(op);
2501
+ return;
2502
+ }
2503
+ // ── DEF-BLOCK PLACEMENT (readsStayWhereWritten; see AnalyzeOptions) ──────────────────
2504
+ // Every render in a block this one strictly dominates, with a branch between ⇒ the asm
2505
+ // read once above that branch, and re-spelling the read there reproduces it. ONE render
2506
+ // suffices — the short-circuit-into-a-call shape has exactly one — and an unresolvable
2507
+ // render position refuses, as everywhere else. Both memory reads, spelled positively: a
2508
+ // `call` is the enclosing arm's other member and has its own execute-once rules above.
2509
+ if (
2510
+ (op.opcode === 'load' || op.opcode === 'aload') &&
2511
+ opts.readsStayWhereWritten &&
2512
+ dom &&
2513
+ !loops.multiBlockHeaders.has(b) &&
2514
+ !scopes.shortCircuitGuarded.has(r) &&
2515
+ !scopes.onlyFeedsBlockParams(r)
2516
+ ) {
2517
+ const at = consumers.map((c) => render.emitPos(c));
2518
+ const rb = at.some((p) => p === null) ? null : [...new Set(at.map((p) => p!.blk))];
2519
+ if (
2520
+ rb &&
2521
+ rb.length > 0 &&
2522
+ rb.every((x) => x !== b && dom.get(x)!.has(b)) &&
2523
+ !preheaderOfRenderLoop(loops.loopBodies, b, rb) &&
2524
+ !fallThroughSeam(predsOf, b, rb)
2525
+ ) {
2526
+ materialize.add(op);
2527
+ return;
2528
+ }
2529
+ }
2530
+ // Address-home variation: a multi-render load THROUGH a base this variation homed re-reads what the
2531
+ // asm read once into the register the home just reproduced — home the value too, at the
2532
+ // load's own position. Only through variation-homed bases (the fixpoint's later sweep sees them
2533
+ // even though reverse order visits the load first); the general multi-render re-read stays
2534
+ // the default rule below. addressHomedBases registers on the same sweep that materializes the
2535
+ // base — safe because no other rule can pre-empt a value the variation would home: base-slot-only
2536
+ // means no successor-arg use (outside copyInterdependent's read set), and the variation's scope
2537
+ // excludes consts, the const arm's only clientele.
2538
+ if (
2539
+ opts.homeSharedAddresses &&
2540
+ op.opcode === 'load' &&
2541
+ consumers.length > 1 &&
2542
+ state.addressHomedBases.has(op.operands[0]) &&
2543
+ !loops.multiBlockHeaders.has(b)
2544
+ ) {
2545
+ materialize.add(op);
2546
+ return;
2547
+ }
2548
+ // A MULTI-RENDER load re-reads memory at each render — which is exactly what the original
2549
+ // per-use source spelling did (`while (*s != EOS) *d = *s;` reads *s twice per iteration),
2550
+ // so it is sound iff every render still sees the def-time memory: NO write anywhere
2551
+ // between the def and ANY render (cycle-aware, conservative write set). Otherwise a temp.
2552
+ //
2553
+ // WHERE it renders. Without the variation: one position per consumer, and a consumer with no
2554
+ // single position (its own value renders in several places) refuses. With the variation a load
2555
+ // resolves the whole SET instead — the second half of the value-home defect, where the
2556
+ // local is invented not by a barrier but because the pure expression downstream is itself
2557
+ // duplicated (`gOut = (gValue << 1) + gValue; return (gValue << 1) + gValue;`). Never for a
2558
+ // call or a marked read: several positions there mean several executions.
2559
+ const poss =
2560
+ opts.rereadGlobals && !once
2561
+ ? render.emitPositions(op)
2562
+ : consumers.length > 1
2563
+ ? consumers.map((c) => render.emitPos(c))
2564
+ : [render.emitPos(consumers[0])];
2565
+ if (!poss || poss.some((p) => p === null)) {
2566
+ materialize.add(op);
2567
+ return;
2568
+ }
2569
+ // A named helper op bars it too, as it bars the single render below: it is the call the asm
2570
+ // made, and a value read before it and used after it was read once and kept across it
2571
+ // (`v = gB; t = v / n; … q[1] = v;` is one `ldr` ahead of the `bl __divsi3`).
2572
+ if (poss.length > 1) {
2573
+ const writes = barsThisRead ?? effectful;
2574
+ const isWrite = (x: Op): boolean => writes(x) || namedHelper(materialize, x);
2575
+ if (poss.some((p) => memWriteBetween(op, p!, isWrite))) {
2576
+ materialize.add(op);
2577
+ }
2578
+ return;
2579
+ }
2580
+ const pos = poss[0]!;
2581
+ const access: PlacedAccess = { op, once, sites, pos, ownPart: partOf(render, op), barsThisRead };
2582
+ // A CROSS-BLOCK call or qualified read would run on the render block's paths instead of
2583
+ // its own — always materialize. Within its own block it is judged like everything else, by the
2584
+ // barrier scan below.
2585
+ if (once && pos.blk !== b) {
2586
+ materialize.add(op);
2587
+ return;
2588
+ }
2589
+ // Otherwise: inline only if no barrier stands on any def-avoiding def→render path.
2590
+ if (memWriteBetween(op, pos, (x) => isBarrier(rules, materialize, access, x))) {
2591
+ materialize.add(op);
2592
+ }
2593
+ }
2594
+
2595
+ /** A helper op the helper clause named, or a divide the exit rule did, is a pre-update home too
2596
+ * when its value reads a loop variable and is read where the update has run. Named, it lifts the
2597
+ * loop the pre-update hazard declines, in a body of any size — so the structurer's refusal on
2598
+ * homes (`preUpdateHomes`) must see it, as it sees the escape rule's.
2599
+ * KNOWN GAP: `readAfterUpdate` asks of the op's own consumers, so a divide whose value leaves the
2600
+ * loop only through a body op it feeds (`t + 1`) is not a home. Walked through that op, it would
2601
+ * be one, and the refusal would decline every such loop over a counter the function takes as a
2602
+ * parameter: its post-loop names include the entry block's. */
2603
+ function namePreUpdateHelpers(rules: MaterializeRules, state: MaterializeState): void {
2604
+ const { uses, render, loops } = rules;
2605
+ for (const op of state.materialize) {
2606
+ const r = op.results[0];
2607
+ if (!r || state.preUpdateHomes.has(op) || !(isHelper(op) || state.exitDivides.has(op))) {
2608
+ continue;
2609
+ }
2610
+ const consumers = render.consumersOf(op);
2611
+ if (
2612
+ loops.bottomTested.some(
2613
+ (L) =>
2614
+ L.body.has(uses.opBlock.get(op)!) && loops.readAfterUpdate(L, r, consumers) && loops.readsPreUpdate(L, op, r),
2615
+ )
2616
+ ) {
2617
+ state.preUpdateHomes.add(op);
2618
+ }
2619
+ }
2620
+ }
2621
+
2622
+ /** Every stage `analyze` runs ahead of its fixpoint, over an empty `MaterializeState`. */
2623
+ function analysisStages(
2624
+ fn: Fn,
2625
+ returnsVoid: boolean,
2626
+ opts: AnalyzeOptions,
2627
+ ): {
2628
+ liveIn: Map<Block, Set<Value>>;
2629
+ reachFrom: (b: Block) => Set<Block>;
2630
+ state: MaterializeState;
2631
+ rules: MaterializeRules;
2632
+ } {
2633
+ const { dom } = opts;
2634
+ const uses = indexUses(fn, returnsVoid);
2635
+ const edges = edgeArgUses(fn, dom ?? dominators(fn));
2636
+ const liveIn = blockLiveIn(fn, returnsVoid);
2637
+ const state: MaterializeState = {
2638
+ materialize: new Set(),
2639
+ preUpdateHomes: new Set(),
2640
+ exitDivides: new Set(),
2641
+ addressHomedBases: new Set(),
2642
+ };
2643
+ const { materialize } = state;
2644
+ const render = makeRenderModel(uses, materialize);
2645
+ const reach = makeInlineReach(uses, edges, materialize);
2646
+ const { reachFrom, reachAvoiding } = makeReach();
2647
+ const memWriteBetween = makeMemWriteBetween({ opBlock: uses.opBlock, opIndex: uses.opIndex, reachAvoiding });
2648
+ const defOf = opts.defs ?? defOpMap(fn);
2649
+ const predsOf = predecessors(fn);
2650
+ const loops = makeLoopRules({ fn, dom, predsOf, liveIn, uses, defOf, render, materialize });
2651
+ const scopes = homeScopes({ fn, returnsVoid, opts, uses, defOf, predsOf, loops, memWriteBetween });
2652
+ const copyInterdependent = copyInterdependentValues(fn, defOf);
2653
+ const rules = {
2654
+ opts,
2655
+ uses,
2656
+ edges,
2657
+ render,
2658
+ reach,
2659
+ loops,
2660
+ scopes,
2661
+ defOf,
2662
+ predsOf,
2663
+ memWriteBetween,
2664
+ copyInterdependent,
2665
+ };
2666
+ return { liveIn, reachFrom, state, rules };
2667
+ }
2668
+
2669
+ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {}): StructureAnalysis {
2670
+ const { liveIn, reachFrom, state, rules } = analysisStages(fn, returnsVoid, opts);
2671
+ const { materialize, preUpdateHomes } = state;
2672
+ const { uses, render, loops } = rules;
1468
2673
  // Decide in REVERSE program order so a consumer's own materialization is settled before any
1469
2674
  // producer asks for its emit position (SSA: uses follow defs in dominance/layout order) — and
1470
2675
  // iterate to a fixpoint for IR whose block layout does not follow dominance (hand-built IR):
1471
2676
  // materialize only GROWS, and growing it only moves render positions closer / adds barriers,
1472
2677
  // so the loop is monotone and converges.
1473
- for (let sizeBefore = -1; sizeBefore !== materialize.size;) {
2678
+ //
2679
+ // Then the same fixpoint again with the pre-update escape rule on. The rule stops its walk at a
2680
+ // materialized def, so it must see every name the other rules give; it adds its homes on the first
2681
+ // sweep, and the rest of the phase is the other rules settling around them.
2682
+ let escapePhase = false;
2683
+ for (let sizeBefore = -1; sizeBefore !== materialize.size || !escapePhase;) {
2684
+ if (sizeBefore === materialize.size) {
2685
+ escapePhase = true;
2686
+ }
1474
2687
  sizeBefore = materialize.size;
1475
- emitPosCache.clear();
1476
- emitPosSetCache.clear(); // both render-position caches read `materialize`, which just grew
2688
+ render.invalidate(); // both render-position caches read `materialize`, which just grew
1477
2689
  for (let bi = fn.blocks.length - 1; bi >= 0; bi--) {
1478
2690
  const b = fn.blocks[bi];
1479
2691
  for (let oi = b.ops.length - 1; oi >= 0; oi--) {
@@ -1481,264 +2693,52 @@ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {})
1481
2693
  if (materialize.has(op)) {
1482
2694
  continue;
1483
2695
  }
1484
- if (op.opcode !== 'call' && op.opcode !== 'load' && op.opcode !== 'aload') {
1485
- // PURE value-producing op (a constant, an address computation, arithmetic — NOT a
1486
- // memory access). A value with ≥2 distinct-STATEMENT uses in the SSA is one the compiler
1487
- // kept in a register and reused: the frontend never dedups, so multi-use exists ONLY
1488
- // because the asm loaded/computed the value once and read the same register again.
1489
- // Inlining it re-derives the value at each use (a fresh pool load / repeated address
1490
- // arithmetic) — which the compiler did NOT do — so materialize it into a local instead,
1491
- // reproducing that register. Pure ⇒ every render is value-identical, so (unlike a load)
1492
- // no intervening memory write can invalidate a later render: multi-consumer suffices, no
1493
- // barrier scan. Scope: a `const` that is LIVE ACROSS A CALL. A value the compiler needs
1494
- // after a call must survive in a CALLEE-SAVED register — i.e. a local — because the call
1495
- // clobbers the caller-saved ones; the compiler therefore loads it ONCE and keeps it,
1496
- // exactly what materializing into a local reproduces (the base of `((s32 *)C)[i]` reused
1497
- // across `foo(...)` calls). WITHOUT a call in its live range the const is instead cheaply
1498
- // re-materialized at each use (a bare `movs r, #0` per init), so materializing it would
1499
- // ADD pointless copies and MISS — hence the call gate (the small-constant regression).
1500
- // Cheap deref casts still land on the `index` node at the use, preserving byte strides.
1501
- // Second scope: a NON-const read by a sibling parallel-copy arg (`copyInterdependent`) —
1502
- // the one place a pure value's inlining is not value-identical rendering but a
1503
- // re-derivation the copy machinery is forced into. Address cones are excluded from it
1504
- // (an `&g + i` rendered standalone loses the memAccess's inline `(u8 *)` cast —
1505
- // cast-aware base materialization is separate), and consts are not in it at all: a
1506
- // re-derived const is re-materialization, which is the compiler's own behavior.
1507
- const pr = op.results[0];
1508
- if (op.opcode === 'const' && pr && useSitesOf.has(pr)) {
1509
- const cons = [...new Set((useSitesOf.get(pr) ?? []).map((s) => s.op))];
1510
- if (cons.length > 1 && liveAcrossCall(op, cons)) {
1511
- materialize.add(op);
1512
- }
1513
- } else if (op.opcode !== 'const' && pr && copyInterdependent.has(pr) && !addressCone(op)) {
1514
- materialize.add(op);
1515
- }
1516
- // Folding the FOUR VARIATION scopes below into one predicate-parameterized scope is BOOKED
1517
- // in docs/level-tower.md and deliberately unpaid; what it cannot absorb is named there,
1518
- // along with the price the fourth one added to it (the gate duplication).
1519
- // Third scope, under the address-home variation only (see AnalyzeOptions.homeSharedAddresses):
1520
- // a non-const pure value whose MERGE CLASS is used only as the base of 2+ memory
1521
- // accesses.
1522
- if (
1523
- homeSharedAddresses &&
1524
- op.opcode !== 'const' &&
1525
- pr &&
1526
- sharedBaseClass.has(pr) &&
1527
- !addressCone(op) &&
1528
- !multiBlockHeaders.has(b)
1529
- ) {
1530
- materialize.add(op);
1531
- addressHomedBases.add(pr);
1532
- }
1533
- // Fourth scope, under the loop-expression-home variation (AnalyzeOptions.homeLoopExprs): a
1534
- // pure non-const value with 2+ distinct consumers, at least one of them inside a loop the
1535
- // def sits outside. Shared bases stay the previous scope's (its load rule needs the
1536
- // registration).
1537
- if (
1538
- homeLoopExprs &&
1539
- op.opcode !== 'const' &&
1540
- pr &&
1541
- !usedOnlyAsSharedBase(pr) &&
1542
- loopSharedConsumers(pr, b) &&
1543
- !addressCone(op) &&
1544
- !multiBlockHeaders.has(b)
1545
- ) {
1546
- materialize.add(op);
1547
- }
1548
- // Fifth scope, under the derived-read-home variation (AnalyzeOptions.homeDerivedReads): a
1549
- // pure non-const value with 2+ consumers standing on a memory read. Shared bases stay
1550
- // the third scope's and values consumed across a loop the fourth's; what this one adds
1551
- // is the straight-line case, which neither reaches.
1552
- if (
1553
- homeDerivedReads &&
1554
- op.opcode !== 'const' &&
1555
- pr &&
1556
- !usedOnlyAsSharedBase(pr) &&
1557
- !rendersAsAddress(op) &&
1558
- multiConsumer(pr) &&
1559
- standsOnMovableRead(op, b) &&
1560
- !multiBlockHeaders.has(b)
1561
- ) {
1562
- materialize.add(op);
1563
- }
1564
- // Sixth scope, under the merge-feed-home variation (AnalyzeOptions.homeMergeFeeds) —
1565
- // `mergeFeedHomes` above. The only one of the FOUR VARIATION scopes that admits a `const`; the
1566
- // other const clientele is the first scope, a const live across a call. For the sibling
1567
- // variations a re-derived const is re-materialization, the compiler's own behavior, while a
1568
- // const the arms of a branch merge is one it held in a register across them
1569
- // (`mov r5, #0` once, not per arm).
1570
- if (homeMergeFeeds && mergeFeedOps.has(op)) {
1571
- materialize.add(op);
1572
- }
1573
- continue;
1574
- }
1575
- const r = op.results[0];
1576
- if (!r || !useSitesOf.has(r)) {
1577
- continue;
1578
- } // dead call → exprstmt (unchanged)
1579
- // Under the value-home variation: which named global cell this op reads, if any. A constant-
1580
- // offset `load` only — an `aload`'s runtime index names no single cell, and a call reads
1581
- // everything. Null ⇒ every write bars, exactly as before.
1582
- const cell =
1583
- rereadGlobals && defs && op.opcode === 'load'
1584
- ? globalCellOf(defs, op.operands[0], op.attrs.off as number)
1585
- : null;
1586
- const barsThisRead = cell && defs && !volatileGlobal?.(cell.name) ? mayWriteGlobal(defs, cell.name) : null;
1587
- if (materializeJoinFeeds && op.opcode === 'load' && condBrArgFed.has(r)) {
1588
- materialize.add(op);
1589
- continue;
1590
- }
1591
- const sites = useSitesOf.get(r)!;
1592
- const consumers = [...new Set(sites.map((s) => s.op))];
1593
- const isCall = op.opcode === 'call';
1594
- // A call must EXECUTE once — any second operand slot duplicates it → named temp.
1595
- if (isCall && sites.length > 1) {
1596
- materialize.add(op);
1597
- continue;
1598
- }
1599
- // …and ONCE means once on EVERY path the asm runs it on, which the multi-site rule above
1600
- // does not say. `anchored` calls a terminator a single render position, but a branch's edge
1601
- // copies are emitted inside the ARMS, so a call whose only consumer is an edge argument
1602
- // renders in one arm and is skipped on the others. Compiled: `s32 t = f2(a); if (a > 0) {
1603
- // return t; } return 0;` gives `bl f2` ahead of the `cmp`, and inlined at the edge the
1604
- // recovered C calls `f2` only in the `else`; a `switch_br` arm hides it the same way.
1605
- // Materializing puts it back at the position the asm executed it. Sole-use only in
1606
- // practice — a second use already materialized above — and it is 0 of 2288 sa3 functions,
1607
- // 2 of 412 klonoa ones.
1608
- if (isCall && branchArgFed.has(r)) {
1609
- materialize.add(op);
1610
- continue;
1611
- }
1612
- // Live across a LOOP neither side belongs to: the access ran before the loop and the
1613
- // value crossed it in a callee-saved register (hipress's `keep = p[1]`, homed in r8 and
1614
- // touched only by `mov`). Rendering at the use would re-schedule the access to the far
1615
- // side of the loop — the same refusal liveAcrossCall makes for a call, applied to a loop.
1616
- if (liveAcrossLoop(op, r, consumers)) {
1617
- materialize.add(op);
1618
- continue;
1619
- }
1620
- // ── DEF-BLOCK PLACEMENT (readsStayWhereWritten; see AnalyzeOptions) ──────────────────
1621
- // Every render in a block this one strictly dominates, with a branch between ⇒ the asm
1622
- // read once above that branch, and re-spelling the read there reproduces it. ONE render
1623
- // suffices — the short-circuit-into-a-call shape has exactly one — and an unresolvable
1624
- // render position refuses, as everywhere else. Both memory reads, spelled positively: a
1625
- // `call` is the enclosing arm's other member and has its own execute-once rules above.
2696
+ // A call needs no rule: read outside its block it is named by `nameAccess`'s cross-block rule.
2697
+ const er = op.results[0];
1626
2698
  if (
1627
- (op.opcode === 'load' || op.opcode === 'aload') &&
1628
- readsStayWhereWritten &&
1629
- dom &&
1630
- !multiBlockHeaders.has(b) &&
1631
- !shortCircuitGuarded.has(r) &&
1632
- !onlyFeedsBlockParams(r)
2699
+ escapePhase &&
2700
+ op.opcode !== 'call' &&
2701
+ er &&
2702
+ uses.useSitesOf.has(er) &&
2703
+ loops.escapesAheadOfUpdate(op, er, render.consumersOf(op))
1633
2704
  ) {
1634
- const at = consumers.map((c) => emitPos(c));
1635
- const rb = at.some((p) => p === null) ? null : [...new Set(at.map((p) => p!.blk))];
1636
- if (
1637
- rb &&
1638
- rb.length > 0 &&
1639
- rb.every((x) => x !== b && dom.get(x)!.has(b)) &&
1640
- !preheaderOfRenderLoop(loopBodies, b, rb) &&
1641
- !fallThroughSeam(predsOf, b, rb)
1642
- ) {
1643
- materialize.add(op);
1644
- continue;
1645
- }
1646
- }
1647
- // Address-home variation: a multi-render load THROUGH a base this variation homed re-reads what the
1648
- // asm read once into the register the home just reproduced — home the value too, at the
1649
- // load's own position. Only through variation-homed bases (the fixpoint's later sweep sees them
1650
- // even though reverse order visits the load first); the general multi-render re-read stays
1651
- // the default rule below. addressHomedBases registers on the same sweep that materializes the
1652
- // base — safe because no other rule can pre-empt a value the variation would home: base-slot-only
1653
- // means no successor-arg use (outside copyInterdependent's read set), and the variation's scope
1654
- // excludes consts, the const arm's only clientele.
1655
- if (
1656
- homeSharedAddresses &&
1657
- op.opcode === 'load' &&
1658
- consumers.length > 1 &&
1659
- addressHomedBases.has(op.operands[0]) &&
1660
- !multiBlockHeaders.has(b)
1661
- ) {
1662
- materialize.add(op);
1663
- continue;
1664
- }
1665
- // A MULTI-RENDER load re-reads memory at each render — which is exactly what the original
1666
- // per-use source spelling did (`while (*s != EOS) *d = *s;` reads *s twice per iteration),
1667
- // so it is sound iff every render still sees the def-time memory: NO write anywhere
1668
- // between the def and ANY render (cycle-aware, conservative write set). Otherwise a temp.
1669
- //
1670
- // WHERE it renders. Without the variation: one position per consumer, and a consumer with no
1671
- // single position (its own value renders in several places) refuses. With the variation a load
1672
- // resolves the whole SET instead — the second half of the value-home defect, where the
1673
- // local is invented not by a barrier but because the pure expression downstream is itself
1674
- // duplicated (`gOut = (gValue << 1) + gValue; return (gValue << 1) + gValue;`). Never for a
1675
- // call: several positions there mean several executions.
1676
- const poss =
1677
- rereadGlobals && !isCall
1678
- ? emitPositions(op)
1679
- : consumers.length > 1
1680
- ? consumers.map((c) => emitPos(c))
1681
- : [emitPos(consumers[0])];
1682
- if (!poss || poss.some((p) => p === null)) {
1683
- materialize.add(op);
1684
- continue;
1685
- }
1686
- if (poss.length > 1) {
1687
- const isWrite = barsThisRead ?? ((x: Op) => EFFECTFUL_OPS.has(x.opcode));
1688
- if (poss.some((p) => memWriteBetween(op, p!, isWrite))) {
1689
- materialize.add(op);
1690
- }
1691
- continue;
1692
- }
1693
- const pos = poss[0]!;
1694
- // A between-op is a BARRIER when it renders as a sequenced statement the def would cross:
1695
- // stores/opaque always; a call/load that is dead (statement), materialized (statement), or
1696
- // inlined into a DIFFERENT statement. A sibling effect inlined into the SAME statement is
1697
- // not a reorder — the recompiling compiler orders unsequenced operands of one expression
1698
- // exactly as it originally chose to. Loads never bar a load (reads don't conflict).
1699
- const samePos = (q: { blk: Block; idx: number } | null) => q !== null && q.blk === pos.blk && q.idx === pos.idx;
1700
- const isBarrier = (x: Op): boolean => {
1701
- // Value-home variation: a store/astore this read is PROVABLY disjoint from (a different named
1702
- // global) does not sequence against it, so the read may still render at its use.
1703
- if (barsThisRead && (x.opcode === 'store' || x.opcode === 'astore') && !barsThisRead(x)) {
1704
- return false;
1705
- }
1706
- if (x.opcode === 'store') {
1707
- // A store to a PROVABLY-DISJOINT slot of the same base never aliases the load
1708
- // (`disjointConstSlots`, ir/alias.ts). Anything less certain bars.
1709
- if (!isCall && op.opcode === 'load' && disjointConstSlots(op, x)) {
1710
- return false;
1711
- }
1712
- return true;
1713
- }
1714
- if (x.opcode === 'astore' || x.opcode === 'opaque') {
1715
- return true;
1716
- }
1717
- if (x.opcode === 'call') {
1718
- return !x.results.length || !useSitesOf.has(x.results[0]) || materialize.has(x) || !samePos(emitPos(x));
1719
- }
1720
- if (!isCall) {
1721
- return false;
1722
- } // a load never bars a load
1723
- if (x.opcode === 'load' || x.opcode === 'aload') {
1724
- return !x.results.length || !useSitesOf.has(x.results[0])
1725
- ? false // dead load: never emitted at all
1726
- : materialize.has(x) || !samePos(emitPos(x));
1727
- }
1728
- return false;
1729
- };
1730
- // A CROSS-BLOCK call's execution would become path-dependent — always materialize. Within
1731
- // its own block a call is judged like everything else, by the barrier scan below.
1732
- if (isCall && pos.blk !== b) {
1733
2705
  materialize.add(op);
2706
+ preUpdateHomes.add(op);
1734
2707
  continue;
1735
2708
  }
1736
- // Otherwise: inline only if no barrier stands on any def-avoiding def→render path.
1737
- if (memWriteBetween(op, pos, isBarrier)) {
1738
- materialize.add(op);
2709
+ if (op.opcode !== 'call' && op.opcode !== 'load' && op.opcode !== 'aload') {
2710
+ namePureOp(rules, state, op, b, escapePhase);
2711
+ } else {
2712
+ nameAccess(rules, state, op, b);
1739
2713
  }
1740
2714
  }
1741
2715
  }
1742
2716
  }
1743
- return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween };
2717
+ namePreUpdateHelpers(rules, state);
2718
+ return {
2719
+ useSitesOf: uses.useSitesOf,
2720
+ opIndex: uses.opIndex,
2721
+ opBlock: uses.opBlock,
2722
+ liveIn,
2723
+ materialize,
2724
+ preUpdateHomes,
2725
+ reachFrom,
2726
+ emitPos: render.emitPos,
2727
+ memWriteBetween: rules.memWriteBetween,
2728
+ };
1744
2729
  }
2730
+
2731
+ /** Internal surface for this module's own tests, and for nothing else. `@asmlift/core` exports
2732
+ * every source path under `./*`, so a plain `export` here would put the stages `analyze` runs into the
2733
+ * package's public API, where no consumer should be able to depend on them. The name says what it
2734
+ * is; nothing in `src` reads it. */
2735
+ export const __testing = {
2736
+ analysisStages,
2737
+ blockLiveIn,
2738
+ indexUses,
2739
+ edgeArgUses,
2740
+ makeRenderModel,
2741
+ makeLoopRules,
2742
+ namePureOp,
2743
+ nameAccess,
2744
+ };