@asmlift/core 0.6.0 → 0.7.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 (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/backend/cfamily.ts +39 -11
  4. package/src/contracts.ts +60 -11
  5. package/src/frontend/ssa.ts +1 -1
  6. package/src/frontend/thumb.ts +2 -2
  7. package/src/ir/alias.ts +24 -0
  8. package/src/ir/core.ts +8 -0
  9. package/src/ir/opcodes.ts +43 -7
  10. package/src/ir/simplify.ts +1 -1
  11. package/src/l3/address.ts +2 -2
  12. package/src/l3/advance.ts +373 -0
  13. package/src/l3/argbase.ts +4 -4
  14. package/src/l3/ast.ts +65 -21
  15. package/src/l3/basecse.ts +48 -28
  16. package/src/l3/coalesce.ts +9 -9
  17. package/src/l3/gates.ts +75 -1
  18. package/src/l3/hoist.ts +1 -1
  19. package/src/l3/homesplit.ts +13 -13
  20. package/src/l3/initfirst.ts +3 -3
  21. package/src/l3/inlinebase.ts +16 -16
  22. package/src/l3/mentions.ts +68 -5
  23. package/src/l3/mulfirst.ts +3 -3
  24. package/src/l3/nearbase.ts +4 -4
  25. package/src/l3/offmember.ts +5 -5
  26. package/src/l3/parkfirst.ts +6 -6
  27. package/src/l3/pollguard.ts +3 -3
  28. package/src/l3/ptrfield.ts +4 -4
  29. package/src/l3/regspell.ts +8 -8
  30. package/src/l3/reindex.ts +22 -17
  31. package/src/l3/scopebase.ts +28 -25
  32. package/src/l3/sinkinit.ts +7 -7
  33. package/src/l3/slotorder.ts +3 -3
  34. package/src/l3/storage.ts +1 -1
  35. package/src/l3/tailmerge.ts +2 -2
  36. package/src/l3/typing.ts +3 -3
  37. package/src/l3/unmerge.ts +483 -59
  38. package/src/l3/unreduce.ts +13 -13
  39. package/src/l3/volatileptr.ts +11 -11
  40. package/src/l3/volatileval.ts +11 -11
  41. package/src/l3/volstore.ts +16 -16
  42. package/src/l3/zerosub.ts +6 -6
  43. package/src/pattern/engine.ts +4 -4
  44. package/src/pipeline.ts +17 -5
  45. package/src/proto.ts +2 -2
  46. package/src/raise/const.ts +203 -3
  47. package/src/raise/divpow2.ts +2 -2
  48. package/src/raise/extscale.ts +342 -0
  49. package/src/raise/globalshape.ts +32 -12
  50. package/src/raise/gvn.ts +2 -2
  51. package/src/raise/magicdiv.ts +2 -2
  52. package/src/raise/memberarrays.ts +4 -4
  53. package/src/raise/narrowlocal.ts +18 -2
  54. package/src/raise/paramwidth.ts +24 -2
  55. package/src/raise/pre-recovery.ts +90 -25
  56. package/src/raise/retsink.ts +381 -15
  57. package/src/raise/shortcircuit.ts +595 -34
  58. package/src/raise/structs.ts +4 -4
  59. package/src/raise/tailsink.ts +126 -0
  60. package/src/rank-declare.ts +4 -4
  61. package/src/{rank-axes.ts → rank-variations.ts} +319 -189
  62. package/src/rank.ts +1148 -803
  63. package/src/structure/analysis.ts +87 -90
  64. package/src/structure/bitfields.ts +130 -30
  65. package/src/structure/globalaccess.ts +30 -4
  66. package/src/structure/namecoalesce.ts +32 -13
  67. package/src/structure/structure.ts +1415 -200
  68. package/src/structure/switch-recover.ts +100 -7
  69. package/src/symbols.ts +127 -6
  70. package/src/target.ts +155 -35
  71. package/src/trace.ts +1 -1
  72. package/src/variation-definitions.ts +1540 -0
  73. package/src/variation-gates.ts +89 -0
  74. package/src/variation-tokens.ts +355 -0
@@ -40,6 +40,7 @@
40
40
  // is NEGATABLE whenever the orientation inverts it — an icmp by opcode swap or a `logic_and`/`logic_or` by
41
41
  // De Morgan, both via `negateCondOps`, which the control-flow form below shares. Any deviation falls
42
42
  // through untouched (a miss, never a miscompile).
43
+ import { disjointConstSlots } from '../ir/alias';
43
44
  import {
44
45
  Block,
45
46
  Fn,
@@ -47,6 +48,7 @@ import {
47
48
  Successor,
48
49
  Value,
49
50
  defOpMap,
51
+ dominators,
50
52
  foldWriteOrder,
51
53
  forwardingTarget,
52
54
  mkOp,
@@ -54,9 +56,11 @@ import {
54
56
  predecessors,
55
57
  reachableBlocks,
56
58
  replaceAllUsesWith,
59
+ successorsOf,
57
60
  } from '../ir/core';
58
- import { HOIST_UNSAFE_OPS, NEGATED_ICMP } from '../ir/opcodes';
61
+ import { EFFECTFUL_OPS, HOIST_UNSAFE_OPS, NEGATED_ICMP, ORDER_SENSITIVE_OPS } from '../ir/opcodes';
59
62
  import { T } from '../ir/types';
63
+ import { type Gate, firstRejection } from '../l3/gates';
60
64
 
61
65
  const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
62
66
 
@@ -328,13 +332,13 @@ export function recognizeShortCircuit(fn: Fn): boolean {
328
332
  // 6 rows, protects 2 (`pokeemerald:IsStringLengthAtLeast`,
329
333
  // `pokeemerald:TrySetCantSelectMoveBattleScript`), and on the other 4 the published winner
330
334
  // folds THROUGH it — `kleod:CheckWorldCompletion`'s refused site is `v5 == 3 || v5 == 5` on an
331
- // ordinary inner-loop counter with no dispatch region near it. It is the axis, not the clause,
335
+ // ordinary inner-loop counter with no dispatch region near it. It is the variation, not the clause,
332
336
  // that keeps those 4. A structural discriminator is L1-visible and would be strictly better —
333
337
  // is the shared block the entry of a region with dispatch-shaped in-edges, is the scrutinee
334
338
  // defined by the enclosing loop header — and is UNBUILT.
335
339
  // The relayed clause below is a different statement (see its own note: a blunt proxy that
336
340
  // fires on an ordinary loop counter), it has NO inhabitant anywhere in the benchmark, and a
337
- // candidate born there would carry a `/connective` label for a fold that answers
341
+ // candidate born there would carry a `/connective` variation for a fold that answers
338
342
  // no connective-vs-tree question. It stays absolute.
339
343
  // - the shared block was reached through a RELAY, and either test's scrutinee is compared against
340
344
  // constants more than once in the function. This one is ABSOLUTE — `foldTreeOwned` does not
@@ -360,11 +364,24 @@ export function recognizeShortCircuit(fn: Fn): boolean {
360
364
  // (`af:adds:ido7.1` and the `divv`/`gcd`/`modv` rows).
361
365
  // - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
362
366
  // would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
363
- // - a value defined in ^g is used outside ^g, or used more than once. Then the structurer
364
- // MATERIALIZES it into a local, which renders as a statement BEFORE the `if` — turning `b`'s
365
- // conditional computation into an unconditional one. Single-use-and-local is precisely the
366
- // shape analysis.ts inlines into the connective's right operand, where C's own short-circuit
367
+ // - a value defined in ^g is used more than once inside ^g. Then the structurer MATERIALIZES it
368
+ // into a local, which renders as a statement BEFORE the `if` — turning `b`'s conditional
369
+ // computation into an unconditional one. Single-use-and-local is precisely the shape
370
+ // analysis.ts inlines into the connective's right operand, where C's own short-circuit
367
371
  // re-guards it. This is what keeps a load in `b` from being hoisted across the guard in `a`.
372
+ // - a value defined in ^g is ALSO read past ^g, and a rule in `ARM_REREAD_GATES` refuses
373
+ // re-deriving it in the arm. A read past ^g is otherwise not a refusal: on verified IR it can
374
+ // only sit under the arm ^g's non-shared edge enters (^g dominates nothing else — the shared
375
+ // block is reached from ^h too), so the arm RE-DERIVES it (`armRereadCone`) and ^g keeps only
376
+ // its own use. Refusing every such read splits the condition into a nest whose shared block
377
+ // the structurer duplicates into both negative branches — `synthetic:ladder5` and
378
+ // `synthetic:ladidx2`. The two
379
+ // are different evidence: `ladidx2` is `if (a && (p->f & 0x7F) == 0x7F) { p->f &= 0x80; }`,
380
+ // which names `p->f` twice while agbcc reads it once and carries the register into the arm,
381
+ // so its copy is a LOAD; `ladder5`'s arm reuses an ADDRESS its test computed, a
382
+ // pure cone analysis.ts re-derives at every use anyway. Two of the table's rules are sound
383
+ // (the copy must not run on a path the original did not); the rest are fidelity, and say
384
+ // which bytes they protect.
368
385
  // - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
369
386
  // so it can only carry one argument list; picking either would silently drop the other path's
370
387
  // phi input.
@@ -396,8 +413,8 @@ export function recognizeShortCircuit(fn: Fn): boolean {
396
413
  // transfer the number to yet either — instrumenting the value form over the whole benchmark
397
414
  // under both lift configurations counts 6 folds per configuration, every head a single icmp.
398
415
  //
399
- // The `/connective` LIFT AXIS is a separate question from the default lift, and is unwidened:
400
- // `onTreeOwned` below is what tells rank.ts the axis exists for a row, and this check sits ABOVE
416
+ // The `/connective` LIFT VARIATION is a separate question from the default lift, and is unwidened:
417
+ // `onTreeOwned` below is what tells rank.ts the variation exists for a row, and this check sits ABOVE
401
418
  // it. Over the whole benchmark under BOTH configurations rank.ts lifts with (`foldTreeOwned`
402
419
  // false and true), against the same rows with the connective case ablated: the recovered IR
403
420
  // moves on the same 2 rows under each, `onTreeOwned` fires on the same rows either way, and
@@ -412,26 +429,28 @@ export function recognizeShortCircuit(fn: Fn): boolean {
412
429
  // source order, so which was written is recorded in the branch senses. This rewrite keeps ^h's
413
430
  // unchanged successor slot, so the connective comes out in the orientation those senses spell, and
414
431
  // which of the two that is depends on the branch RANGE below, not on the source. Reaching the
415
- // other is `negateCond`'s job (l3/ast.ts distributes `!(a && b)`), and rank.ts's `/flip-join` axis
416
- // is what asks for it on a RECONVERGING if — the default spells the layout reading and the axis
432
+ // other is `negateCond`'s job (l3/ast.ts distributes `!(a && b)`), and rank.ts's `/flip-join` variation
433
+ // is what asks for it on a RECONVERGING if — the default spells the layout reading and the variation
417
434
  // spells its dual, so both orientations are compiled and the differ picks (synthetic:ifand_near
418
- // matches at the default, synthetic:ifor_near on the axis).
435
+ // matches at the default, synthetic:ifor_near on the variation).
419
436
  //
420
437
  // What neither reaches is the MIXED spelling. `negateJoinedBranchSense` is a per-FUNCTION boolean,
421
- // so the axis negates every joined `if` at once — and of the 28 real rows carrying the
438
+ // so the variation negates every joined `if` at once — and of the 28 real rows carrying the
422
439
  // `short-circuit` tag, 16 hold two or more TWO-ARMED ifs (counted by `else`, which is what the
423
- // axis's own `thenS.length && elseS.length` gate needs) and 12 hold two or more conditions
440
+ // variation's own `thenS.length && elseS.length` gate needs) and 12 hold two or more conditions
424
441
  // carrying a connective. TWO-ARMED is the count that matters: both sense booleans exclude a
425
442
  // one-armed `if` by construction, so a tally of `if (` of any kind is the wrong denominator.
426
- // A per-SITE negation is the open lever; a gate on whether to ENUMERATE the axis does not reach
427
- // it, and removes a spelling the differ would referee.
443
+ // The per-SITE negation is `/site-sense` (rank-variations.ts), which reads the orientation this fold
444
+ // stamps on the fused branch (`scSharedOnFall`, below) instead of the per-function boolean. A gate
445
+ // on whether to ENUMERATE the variation does not reach the mixed spelling and removes one the differ
446
+ // would referee.
428
447
  //
429
448
  // The De Morgan negation below forecloses a third spelling, at a measured price: it DISTRIBUTES, so
430
449
  // the leaves come out negated (`a || (!b && !c)`) and `a || !(b || c)` has no
431
450
  // candidate — the IR has no `logic_not` to build one from (ir/opcodes.ts). Compiled both ways on
432
451
  // the `a || (b && c)` guard shape at agbcc's default flags, the two source spellings assemble to
433
452
  // the same bytes (12/12 rows, score 0), so the foreclosure costs nothing here. A shape that ever
434
- // separated them would be a new axis, not a bug in this fold.
453
+ // separated them would be a new variation, not a bug in this fold.
435
454
  //
436
455
  // WHICH slot ^g lands in is decided by the asm's branch POLARITY, and on Thumb the branch RANGE
437
456
  // decides the polarity — so the same source `&&` reaches this pass two different ways:
@@ -462,11 +481,35 @@ export function recognizeShortCircuit(fn: Fn): boolean {
462
481
  // as a node stamp (the `#144` `Expr.baseOrdered` shape) would hand every site of such a function
463
482
  // one answer and reach exactly the two configurations `negateJoinedBranchSense` already reaches.
464
483
  //
484
+ // WHAT IS THE CARRIER is the OTHER boolean this loop computes — whether the SHARED block was
485
+ // reached from ^g by its branch or by falling into it — and the fold stamps it as
486
+ // `scSharedOnFall`. It separates every site the sources above wrote as a dual from every site
487
+ // they did not, at constant branch range, because it reads the source's connective rather than
488
+ // the range: an `&&` sends every failing test AWAY to the shared block, an `||` falls into it.
489
+ //
490
+ // IT IS HALF THE CARRIER. Which SOURCE arm the shared block is only names the spelling once you
491
+ // also know which SUCCESSOR SLOT it lands in here, and the slot is `gIsFall` — stamped beside it
492
+ // as `scSharedIsTaken`. `gIsFall` false puts the shared arm in the FALL slot, which happens at the
493
+ // long-branch `&&` AND at every CHAINED fold, since an inner fold leaves the head's taken edge
494
+ // pointing at the next test. There the source's `then` is in the taken slot however ^g reached the
495
+ // shared block, so BOTH values of `scSharedOnFall` read POSITIVE — measured, not derived: the two
496
+ // inhabited layouts stamp opposite `scSharedOnFall` (`synthetic:chainsense` false,
497
+ // `synthetic:ifand_far` true) and want the same spelling, because the long branch is exactly the
498
+ // layout that falsifies the source-order premise ON that stamp.
499
+ //
500
+ // AND THAT IS STILL NOT ALL OF IT, because the long branch INVERTS the last test and so moves the
501
+ // shared arm between slots: a long `||` puts it back in the TAKEN slot and stamps the pair a short
502
+ // `&&` stamps (`synthetic:ifor_far` against `synthetic:ifand_near`, measured from their own asm),
503
+ // while wanting the opposite spelling. The third stamp is `scEdgeRelayed`, the trampoline the
504
+ // inversion leaves on one of the two edges — see its own note at the stamp. The table the consumer
505
+ // reads is `structure.ts`'s `senseFromFoldEvidence` site default, and one of its cells is
506
+ // undecided by all three facts (`kleod:CheckWorldCompletion:agbcc`, two sites, opposite senses).
507
+ //
465
508
  // Every refusal falls through untouched — a miss, never a miscompile.
466
509
  /** Per-call options for `recognizeBranchShortCircuit` — the tree-ownership refusal's two ends. */
467
510
  export interface BranchShortCircuitOptions {
468
511
  /** Take the fold at a site the PAIRWISE comparison-tree refusal owns, spelling the connective
469
- * where the default leaves the tree for switch-recover.ts. rank.ts's `/connective` axis; see the
512
+ * where the default leaves the tree for switch-recover.ts. rank.ts's `/connective` variation; see the
470
513
  * REFUSALS note. It widens the SHAPE the fold accepts and nothing about what the fold may move —
471
514
  * every other refusal still applies, the RELAYED clause included.
472
515
  *
@@ -479,14 +522,22 @@ export interface BranchShortCircuitOptions {
479
522
  * boolean, where a per-site fork would be 1024×. That is why the boolean, not an oversight. */
480
523
  foldTreeOwned?: boolean;
481
524
  /** Called at each site the pairwise tree-ownership refusal is the ONE thing stopping the fold —
482
- * how rank.ts learns the axis has an inhabitant here without re-running the matcher. Asked LAST,
525
+ * how rank.ts learns the variation has an inhabitant here without re-running the matcher. Asked LAST,
483
526
  * after `sameArgs` and the negatability check, so a report means a `/connective` candidate that
484
527
  * differs from its sibling: reporting a refusal merely REACHED would double the row's whole
485
528
  * candidate cross for a lift that produces duplicates the dedup collapses. (Its sibling gate
486
- * `hasSetupArgsNarrowing` asks the same question the same way — does the lever CHANGE anything.)
529
+ * `hasSetupArgsNarrowing` asks the same question the same way — does the variation CHANGE anything.)
487
530
  * The pass re-scans after every rewrite, so one site can report more than once; read it as a
488
531
  * boolean. */
489
532
  onTreeOwned?: () => void;
533
+ /** The RE-READ admission's refusals — {@link ARM_REREAD_GATES} when absent. A parameter so a
534
+ * census can hand in `tallying(ARM_REREAD_GATES).gates` and a test can ablate one rule; the
535
+ * census does, through `bench gates --pass arm-reread` (apps/benchmark/src/run/gate-census.ts). */
536
+ armReread?: readonly Gate<ArmRereadSite>[];
537
+ /** The TARGET's `compilerBehaviors.reloadsLocalReread` (target.ts), which one conjunct of one rule
538
+ * in {@link ARM_REREAD_GATES} reads — `read-behind-effect`. Threaded by raise/pre-recovery.ts from
539
+ * the target it already holds, the way `narrowlocal`'s `hoistsSingleSetArm` is. Absent ⇒ false. */
540
+ reloadsLocalReread?: boolean;
490
541
  }
491
542
 
492
543
  export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOptions = {}): boolean {
@@ -525,17 +576,11 @@ export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOpti
525
576
  if (gTaken.block === gFall.block) {
526
577
  continue;
527
578
  }
528
- // ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
529
- // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
530
- // short circuit.
579
+ // ^g's body must be pure see the REFUSALS note.
531
580
  // HOIST_UNSAFE_OPS includes `opaque`: an instruction asmlift could not model, and moving it
532
581
  // out of the arm that guards it is the reordering this refuses. Loud either way today — a
533
582
  // decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under `annotate`.
534
- const body = g.ops.slice(0, -1);
535
- if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
536
- continue;
537
- }
538
- if (!definedValuesStayLocal(fn, g)) {
583
+ if (g.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
539
584
  continue;
540
585
  }
541
586
  // Which of ^g's edges rejoins ^h's other successor? That is the shared block. A DIRECT edge
@@ -579,6 +624,17 @@ export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOpti
579
624
  if (wantEdge !== gTaken && !negation) {
580
625
  continue;
581
626
  }
627
+ // Every value ^g defines is consumed only by ^g itself, once, or re-derived in the arm —
628
+ // see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of
629
+ // the short circuit. Asked AFTER `sameArgs` and the negatability check, so a census of
630
+ // `ARM_REREAD_GATES` counts only sites nothing cheaper refuses — above `sameArgs` it would
631
+ // credit `read-behind-effect` with void functions whose shared edges carry different dead
632
+ // `r0` values. The verdict is a conjunction of pure tests, so the order moves no fold. And
633
+ // BEFORE tree ownership, so `onTreeOwned` keeps its meaning — the one thing in the way.
634
+ const reread = armRereadCone(fn, g, otherEdge.block, preds, opts);
635
+ if (!reread || !definedValuesStayLocal(fn, g, reread)) {
636
+ continue;
637
+ }
582
638
  // LAST refusal, so `onTreeOwned` reports a site where tree ownership is the ONE thing in the
583
639
  // way — which is why the negatability check stays above it even though it MINTS the negated
584
640
  // cone (up to NEGATE_BUDGET ops) and discards it whenever this gate refuses. That discard is
@@ -591,6 +647,8 @@ export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOpti
591
647
  continue;
592
648
  }
593
649
  }
650
+ rereadInArm(fn, g, otherEdge.block, reread);
651
+ const body = g.ops.slice(0, -1);
594
652
  const second = negation ? negation.result : c2;
595
653
  const negated: Op[] = negation ? negation.ops : [];
596
654
  const res = mkValue(T.unk(32));
@@ -601,7 +659,69 @@ export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOpti
601
659
  // ^g's body moves ahead of ^h's terminator; ^h keeps the successor SLOT that did not change
602
660
  // (taken=shared for `||`, taken=other for `&&`), so the frontend's branch sense survives.
603
661
  h.ops.splice(h.ops.length - 1, 1, ...body, ...negated, connective, {
604
- ...mkOp('cond_br', { operands: [res] }),
662
+ // THE ORIENTATION EVIDENCE, kept because only this pass can see it. `scSharedOnFall`
663
+ // says the SHARED block — the arm both tests reach — was reached from ^g by
664
+ // FALL-THROUGH rather than by ^g's branch. gcc lays a condition's arms out in source
665
+ // order, so an `&&` sends every failing test AWAY to the shared block (the source's
666
+ // `else`) while an `||` lets the last test FALL INTO it (the source's `then`) — which is
667
+ // the one thing that separates `if (a && b) X else Y` from its dual `if (!a || !b) Y
668
+ // else X`, two different objects that fold to the same connective and the same successor
669
+ // slots. Consumed at L3 by `StructureOptions.senseFromFoldEvidence`.
670
+ //
671
+ // `gIsFall` is NOT that separator and is stamped for a different reason. It reads the
672
+ // branch RANGE: over `synthetic:joinsense` (two sites, opposite source connectives) and
673
+ // `synthetic:mixsense` (four sites, two inverted) it is TRUE at every site of both,
674
+ // where `scSharedOnFall` separates them. What it is, is the SLOT — the successor order
675
+ // below is `gIsFall`'s — so `scSharedIsTaken` says where the shared arm went and
676
+ // `scSharedOnFall` says which source arm it is. The shared arm in the FALL slot means the
677
+ // taken slot holds the source's `then` whatever `scSharedOnFall` reads, so only the TAKEN
678
+ // quadrants can negate; `structure.ts` owns that table and the layout premise under it.
679
+ //
680
+ // `scEdgeRelayed` — the THIRD fact, and the only one of the three that is neither the
681
+ // source's connective nor a slot. It says a long-branch TRAMPOLINE sat on one of this
682
+ // fold's two edges: agbcc inverts a conditional it cannot reach in ±256 bytes and leaves
683
+ // a `br`-only block behind, which is exactly the inversion that breaks `scSharedOnFall`'s
684
+ // source-order premise. Measured on the four orientation rows, lifted from their own
685
+ // compiled asm (`ONLY=synthetic:<row>:agbcc` through the probe in the round's ledger):
686
+ //
687
+ // row source onFall isTaken relayed /site-sense spells
688
+ // ifand_near a && b false true false a && b ✅
689
+ // ifor_near a || b true true false a || b ✅
690
+ // ifand_far a && b true false true a && b ✅
691
+ // ifor_far a || b false true TRUE its DUAL ❌ without this stamp
692
+ //
693
+ // `ifand_near` and `ifor_far` stamp the IDENTICAL pair and want OPPOSITE spellings, so no
694
+ // two of these booleans can decide the site. The relay is what separates them, and
695
+ // `synthetic:ifor_far` is the only row whose spelling it decides — the row exists for this
696
+ // stamp, because its SCORE cannot referee it (MATCH 0/139 on `/flip-join`, like
697
+ // `ifand_far`'s 0/140).
698
+ //
699
+ // A PROXY, and named as one: what decides the spelling is the branch INVERSION, and what
700
+ // is observable here is the trampoline the inversion leaves. They coincide on every input
701
+ // measured, and a relay arriving from some other cause at a taken-slot site would read as
702
+ // an inversion that did not happen. The `&&` long branch relays the SHARED edge and the
703
+ // `||` long branch the OTHER one — measured on `ifand_far` and `ifor_far`, and both
704
+ // layouts pinned in `test/branch-shortcircuit.test.ts` — which is why both are asked.
705
+ //
706
+ // POSITIONAL, unlike its two neighbours: `scSharedOnFall` and `scEdgeRelayed` name arms
707
+ // and survive anything, while `scSharedIsTaken` names a SUCCESSOR SLOT of the very op it
708
+ // rides on. THE INVARIANT IT NEEDS IS NOT "nothing assigns `.successors`" — something
709
+ // does. It is that no pass between here and `structure` may REORDER a stamped `cond_br`'s
710
+ // successor slots. Downstream of this fold `raiseRecovered` (pipeline.ts) runs
711
+ // `sinkReturns` and `foldEmptyLatches`; `raise/latch.ts` holds the one successor rewrite in
712
+ // `packages/core/src` (grep `successors[i] =` / `successors.push|splice`) —
713
+ // `op.successors[i] = { block: onward.block, … }`, which repoints a slot IN PLACE at its
714
+ // own index and therefore keeps the stamp true. `sinkReturns` only replaces `br`
715
+ // terminators. A pass that canonicalised a stamped `cond_br`'s edges would turn the
716
+ // reading over with no refusal firing, and would have to re-stamp.
717
+ ...mkOp('cond_br', {
718
+ operands: [res],
719
+ attrs: {
720
+ scSharedOnFall: sharedEdge === gFall,
721
+ scSharedIsTaken: gIsFall,
722
+ scEdgeRelayed: throughRelay || forwardingTarget(otherEdge.block) !== otherEdge.block,
723
+ },
724
+ }),
605
725
  successors: gIsFall
606
726
  ? [
607
727
  { block: sharedEdge.block, args: [...sharedEdge.args] },
@@ -655,7 +775,7 @@ export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOpti
655
775
  * a minted count is always ODD: 1, 3, 5, 7, 9. At 8 a FOUR-clause inner conjunct (7 ops) folds and
656
776
  * a FIVE-clause one (9 ops) does not; 7 and 8 are therefore one gate, and so is 9 over everything
657
777
  * measured here, the deepest cone in the 2,047 lifted klonoa+sa3 functions minting 5 (17
658
- * connective negations, 0 refused). Clause COUNT is not the axis either: a FLAT `a || b || c || …`
778
+ * connective negations, 0 refused). Clause COUNT is not the measure either: a FLAT `a || b || c || …`
659
779
  * chain pays nothing at all, because ^g's condition is never a connective in that shape.
660
780
  *
661
781
  * The bound is on ops KEPT, and the frontier is a NODE COUNT — not a shape. Pinned as such rather
@@ -803,32 +923,473 @@ function constTestScrutinee(defs: Map<Value, Op>, c: Value): Value | null {
803
923
  return xc === yc ? null : xc ? y : x;
804
924
  }
805
925
 
806
- /** True when every value `g` defines is read at most once, and any read is inside `g`.
926
+ /** `armRereadCone`'s answer: the ops the arm copies, and the originals that then leave ^g. */
927
+ interface ArmReread {
928
+ copy: ReadonlySet<Op>;
929
+ drop: ReadonlySet<Op>;
930
+ }
931
+
932
+ const readsOf = (op: Op): Value[] => [...op.operands, ...op.successors.flatMap((s) => s.args)];
933
+
934
+ /** What the arm must RE-DERIVE for the fold to go ahead: `copy` is every `g`-defined value read
935
+ * outside `g` with the part of its operand cone `g` computes, and `drop` the originals in `copy`
936
+ * nothing left in `g` reads — the ones that would MOVE rather than duplicate. Both empty when
937
+ * nothing escapes — then no gate is asked, because there is nothing to re-derive; null when a
938
+ * gate in `opts.armReread` (default {@link ARM_REREAD_GATES}) refuses the site.
939
+ *
940
+ * The copy is exact at the arm's head because nothing runs between: `g`'s body is pure (its
941
+ * caller refuses anything in HOIST_UNSAFE_OPS), so a load copied there reads what `g`'s own load
942
+ * read, and `g` is the arm's ONLY predecessor, so the copy runs exactly when the original's
943
+ * value would have been live there. On verified IR that also places every escaping read under
944
+ * the arm: `g` dominates nothing else, because the shared block is reached from ^h directly.
945
+ * That is the MEANING half, and it holds however analysis.ts spells the copy. It is not the bytes
946
+ * half — see ARM_REREAD_GATES' `read-behind-effect`. */
947
+ function armRereadCone(
948
+ fn: Fn,
949
+ g: Block,
950
+ arm: Block,
951
+ preds: Map<Block, Block[]>,
952
+ opts: BranchShortCircuitOptions,
953
+ ): ArmReread | null {
954
+ const defOf = new Map<Value, Op>();
955
+ for (const op of g.ops) {
956
+ for (const r of op.results) {
957
+ defOf.set(r, op);
958
+ }
959
+ }
960
+ const readers = fn.blocks
961
+ .filter((b) => b !== g)
962
+ .flatMap((b) => b.ops.filter((op) => readsOf(op).some((v) => defOf.has(v))));
963
+ if (readers.length === 0) {
964
+ return { copy: new Set(), drop: new Set() };
965
+ }
966
+ const site = armRereadSite(fn, g, arm, preds, defOf, readers, opts.reloadsLocalReread === true);
967
+ return firstRejection(opts.armReread ?? ARM_REREAD_GATES, site) === null
968
+ ? { copy: site.copy, drop: site.drop }
969
+ : null;
970
+ }
971
+
972
+ /** One site the RE-READ admission judges: a condition block ^g that defines a value something past
973
+ * it reads, and the arm ^g's non-shared edge enters — where that value would be re-derived.
974
+ *
975
+ * THE FIELDS ARE LAZY. `copy` walks the operand cone and `drop` the whole of ^g, and the two
976
+ * cheapest gates refuse without asking either. */
977
+ export interface ArmRereadSite {
978
+ readonly fn: Fn;
979
+ /** ^g, the second condition block the fold would merge into ^h */
980
+ readonly g: Block;
981
+ /** the block ^g's non-shared edge enters — where the copy goes */
982
+ readonly arm: Block;
983
+ readonly preds: ReadonlyMap<Block, readonly Block[]>;
984
+ /** the op in ^g defining each value ^g defines */
985
+ readonly defOf: ReadonlyMap<Value, Op>;
986
+ /** every op outside ^g that reads a value ^g defines — never empty (see `armRereadCone`) */
987
+ readonly readers: readonly Op[];
988
+ /** those values and the part of their operand cone ^g computes: what the arm would copy */
989
+ readonly copy: ReadonlySet<Op>;
990
+ /** the originals in `copy` nothing left in ^g reads — deleted from ^g, so they MOVE to the arm.
991
+ * Reverse order, so an original that fed only another dropped original goes too. */
992
+ readonly drop: ReadonlySet<Op>;
993
+ /** the target's compiler loads a LOCAL initialised with a read its test already performed a
994
+ * second time (`BranchShortCircuitOptions.reloadsLocalReread`) */
995
+ readonly targetReloadsLocalReread: boolean;
996
+ }
997
+
998
+ function armRereadSite(
999
+ fn: Fn,
1000
+ g: Block,
1001
+ arm: Block,
1002
+ preds: Map<Block, Block[]>,
1003
+ defOf: Map<Value, Op>,
1004
+ readers: Op[],
1005
+ targetReloadsLocalReread: boolean,
1006
+ ): ArmRereadSite {
1007
+ let copy: Set<Op> | undefined;
1008
+ let drop: Set<Op> | undefined;
1009
+ const coneOf = (): Set<Op> => {
1010
+ const out = new Set<Op>();
1011
+ for (const work = readers.flatMap(readsOf).filter((v) => defOf.has(v)); work.length;) {
1012
+ const d = defOf.get(work.pop()!);
1013
+ if (d && !out.has(d)) {
1014
+ out.add(d);
1015
+ work.push(...d.operands);
1016
+ }
1017
+ }
1018
+ return out;
1019
+ };
1020
+ const dropOf = (c: ReadonlySet<Op>): Set<Op> => {
1021
+ const out = new Set<Op>();
1022
+ const live = new Set<Value>();
1023
+ for (let i = g.ops.length - 1; i >= 0; i--) {
1024
+ const op = g.ops[i];
1025
+ if (c.has(op) && op.results.every((r) => !live.has(r))) {
1026
+ out.add(op);
1027
+ } else {
1028
+ readsOf(op).forEach((v) => live.add(v));
1029
+ }
1030
+ }
1031
+ return out;
1032
+ };
1033
+ return {
1034
+ fn,
1035
+ g,
1036
+ arm,
1037
+ preds,
1038
+ defOf,
1039
+ readers,
1040
+ targetReloadsLocalReread,
1041
+ get copy() {
1042
+ return (copy ??= coneOf());
1043
+ },
1044
+ get drop() {
1045
+ return (drop ??= dropOf(this.copy));
1046
+ },
1047
+ };
1048
+ }
1049
+
1050
+ /** Whether leaving ^g for `arm` leaves a natural loop ^g is in — some back-edge `t→h` (h dominates
1051
+ * t) whose body, h plus everything reaching t without passing h, holds ^g and not the arm. */
1052
+ function leavesALoop(fn: Fn, g: Block, arm: Block, preds: ReadonlyMap<Block, readonly Block[]>): boolean {
1053
+ const dom = dominators(fn);
1054
+ for (const t of fn.blocks) {
1055
+ for (const h of successorsOf(t)) {
1056
+ if (!dom.get(t)?.has(h)) {
1057
+ continue;
1058
+ }
1059
+ const body = new Set<Block>([h]);
1060
+ for (const work = [t]; work.length;) {
1061
+ const b = work.pop()!;
1062
+ if (!body.has(b)) {
1063
+ body.add(b);
1064
+ work.push(...(preds.get(b) ?? []));
1065
+ }
1066
+ }
1067
+ if (body.has(g) && !body.has(arm)) {
1068
+ return true;
1069
+ }
1070
+ }
1071
+ }
1072
+ return false;
1073
+ }
1074
+
1075
+ /** Whether analysis.ts would spell the arm's copy of a READ as a LOCAL rather than inline — a
1076
+ * MIRROR of analysis.ts's placement and barrier rules (structure/analysis.ts: `emitPos`, the
1077
+ * single-render `isBarrier`, the multi-render `isWrite`), not a coarser stand-in for them. It is
1078
+ * separate code, so the two can drift: the DIFFERENTIAL in the matching suite
1079
+ * (shortcircuit-branch.test.ts) is what holds them together. The copy sits at the arm's head, so
1080
+ * every question is about the arm block.
1081
+ *
1082
+ * Only the copied READS are asked about. An address member of the cone (`add`, `gaddr`) re-derives
1083
+ * at every use and carries nothing, however the address is spelled — so `p[i]` and `p[5]` get the
1084
+ * same answer. A read answers yes when:
1085
+ *
1086
+ * - it renders nowhere single: some PURE value between it and a statement has two consumers, so
1087
+ * `emitPos` is null and analysis.ts materializes it whatever stands in the way
1088
+ * (`p[2] = p[1] & 0x80; p[3] = p[1] & 0x80;` — one `and`, two stores);
1089
+ * - it has MORE THAN ONE direct reader, and an effect precedes one of their render positions —
1090
+ * the multi-render rule, `isWrite = EFFECTFUL_OPS`, which exempts nothing;
1091
+ * - it has ONE, and an effect precedes its render position that analysis.ts's `isBarrier`
1092
+ * counts: any `astore`/`opaque`, a `call` that is not rendered inside that same statement,
1093
+ * and a `store` UNLESS it is to a provably disjoint slot of the read's own base
1094
+ * (`disjointConstSlots`, ir/alias.ts — the helper `isBarrier` calls).
1095
+ *
1096
+ * A reader in any block other than the arm answers yes — the copy then outlives the arm's first
1097
+ * block. What this does NOT mirror, and which way each gap errs, is ARM_REREAD_GATES' RESIDUE. */
1098
+ function readHeldAcrossEffect(c: ArmRereadSite): boolean {
1099
+ const reads = [...c.copy].filter((op) => ORDER_SENSITIVE_OPS.has(op.opcode));
1100
+ if (reads.length === 0) {
1101
+ return false;
1102
+ }
1103
+ // The arm as analysis.ts will see it: the copied cone at its head (position 0 — nothing runs
1104
+ // before it), then the arm's own ops. A copied op's consumers are the arm ops and the OTHER copied
1105
+ // ops reading it, so a read feeding a copied `add` the arm reads renders where that `add` does.
1106
+ const cone = c.g.ops.filter((op) => c.copy.has(op));
1107
+ const posOf = (op: Op): number => Math.max(0, c.arm.ops.indexOf(op));
1108
+ const escapes = (vs: readonly Value[]): boolean =>
1109
+ c.fn.blocks.some((b) => b !== c.g && b !== c.arm && b.ops.some((op) => readsOf(op).some((v) => vs.includes(v))));
1110
+ const consumersOf = (vs: readonly Value[]): Op[] =>
1111
+ [...cone, ...c.arm.ops].filter((op) => readsOf(op).some((v) => vs.includes(v)));
1112
+ // `emitPos`: a statement renders where it stands, a value where its one consumer renders. A pure
1113
+ // value with two consumers renders in both — null. A load or call with two is one analysis.ts
1114
+ // names (a call executes once), so it renders where it stands. A consumer outside the arm leaves
1115
+ // the position unresolved — null, the other-block clause.
1116
+ const at = (op: Op): number | null => {
1117
+ if (op.successors.length > 0 || op.opcode === 'ret' || op.results.length === 0) {
1118
+ return posOf(op);
1119
+ }
1120
+ if (escapes(op.results)) {
1121
+ return null;
1122
+ }
1123
+ const cs = consumersOf(op.results);
1124
+ if (cs.length === 0) {
1125
+ return posOf(op);
1126
+ }
1127
+ if (cs.length === 1) {
1128
+ return at(cs[0]);
1129
+ }
1130
+ return ORDER_SENSITIVE_OPS.has(op.opcode) ? posOf(op) : null;
1131
+ };
1132
+ for (const l of reads) {
1133
+ if (escapes(l.results)) {
1134
+ return true;
1135
+ }
1136
+ const renders = consumersOf(l.results).map(at);
1137
+ if (renders.some((i) => i === null)) {
1138
+ return true;
1139
+ }
1140
+ const multi = renders.length > 1;
1141
+ const bars = (x: Op, pos: number): boolean => {
1142
+ if (!EFFECTFUL_OPS.has(x.opcode)) {
1143
+ return false;
1144
+ }
1145
+ if (multi) {
1146
+ return true;
1147
+ }
1148
+ if (x.opcode === 'store') {
1149
+ return !(l.opcode === 'load' && disjointConstSlots(l, x));
1150
+ }
1151
+ if (x.opcode === 'call') {
1152
+ return at(x) !== pos;
1153
+ }
1154
+ return true;
1155
+ };
1156
+ if (renders.some((pos) => c.arm.ops.slice(0, pos!).some((x) => bars(x, pos!)))) {
1157
+ return true;
1158
+ }
1159
+ }
1160
+ return false;
1161
+ }
1162
+
1163
+ // ── THE RE-READ ADMISSION'S REFUSALS, AS DATA ─────────────────────────────────────────────────
1164
+ //
1165
+ // A table so its refusals can be counted: taken as an OPTIONAL parameter
1166
+ // (`BranchShortCircuitOptions.armReread`), and censused from outside core by
1167
+ // `pnpm bench gates --pass arm-reread`, which swaps this pass's
1168
+ // entry in `PRE_RECOVERY_PASSES` (apps/benchmark/src/run/gate-census.ts). The table is asked only
1169
+ // at a site `sameArgs` and the negatability check have already passed, so a count is its own.
1170
+ //
1171
+ // SOUND is two rules — `entry-arm` and `second-pred` put the copy on a path the original never
1172
+ // ran. The other five are FIDELITY: each refuses a site where re-deriving costs bytes the nest did
1173
+ // not, and a wrong answer there is a miss, never wrong C, because the copy stays under the guard
1174
+ // either way.
1175
+ //
1176
+ // RESIDUE, named because it is not in the table:
1177
+ //
1178
+ // - the USE-COUNT half of `definedValuesStayLocal` — a value ^g reads twice itself is
1179
+ // materialized before the `if` — which judges ^g's own reads rather than this site's
1180
+ // re-derivation, and runs after the table on every fold, escaping or not.
1181
+ // - what `read-behind-effect` does not mirror of analysis.ts, of two kinds that err opposite
1182
+ // ways. (1) `/reread-globals`. Under that STRUCTURE variation analysis.ts lets a store to a
1183
+ // DIFFERENT named global through (`mayWriteGlobal`, ir/alias.ts), so there the copy inlines —
1184
+ // but this pass runs once per LIFT, and rank.ts structures every `/reread-globals` candidate
1185
+ // from a lift it shares with the others (`liftSettings`), so the verdict cannot follow the
1186
+ // variation without a lift of its own. It errs toward REFUSING, at a measured price:
1187
+ // `if (a && (gQ2[1] & 0x7f) == 0x7f) { gK = 1; gQ2[1] &= 0x80; return; } fnB();` keeps a 3/23
1188
+ // nest where the fold matches on that variation (MATCH 0/21 with this rule ablated), and so does the
1189
+ // same arm over a struct global's member; no corpus row. (2) the scopes analysis.ts
1190
+ // materializes a read in for reasons OTHER than a barrier — `liveAcrossLoop`, the address-home
1191
+ // variation, a load fed to a `cond_br` edge. They err toward ADMITTING: a local, under the guard,
1192
+ // one extra load on agbcc; no inhabitant measured. The DIFFERENTIAL in
1193
+ // packages/cli/test/matching/shortcircuit-branch.test.ts is what notices either one growing.
1194
+ //
1195
+ // MEASURED over the whole corpus, every row's default lift and whole enumerated fan with its
1196
+ // symbol map, both tiers (LBG and ProcessInputAndUpdateEntities default lift only): something
1197
+ // escapes ^g at a site on 10 rows. Against main the default source or the fan moves on 6 —
1198
+ // synthetic `ladder4`/`ladder5`/`ladidx1`/`ladidx2`, kleod `CountCollectedGems`, sa3
1199
+ // `EwramFree` — and on no other row; `nestinit` and `ucmp:mwcc_242_81` are refused here and come
1200
+ // out as main did, and `CheckTileCollisionVertical` and `TrySetCantSelectMoveBattleScript` are
1201
+ // refused at every site and come out as main did too — held
1202
+ // there by `loop-exit`, `moves-a-read` and `read-behind-effect`, whose prices outside the corpus
1203
+ // are on each rule. `read-behind-effect`'s target scoping and its analysis.ts mirror move nothing
1204
+ // in the corpus — default source and fan byte-identical on all 784 synthetic and 252 real rows,
1205
+ // and the same verdict at every site — so what they buy is off the corpus, on the rule.
1206
+ export const ARM_REREAD_GATES: readonly Gate<ArmRereadSite>[] = [
1207
+ {
1208
+ id: 'entry-arm',
1209
+ why: 'an arm that is the function’s first block is also reached on entry, where the copy would run too',
1210
+ sound: true,
1211
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the arm is the entry block',
1212
+ rejects: (c) => c.arm === c.fn.blocks[0],
1213
+ },
1214
+ {
1215
+ // With readers present the second predecessor can only be one ^g's copy dominates — a loop
1216
+ // back into the arm, re-running the re-read against memory the loop has since written.
1217
+ id: 'second-pred',
1218
+ why: 'a second way into the arm would run the copy on a path that never ran the original',
1219
+ sound: true,
1220
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the arm has a second predecessor',
1221
+ rejects: (c) => (c.preds.get(c.arm) ?? []).length !== 1,
1222
+ },
1223
+ {
1224
+ // The loop-guard shape, `for (i = 0; i < n; …)` under an `if`: agbcc tests the register it just
1225
+ // set to the induction's start (`mov r3, #0; cmp r3, r5`) and carries that register into the
1226
+ // loop, so ^g's condition reads a VARIABLE the arm goes on to own. A copy re-derives the
1227
+ // constant and the fold hands the outer `if` a `0 < n` loop recovery needed as its own guard
1228
+ // — `synthetic:nestinit:agbcc` MATCH → 6/49.
1229
+ id: 'edge-arg',
1230
+ why: 'a value an edge carries is a variable the arm owns, not an expression it re-reads',
1231
+ sound: false,
1232
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM carries on as an edge ARGUMENT',
1233
+ rejects: (c) => c.readers.some((op) => op.successors.some((s) => s.args.some((v) => c.defOf.has(v)))),
1234
+ },
1235
+ {
1236
+ // A value the SSA builder homed in a frame slot (`fn.slotHomes`) is one the target held in a
1237
+ // DECLARED local and reloads in the arm (`ldr rN, [sp, #k]`). The copy re-derives it instead,
1238
+ // and the copy's result carries no home, so the local and its `[sp, #k]` traffic are spelled
1239
+ // away and the frame order `l3/slotorder.ts` reads loses that name — silently, the failure
1240
+ // `replaceAllUsesWith` (ir/core.ts) ships an equally inhabitant-less guard against. Those are
1241
+ // the bytes it protects. NO INHABITANT in either tier or in any probe written against this
1242
+ // table, and stated rather than argued away: only Thumb stamps homes, and on agbcc a value
1243
+ // spilled in ^g is one live across a call in the arm, which `read-behind-effect` refuses on
1244
+ // its own unless the copy is a pure ADDRESS — the only shape where this rule alone would decide.
1245
+ id: 'slot-home',
1246
+ why: 'the machine kept this value in a stack slot rather than re-deriving it',
1247
+ sound: false,
1248
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the frame homed it',
1249
+ rejects: (c) => [...c.copy].some((op) => op.results.some((r) => c.fn.slotHomes?.has(r))),
1250
+ },
1251
+ {
1252
+ // A loop body's `if (a && … && d) { …; return; }`: the fold fuses the WHOLE condition into the
1253
+ // loop header, whose out-edge is then this early-return arm, and loop recovery reads it as the
1254
+ // loop's exit and declines the latch's (`unrecovered back-edge`). The shape is not this
1255
+ // admission's — the same loop with an arm that re-reads nothing folds the same way and
1256
+ // declines on main too — but this admission is what reached it: a probe of kleod
1257
+ // `CheckTileCollisionVertical`'s shape (not a corpus row) went diff:11/57 → declined, and the
1258
+ // real row's eight `/reread-globals` candidates all threw, which `bench run` does not report.
1259
+ // Refusing the re-read at a loop exit keeps exactly the nest main produced there. No row this
1260
+ // admission matches has its arm outside a loop ^g is in.
1261
+ //
1262
+ // What it protects that no corpus row pins is the ordinary SEARCH LOOP: `for (i = 0; i < 8;
1263
+ // i++) { if (q[i] != 0 && (v = p[i]) > 5) { r[0] = v; return; } } fnB();` matches as the nest
1264
+ // and scores 21/33 fused, and its `p[i] &= 0x80` twin MATCH against 31/37. The first is in the
1265
+ // matching suite (shortcircuit-branch.test.ts), which fails with this rule ablated.
1266
+ id: 'loop-exit',
1267
+ why: "fusing a loop body's whole condition makes this arm the header's exit, which loop recovery misreads",
1268
+ sound: false,
1269
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a value the ARM re-reads, when the arm LEAVES the loop',
1270
+ rejects: (c) => leavesALoop(c.fn, c.g, c.arm, c.preds),
1271
+ },
1272
+ {
1273
+ // `if (a) { v = p->f; if (b) use(v); }`: the target reads `p->f` on BOTH exits of `b`, before
1274
+ // `b`'s own reads; moved, it runs on one, after them. No row this admission matches moves
1275
+ // anything (the census: every fire site's `drop` is empty), and moving the read cost
1276
+ // the probe `if (gA) { v = gP->f[5]; if (gQ == 3) sink(v); }` its MATCH (0/20 → 15/24) and
1277
+ // turned a device-register read into one on one path only. Moving a PURE original stays: it
1278
+ // re-derives at every use whatever this does.
1279
+ id: 'moves-a-read',
1280
+ why: 'the target read it before the second test on both of its exits; moved, it runs on one',
1281
+ sound: false,
1282
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a READ only the arm consumes would move under the second test',
1283
+ rejects: (c) => [...c.drop].some((op) => ORDER_SENSITIVE_OPS.has(op.opcode)),
1284
+ },
1285
+ {
1286
+ // THE BYTES HALF of the copy (see `armRereadCone`), and a claim about ONE compiler. The arm of
1287
+ // `if (a && (p[1] & 0x7f) == 0x7f) { … }` loads `p[1]` again or not by SPELLING — loads of
1288
+ // `p[1]`, compiled at each bench toolchain's flags:
1289
+ //
1290
+ // agbcc ido7.1 gcc2.7.2kmc gcc2.7.2 mwcc_242_81
1291
+ // p[1] &= 0x80; 1 1 1 1 1
1292
+ // q[0] = 5; p[1] &= 0x80; 1 2 2 2 2
1293
+ // p[3] = 5; p[1] &= 0x80; 1 1 2 2 2
1294
+ // fnB(); p[2] = p[1]; 2 2 2 2 2
1295
+ // u8 v = p[1]; p[2] = v; 2 1 1 1 1
1296
+ // u8 v = p[1]; q[0] = 5; p[2] = v; 2 1 1 1 1
1297
+ // u8 v = p[1]; fnB(); p[2] = v; 2 1 1 1 1
1298
+ //
1299
+ // A LOCAL at the arm's head costs agbcc a second load and costs the other four nothing — the
1300
+ // target's `compilerBehaviors.reloadsLocalReread` (target.ts), the conjunct below. analysis.ts
1301
+ // spells the copy inline unless something stands between it and a use, and otherwise
1302
+ // materializes a local at the arm's head: correct, under the guard, and on agbcc a second
1303
+ // load. So on agbcc a read analysis.ts would materialize is refused, and the nest main spells
1304
+ // stays — `{ fnB(); sink(v); }`, `{ sink(v); sink(v); }` and `{ gP->f[3] = 0; sink(v); }` each
1305
+ // match only as the nest. Elsewhere the local IS the target's spelling: on mwcc,
1306
+ // `if (a) { u8 v = p[3]; if ((v & 0x7f) == 0x7f) { fnA(); p[4] = v; return; } } fnB();` was
1307
+ // 3/24 with this rule running there and matches without it.
1308
+ //
1309
+ // WHICH copies analysis.ts materializes is asked with analysis.ts's own rules
1310
+ // (`readHeldAcrossEffect`): only the copied READS, never the address arithmetic beside them,
1311
+ // and a store to a provably disjoint slot is no barrier. The coarser rule — ANY effect bars,
1312
+ // seeded from every copied op — holds 13 agbcc probes at 3 to 8 points that byte-match under
1313
+ // this one (`r->x = 5; r->fl &= 0x80;`, `p[3] = 5; p[1] &= 0x80;`, `p[i] &= 0x80; fnB(); p[i]
1314
+ // = 1;` among them), scores 8 more worse, and scores none of 249 probes over five toolchains
1315
+ // better. What this one does not mirror is the RESIDUE above.
1316
+ id: 'read-behind-effect',
1317
+ why: 'a re-read written as a local costs a second load on a target whose compiler reloads one',
1318
+ sound: false,
1319
+ guardedBy: 'branch-shortcircuit.test.ts: REFUSED: a READ the arm holds across an effect is not re-derived',
1320
+ rejects: (c) => c.targetReloadsLocalReread && readHeldAcrossEffect(c),
1321
+ },
1322
+ ];
1323
+
1324
+ /** Copy `copy` to the head of `arm`, point every read outside `g` at the copies, and delete `drop`.
1325
+ * A dropped original is deleted rather than left for the hoist: the arm now performs it, and a
1326
+ * dead READ left in ^h is one the structurer may still spell (SPELLED_WHEN_DEAD_OPS). */
1327
+ function rereadInArm(fn: Fn, g: Block, arm: Block, { copy, drop }: ArmReread): void {
1328
+ const copyOf = new Map<Value, Value>();
1329
+ const copies = g.ops
1330
+ .filter((op) => copy.has(op))
1331
+ .map((op): Op => ({
1332
+ opcode: op.opcode,
1333
+ operands: op.operands.map((v) => copyOf.get(v) ?? v),
1334
+ results: op.results.map((r) => {
1335
+ const c = mkValue(r.type);
1336
+ copyOf.set(r, c);
1337
+ return c;
1338
+ }),
1339
+ attrs: { ...op.attrs },
1340
+ successors: [],
1341
+ }));
1342
+ const redirect = (v: Value): Value => copyOf.get(v) ?? v;
1343
+ for (const b of fn.blocks) {
1344
+ if (b === g) {
1345
+ continue;
1346
+ }
1347
+ for (const op of b.ops) {
1348
+ op.operands = op.operands.map(redirect);
1349
+ for (const s of op.successors) {
1350
+ s.args = s.args.map(redirect);
1351
+ }
1352
+ }
1353
+ }
1354
+ arm.ops.unshift(...copies);
1355
+ g.ops = g.ops.filter((op) => !drop.has(op));
1356
+ }
1357
+
1358
+ /** True when every value `g` defines is read at most once, and any read is inside `g` or is one
1359
+ * `armRereadCone` answered for — reads outside `g` move to the arm's copy, and reads BY a
1360
+ * dropped original leave with it. The use-count half is the residue ARM_REREAD_GATES names.
807
1361
  *
808
1362
  * The VALUE form above needs no such check, and the asymmetry is real rather than drift: its feeder
809
1363
  * ends in `br M`, so the feeder has no successor of its own to dominate and every value it defines
810
1364
  * is either read in the feeder or carried to `M` as the phi argument the fold consumes. Here ^g
811
1365
  * ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
812
- * escape, and only this check stops it.
1366
+ * escape, and only this check and the arm's copy stop it.
813
1367
  *
814
1368
  * What the two folds genuinely DO share is the `fn.blocks[0]` refusal, and each has a test that
815
1369
  * pins its own half: 'a feeder that is the entry block is not folded away' for the value form, and
816
1370
  * 'the ENTRY block is never folded away' for the branch form. They are deliberately NOT routed
817
1371
  * through one shared `isEntry` helper — a helper enforces nothing, and it is the two tests that
818
1372
  * hold each fold to the refusal. When changing either fold, check the other. */
819
- function definedValuesStayLocal(fn: Fn, g: Block): boolean {
1373
+ function definedValuesStayLocal(fn: Fn, g: Block, { copy, drop }: ArmReread): boolean {
820
1374
  const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
821
1375
  if (defined.size === 0) {
822
1376
  return true;
823
1377
  }
1378
+ const copied = new Set<Value>([...copy].flatMap((op) => op.results));
824
1379
  const uses = new Map<Value, number>();
825
1380
  for (const b of fn.blocks) {
826
1381
  for (const op of b.ops) {
827
- for (const v of [...op.operands, ...op.successors.flatMap((s) => s.args)]) {
1382
+ if (drop.has(op)) {
1383
+ continue;
1384
+ }
1385
+ for (const v of readsOf(op)) {
828
1386
  if (!defined.has(v)) {
829
1387
  continue;
830
1388
  }
831
1389
  if (b !== g) {
1390
+ if (copied.has(v)) {
1391
+ continue; // re-derived in the arm (`rereadInArm`)
1392
+ }
832
1393
  return false; // escapes ^g — the structurer would render it before the `if`
833
1394
  }
834
1395
  uses.set(v, (uses.get(v) ?? 0) + 1);