@asmlift/core 0.5.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.
- package/README.md +22 -16
- package/package.json +1 -1
- package/src/backend/c.ts +1 -0
- package/src/backend/cfamily.ts +270 -171
- package/src/backend/cpp.ts +1 -0
- package/src/backend/pascal.ts +26 -12
- package/src/contracts.ts +243 -39
- package/src/declare.ts +41 -4
- package/src/frontend/mips.ts +11 -0
- package/src/frontend/ppc.ts +43 -7
- package/src/frontend/ssa.ts +404 -29
- package/src/frontend/thumb.ts +2176 -686
- package/src/ir/alias.ts +78 -0
- package/src/ir/bits.ts +75 -0
- package/src/ir/core.ts +345 -2
- package/src/ir/opcodes.ts +176 -21
- package/src/ir/parse.ts +19 -2
- package/src/ir/print.ts +27 -2
- package/src/ir/simplify.ts +190 -3
- package/src/ir/struct-names.ts +42 -0
- package/src/ir/verify.ts +43 -49
- package/src/l3/address.ts +62 -0
- package/src/l3/advance.ts +373 -0
- package/src/l3/argbase.ts +6 -5
- package/src/l3/ast.ts +510 -59
- package/src/l3/basecse.ts +686 -78
- package/src/l3/coalesce.ts +432 -46
- package/src/l3/dce.ts +31 -9
- package/src/l3/gates.ts +96 -1
- package/src/l3/hoist.ts +293 -14
- package/src/l3/homesplit.ts +285 -0
- package/src/l3/initfirst.ts +301 -0
- package/src/l3/inlinebase.ts +193 -0
- package/src/l3/mentions.ts +176 -0
- package/src/l3/mulfirst.ts +42 -0
- package/src/l3/nearbase.ts +152 -0
- package/src/l3/offmember.ts +371 -0
- package/src/l3/parkfirst.ts +96 -0
- package/src/l3/pollguard.ts +154 -0
- package/src/l3/ptrfield.ts +227 -0
- package/src/l3/regspell.ts +114 -89
- package/src/l3/reindex.ts +722 -80
- package/src/l3/scopebase.ts +649 -220
- package/src/l3/sinkinit.ts +40 -0
- package/src/l3/slotorder.ts +123 -0
- package/src/l3/storage.ts +48 -0
- package/src/l3/symbol-refs.ts +41 -8
- package/src/l3/tailmerge.ts +16 -1
- package/src/l3/typing.ts +198 -9
- package/src/l3/unmerge.ts +687 -0
- package/src/l3/unreduce.ts +971 -0
- package/src/l3/volatileptr.ts +207 -0
- package/src/l3/volatileval.ts +130 -0
- package/src/l3/volstore.ts +229 -0
- package/src/l3/zerosub.ts +62 -0
- package/src/pattern/engine.ts +239 -16
- package/src/pipeline.ts +173 -60
- package/src/proto.ts +112 -14
- package/src/raise/arrays.ts +6 -1
- package/src/raise/const.ts +203 -3
- package/src/raise/divpow2.ts +4 -4
- package/src/raise/extscale.ts +342 -0
- package/src/raise/globalshape.ts +1058 -0
- package/src/raise/gvn.ts +33 -18
- package/src/raise/latch.ts +126 -0
- package/src/raise/magicdiv.ts +2 -2
- package/src/raise/memberarrays.ts +594 -0
- package/src/raise/narrow.ts +124 -0
- package/src/raise/narrowlocal.ts +572 -0
- package/src/raise/paramwidth.ts +201 -0
- package/src/raise/pre-recovery.ts +169 -21
- package/src/raise/recover.ts +56 -23
- package/src/raise/retsink.ts +585 -19
- package/src/raise/shortcircuit.ts +1050 -89
- package/src/raise/struct-arrays.ts +19 -2
- package/src/raise/structs.ts +34 -4
- package/src/raise/tailsink.ts +126 -0
- package/src/rank-declare.ts +256 -0
- package/src/rank-variations.ts +760 -0
- package/src/rank.ts +2122 -326
- package/src/structure/analysis.ts +1398 -150
- package/src/structure/bitfields.ts +432 -0
- package/src/structure/globalaccess.ts +300 -0
- package/src/structure/hazards.ts +411 -20
- package/src/structure/loops.ts +2 -49
- package/src/structure/namecoalesce.ts +454 -0
- package/src/structure/structure.ts +3979 -612
- package/src/structure/switch-recover.ts +710 -145
- package/src/symbols.ts +188 -6
- package/src/target.ts +495 -32
- package/src/trace.ts +112 -33
- package/src/variation-definitions.ts +1540 -0
- package/src/variation-gates.ts +89 -0
- package/src/variation-tokens.ts +355 -0
|
@@ -36,14 +36,49 @@
|
|
|
36
36
|
// folds bottom-up: the innermost diamond becomes a bare condition, which the next diamond consumes as its
|
|
37
37
|
// Vb, and so on. SCOPE: the shared-arm must be reachable as a single-predecessor `br` feeder; the `||`
|
|
38
38
|
// form where the const-1 "true" block has TWO predecessors (`return a || b`) is not folded.
|
|
39
|
-
// Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, the head condition
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
// Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, and the head condition
|
|
40
|
+
// is NEGATABLE whenever the orientation inverts it — an icmp by opcode swap or a `logic_and`/`logic_or` by
|
|
41
|
+
// De Morgan, both via `negateCondOps`, which the control-flow form below shares. Any deviation falls
|
|
42
|
+
// through untouched (a miss, never a miscompile).
|
|
43
|
+
import { disjointConstSlots } from '../ir/alias';
|
|
44
|
+
import {
|
|
45
|
+
Block,
|
|
46
|
+
Fn,
|
|
47
|
+
Op,
|
|
48
|
+
Successor,
|
|
49
|
+
Value,
|
|
50
|
+
defOpMap,
|
|
51
|
+
dominators,
|
|
52
|
+
foldWriteOrder,
|
|
53
|
+
forwardingTarget,
|
|
54
|
+
mkOp,
|
|
55
|
+
mkValue,
|
|
56
|
+
predecessors,
|
|
57
|
+
reachableBlocks,
|
|
58
|
+
replaceAllUsesWith,
|
|
59
|
+
successorsOf,
|
|
60
|
+
} from '../ir/core';
|
|
61
|
+
import { EFFECTFUL_OPS, HOIST_UNSAFE_OPS, NEGATED_ICMP, ORDER_SENSITIVE_OPS } from '../ir/opcodes';
|
|
43
62
|
import { T } from '../ir/types';
|
|
63
|
+
import { type Gate, firstRejection } from '../l3/gates';
|
|
44
64
|
|
|
45
65
|
const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
|
|
46
66
|
|
|
67
|
+
/** Run `step` until it stops rewriting, and answer whether it ever did.
|
|
68
|
+
*
|
|
69
|
+
* Both folds in this file are driven the same way and have to be: `defOpMap` and `predecessors`
|
|
70
|
+
* are stale the moment a block is spliced or dropped, so a scan performs at most ONE rewrite and
|
|
71
|
+
* then starts over. `step` is that scan — it recomputes everything it reads, does at most one
|
|
72
|
+
* rewrite, and returns whether it did — which makes the rescan point a function boundary instead
|
|
73
|
+
* of a `break` to a label. */
|
|
74
|
+
function untilFixpoint(step: () => boolean): boolean {
|
|
75
|
+
let changed = false;
|
|
76
|
+
while (step()) {
|
|
77
|
+
changed = true;
|
|
78
|
+
}
|
|
79
|
+
return changed;
|
|
80
|
+
}
|
|
81
|
+
|
|
47
82
|
/** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
|
|
48
83
|
// NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
|
|
49
84
|
// internal call preserves.
|
|
@@ -86,7 +121,6 @@ function recognizeBoolNormalize(fn: Fn): boolean {
|
|
|
86
121
|
|
|
87
122
|
/** Collapse a simple boolean short-circuit diamond into one `logic_and`/`logic_or`, in place. */
|
|
88
123
|
export function recognizeShortCircuit(fn: Fn): boolean {
|
|
89
|
-
let changed = recognizeBoolNormalize(fn);
|
|
90
124
|
const term = (b: Block) => b.ops[b.ops.length - 1];
|
|
91
125
|
const constOf = (defs: Map<Value, Op>, v: Value): number | null => {
|
|
92
126
|
const d = defs.get(v);
|
|
@@ -97,12 +131,15 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
97
131
|
return !!d && BOOL_OPS.has(d.opcode);
|
|
98
132
|
};
|
|
99
133
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
134
|
+
// TWO STATEMENTS, not `recognizeBoolNormalize(fn) || untilFixpoint(…)`: the normalisation must
|
|
135
|
+
// run BEFORE the fold (it is what turns agbcc's branchless is-nonzero into the `icmp_ne` the
|
|
136
|
+
// diamond's second operand has to be), and `||` would skip the fold whenever it reported a
|
|
137
|
+
// change. Both results are returned, because either one is a change to the IR.
|
|
138
|
+
const normalized = recognizeBoolNormalize(fn);
|
|
139
|
+
const folded = untilFixpoint(() => {
|
|
103
140
|
const defs = defOpMap(fn);
|
|
104
141
|
const preds = predecessors(fn);
|
|
105
|
-
|
|
142
|
+
for (const m of fn.blocks) {
|
|
106
143
|
if (m.params.length !== 1) {
|
|
107
144
|
continue;
|
|
108
145
|
}
|
|
@@ -124,9 +161,8 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
124
161
|
// below: `predecessors()` walks successor edges only, so an entry block that is also a loop
|
|
125
162
|
// header shows one predecessor while actually running BEFORE it on the first iteration.
|
|
126
163
|
// Hoisting its body then reorders it and deleting it moves `fn.blocks[0]`. Silent — verify,
|
|
127
|
-
// assertResolved and assertDerefsTyped all pass.
|
|
128
|
-
//
|
|
129
|
-
// note used to assert this one was safe.
|
|
164
|
+
// assertResolved and assertDerefsTyped all pass. Pinned by 'a feeder that is the entry block
|
|
165
|
+
// is not folded away'; the branch form has the same refusal and its own test.
|
|
130
166
|
if (bfeed === fn.blocks[0]) {
|
|
131
167
|
continue;
|
|
132
168
|
}
|
|
@@ -162,10 +198,24 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
162
198
|
continue;
|
|
163
199
|
}
|
|
164
200
|
const cond = ht.operands[0];
|
|
165
|
-
|
|
166
|
-
|
|
201
|
+
// The head condition must be a C BOOLEAN computed here — a negatable comparison or a
|
|
202
|
+
// `logic_and`/`logic_or`, which is exactly `BOOL_OPS` and exactly the set `negateCondOps`
|
|
203
|
+
// accepts at top level, so a fused connective head still folds. Mere def-existence is NOT
|
|
204
|
+
// the test — `call` declares `results: 1` (ir/opcodes.ts), so `defOpMap` maps a call result
|
|
205
|
+
// like any other — because the const/const reduction below hands `cond` ITSELF on as the
|
|
206
|
+
// merge value (`res = condSide`), replacing a phi that was `cond ? 1 : 0`. A non-boolean
|
|
207
|
+
// head there is a silent WRONG VALUE, not a missed fold: `and(6, 3)` emitted where the
|
|
208
|
+
// program yields 1. The branch form carries no such obligation — its result only ever feeds
|
|
209
|
+
// a `cond_br`, which reads truthiness — which is why this gate lives here and not in the
|
|
210
|
+
// shared helper, whose own result is boolean by construction and so covers the negated case.
|
|
211
|
+
//
|
|
212
|
+
// Instrumented over the whole benchmark under BOTH lift configurations, it refuses
|
|
213
|
+
// NOTHING — every head reaching it is a negatable icmp — while the pass folds 6 value-form
|
|
214
|
+
// diamonds per configuration, 3 of them through the const/const reduction. An invariant's
|
|
215
|
+
// guard, not a filter any row depends on.
|
|
216
|
+
if (!isBool(defs, cond)) {
|
|
167
217
|
continue;
|
|
168
|
-
}
|
|
218
|
+
}
|
|
169
219
|
|
|
170
220
|
// The `cond`-side operand is negated iff `cond` guards the short-circuit (taken+0 / fall+1).
|
|
171
221
|
const wantNeg = (c === 0 && mIsTaken) || (c === 1 && mIsFall);
|
|
@@ -180,11 +230,26 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
180
230
|
if (bfeed.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
|
|
181
231
|
continue;
|
|
182
232
|
}
|
|
233
|
+
// NEGATABLE only when the orientation actually inverts the head — asked here rather than up
|
|
234
|
+
// with the other head checks, so a fused `logic_and`/`logic_or` head (what the branch form
|
|
235
|
+
// below leaves behind, and what an earlier round of THIS pass leaves behind in a chain) is
|
|
236
|
+
// not refused when nothing needs inverting. `negateCondOps` is the branch form's helper too,
|
|
237
|
+
// so both siblings refuse the same three shapes. Its dominance precondition holds here for a
|
|
238
|
+
// different reason than there: `cond` is ^h's OWN terminator operand, so its whole cone
|
|
239
|
+
// dominates the point `before` splices into.
|
|
240
|
+
//
|
|
241
|
+
// Minted below every other refusal and above the first mutation: a site refused for any
|
|
242
|
+
// other reason pays nothing, and a refusal below the hoist would leave ^h half-rewritten.
|
|
243
|
+
const negation = wantNeg ? negateCondOps(defs, cond, NEGATE_BUDGET) : null;
|
|
244
|
+
if (wantNeg && !negation) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
183
247
|
bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
|
|
248
|
+
foldWriteOrder(fn.writeOrder, bfeed, h); // …and its writes now follow H's (ir/core.ts)
|
|
184
249
|
let condSide = cond;
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
250
|
+
if (negation) {
|
|
251
|
+
negation.ops.forEach((op) => before(op));
|
|
252
|
+
condSide = negation.result;
|
|
188
253
|
}
|
|
189
254
|
// Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
|
|
190
255
|
let res = condSide;
|
|
@@ -203,13 +268,12 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
203
268
|
m.params = [];
|
|
204
269
|
}
|
|
205
270
|
fn.blocks = fn.blocks.filter((x) => x !== bfeed);
|
|
206
|
-
|
|
207
|
-
progress = true;
|
|
208
|
-
break outer; // defs/preds are stale after mutation — recompute on the next iteration
|
|
271
|
+
return true; // defs/preds are stale after mutation — the driver rescans
|
|
209
272
|
}
|
|
210
273
|
}
|
|
211
|
-
|
|
212
|
-
|
|
274
|
+
return false;
|
|
275
|
+
});
|
|
276
|
+
return normalized || folded;
|
|
213
277
|
}
|
|
214
278
|
|
|
215
279
|
// ── the CONTROL-FLOW form ───────────────────────────────────────────────────────────────────────
|
|
@@ -253,35 +317,235 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
253
317
|
// not a hand-written `||`. switch-recover.ts requires every test's `cond_br` operand to be an
|
|
254
318
|
// `icmp` (its `isCmpOpcode` gate), and a `logic_or` is not one, so folding first PERMANENTLY
|
|
255
319
|
// disqualifies the recovery and a clean `switch (x) { case 1: case 2: … }` degrades to a chain
|
|
256
|
-
// of nested `if`s. The
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
320
|
+
// of nested `if`s. The two spellings are mutually exclusive within one raise and BOTH are
|
|
321
|
+
// legitimate C, and which of them the asm rules out depends on the shape: `x == 0 || x == 2`
|
|
322
|
+
// and `switch (x) { case 0: case 2: }` are ONE object only where the switch has a single case
|
|
323
|
+
// group plus `default:` (agbcc 12 instructions each, one md5; IDO 64 bytes each, one md5), and
|
|
324
|
+
// part as soon as there is a second group to balance a dispatch against (agbcc 20 against 16,
|
|
325
|
+
// IDO 80 bytes and different bytes). So this is a DEFAULT rather than a decision: the switch
|
|
326
|
+
// is the more specific recovery and wins the shape here, while
|
|
327
|
+
// `foldTreeOwned` spells the connective instead and the differ referees (rank.ts's
|
|
328
|
+
// `/connective`). ONLY THIS CLAUSE. Its notion of "same scrutinee" is switch-recover.ts's own
|
|
329
|
+
// PRE1 — a NECESSARY condition for recovery, never a sufficient one, so what it refuses is
|
|
330
|
+
// "a switch could not be ruled out here" and it is a proxy too, just a far tighter one than
|
|
331
|
+
// the relayed clause. Priced over the set it REFUSES rather than the set it admits: it fires on
|
|
332
|
+
// 6 rows, protects 2 (`pokeemerald:IsStringLengthAtLeast`,
|
|
333
|
+
// `pokeemerald:TrySetCantSelectMoveBattleScript`), and on the other 4 the published winner
|
|
334
|
+
// folds THROUGH it — `kleod:CheckWorldCompletion`'s refused site is `v5 == 3 || v5 == 5` on an
|
|
335
|
+
// ordinary inner-loop counter with no dispatch region near it. It is the variation, not the clause,
|
|
336
|
+
// that keeps those 4. A structural discriminator is L1-visible and would be strictly better —
|
|
337
|
+
// is the shared block the entry of a region with dispatch-shaped in-edges, is the scrutinee
|
|
338
|
+
// defined by the enclosing loop header — and is UNBUILT.
|
|
339
|
+
// The relayed clause below is a different statement (see its own note: a blunt proxy that
|
|
340
|
+
// fires on an ordinary loop counter), it has NO inhabitant anywhere in the benchmark, and a
|
|
341
|
+
// candidate born there would carry a `/connective` variation for a fold that answers
|
|
342
|
+
// no connective-vs-tree question. It stays absolute.
|
|
343
|
+
// - the shared block was reached through a RELAY, and either test's scrutinee is compared against
|
|
344
|
+
// constants more than once in the function. This one is ABSOLUTE — `foldTreeOwned` does not
|
|
345
|
+
// widen it. Same reason as the bullet above, widened because the reach is: a relay is what
|
|
346
|
+
// agbcc puts on a tree's default edge, so resolving one walks this fold into a dispatch chain,
|
|
347
|
+
// where the sibling that gives the tree away may be neither test in
|
|
348
|
+
// hand — the split node is RELATIONAL and only its children are equalities, which is precisely
|
|
349
|
+
// what the pairwise test cannot see. Without it `sub_807BD88` and `sub_808491C` each lose a
|
|
350
|
+
// `switch` (sa3).
|
|
351
|
+
//
|
|
352
|
+
// It is BLUNT, and that is why it is held to the relayed case. The count does not distinguish a
|
|
353
|
+
// dispatch chain from any variable tested against constants at two sites: on `sub_8080AD4` it
|
|
354
|
+
// fires on `v2 > 2` and `v2 != 2` — one ordinary loop counter — and refusing there costs the
|
|
355
|
+
// function its whole decompilation, because the only fold it has is the `do…while` exit. Its
|
|
356
|
+
// notion of "same scrutinee" is SSA-value identity, which is switch-recover.ts's own PRE1, so it
|
|
357
|
+
// cannot refuse a tree that recovery could not have taken either — but it is a proxy for "am I
|
|
358
|
+
// inside a dispatch chain", not a test of it. A direct edge onto a relational split node still
|
|
359
|
+
// escapes: `sub_807F334` folds `x > 1 && x == 2` and loses a `switch` on main and here alike.
|
|
360
|
+
// - BOTH of ^g's edges rejoin the shared block, with NEITHER of them landing on it directly. Then
|
|
361
|
+
// there is no "other" arm and nothing decides which side the connective guards. Only resolution
|
|
362
|
+
// creates this — an edge that arrives directly is preferred exactly so the MIPS divide-guard
|
|
363
|
+
// idiom, whose emptied trap block forwards to the same place, keeps folding as it always has
|
|
364
|
+
// (`af:adds:ido7.1` and the `divv`/`gcd`/`modv` rows).
|
|
260
365
|
// - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
|
|
261
366
|
// would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
|
|
262
|
-
// - a value defined in ^g is used
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
|
266
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.
|
|
267
385
|
// - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
|
|
268
386
|
// so it can only carry one argument list; picking either would silently drop the other path's
|
|
269
387
|
// phi input.
|
|
270
|
-
// - ^g's two successors are the same block, or ^g's condition
|
|
271
|
-
// orientation needs negating.
|
|
388
|
+
// - ^g's two successors are the same block, or ^g's condition cannot be NEGATED when the
|
|
389
|
+
// orientation needs negating. `negateCondOps` decides that, over two shapes: a negatable
|
|
390
|
+
// `icmp_*` (the swapped opcode) and a `logic_and`/`logic_or` (De Morgan — the dual connective
|
|
391
|
+
// over recursively negated operands). The connective case is what lets a chain fold past its
|
|
392
|
+
// FIRST level: this pass is iterative, so by the time it tries an outer diamond the inner one is
|
|
393
|
+
// already fused and ^g's condition is a connective, which `NEGATED_ICMP` — a table over
|
|
394
|
+
// comparison opcodes — has no entry for. Refuse it and `a || !(b || c)` stops after one level at
|
|
395
|
+
// all 13 sites this fires on (`synthetic:llcmp:agbcc`, `:gcc2.7.2kmc`, and 11 real agbcc
|
|
396
|
+
// functions in klonoa+sa3); on the two benchmark rows the structurer then tail-duplicates the
|
|
397
|
+
// shared return into both arms. The helper's own refusals — no def, a non-negatable leaf
|
|
398
|
+
// ANYWHERE in the cone (no partial De Morgan), a cone over the node budget — are on the helper.
|
|
399
|
+
//
|
|
400
|
+
// De Morgan DUPLICATES leaf comparisons, so a duplicated leaf could gain a second consumer that
|
|
401
|
+
// analysis.ts renders as a statement BEFORE the `if` — the hazard the single-icmp negation
|
|
402
|
+
// always carried. Measured over those 11 BRANCH-form sites: NO site gains a local (one,
|
|
403
|
+
// `sub_80B7CD0`, loses one, 8 → 7) and every site that already decompiled gets SHORTER. Two
|
|
404
|
+
// (`sub_80930B8`, `sub_80932E0`) go from DECLINED to a full decompilation — folding a loop-exit
|
|
405
|
+
// connective removes the back-edge loop recovery was refusing — so they get LONGER (1 → 112 and
|
|
406
|
+
// 1 → 55 lines) and are the only sites whose local count rises at all, 0 → 22 and 0 → 7. A
|
|
407
|
+
// decompilation appearing, not a leaf escaping.
|
|
408
|
+
//
|
|
409
|
+
// That number is SCOPED to this fold and does not carry to the value form, which shares the
|
|
410
|
+
// helper but no use-count condition: `definedValuesStayLocal` (bottom of this file)
|
|
411
|
+
// independently forbids a ^g-defined value with a second consumer HERE, while the value form
|
|
412
|
+
// relies on the original cone dying to the pass list's own `dce: true`. There is nothing to
|
|
413
|
+
// transfer the number to yet either — instrumenting the value form over the whole benchmark
|
|
414
|
+
// under both lift configurations counts 6 folds per configuration, every head a single icmp.
|
|
415
|
+
//
|
|
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
|
|
418
|
+
// it. Over the whole benchmark under BOTH configurations rank.ts lifts with (`foldTreeOwned`
|
|
419
|
+
// false and true), against the same rows with the connective case ablated: the recovered IR
|
|
420
|
+
// moves on the same 2 rows under each, `onTreeOwned` fires on the same rows either way, and
|
|
421
|
+
// nothing new throws.
|
|
272
422
|
//
|
|
273
423
|
// Every refusal falls through untouched, leaving the tail-duplicated spelling — a miss, never a
|
|
274
424
|
// miscompile. Applied ITERATIVELY, so `a || b || c` folds left-to-right, each round consuming one
|
|
275
425
|
// more condition block.
|
|
276
|
-
|
|
277
|
-
|
|
426
|
+
//
|
|
427
|
+
// WHICH SPELLING, and why the fold alone does not decide it. `if (a && b) X else Y` and its dual
|
|
428
|
+
// `if (!a || !b) Y else X` are the same program and NOT the same bytes — agbcc lays the arms out in
|
|
429
|
+
// source order, so which was written is recorded in the branch senses. This rewrite keeps ^h's
|
|
430
|
+
// unchanged successor slot, so the connective comes out in the orientation those senses spell, and
|
|
431
|
+
// which of the two that is depends on the branch RANGE below, not on the source. Reaching the
|
|
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
|
|
434
|
+
// spells its dual, so both orientations are compiled and the differ picks (synthetic:ifand_near
|
|
435
|
+
// matches at the default, synthetic:ifor_near on the variation).
|
|
436
|
+
//
|
|
437
|
+
// What neither reaches is the MIXED spelling. `negateJoinedBranchSense` is a per-FUNCTION boolean,
|
|
438
|
+
// so the variation negates every joined `if` at once — and of the 28 real rows carrying the
|
|
439
|
+
// `short-circuit` tag, 16 hold two or more TWO-ARMED ifs (counted by `else`, which is what the
|
|
440
|
+
// variation's own `thenS.length && elseS.length` gate needs) and 12 hold two or more conditions
|
|
441
|
+
// carrying a connective. TWO-ARMED is the count that matters: both sense booleans exclude a
|
|
442
|
+
// one-armed `if` by construction, so a tally of `if (` of any kind is the wrong denominator.
|
|
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.
|
|
447
|
+
//
|
|
448
|
+
// The De Morgan negation below forecloses a third spelling, at a measured price: it DISTRIBUTES, so
|
|
449
|
+
// the leaves come out negated (`a || (!b && !c)`) and `a || !(b || c)` has no
|
|
450
|
+
// candidate — the IR has no `logic_not` to build one from (ir/opcodes.ts). Compiled both ways on
|
|
451
|
+
// the `a || (b && c)` guard shape at agbcc's default flags, the two source spellings assemble to
|
|
452
|
+
// the same bytes (12/12 rows, score 0), so the foreclosure costs nothing here. A shape that ever
|
|
453
|
+
// separated them would be a new variation, not a bug in this fold.
|
|
454
|
+
//
|
|
455
|
+
// WHICH slot ^g lands in is decided by the asm's branch POLARITY, and on Thumb the branch RANGE
|
|
456
|
+
// decides the polarity — so the same source `&&` reaches this pass two different ways:
|
|
457
|
+
//
|
|
458
|
+
// short branch `beq shared` ^g is ^h's FALL → logic_or
|
|
459
|
+
// long branch `bne ^g / b shared` ^g is ^h's TAKEN → logic_and
|
|
460
|
+
//
|
|
461
|
+
// agbcc inverts a conditional it cannot reach, so past ±256 bytes it emits the second form, and the
|
|
462
|
+
// trampoline it leaves on the `b` sits on the edge into the SHARED block — which `forwardingTarget`
|
|
463
|
+
// (ir/core.ts) looks through. Only that edge needs it: the INVERTED branch is the one that still
|
|
464
|
+
// reaches, so `bne ^g` always arrives at ^g directly and no relay can sit between them.
|
|
465
|
+
//
|
|
466
|
+
// SO THE CONNECTIVE THIS FOLD MINTS IS THE RANGE'S, NEVER THE SOURCE'S: both `synthetic:ifand_near`
|
|
467
|
+
// (source `&&`) and `synthetic:ifor_near` (source `||`) are short-branch rows and both come out
|
|
468
|
+
// `logic_or`. Which SPELLING then wins is the joined-sense default's question, one layer up, and
|
|
469
|
+
// BOTH halves have a dual candidate there — `ifand_near:agbcc` matches at the default (`unsigned`),
|
|
470
|
+
// while `ifor_near:agbcc` and the long-branch `ifand_far:agbcc` each match on `/flip-join`.
|
|
471
|
+
//
|
|
472
|
+
// `gIsFall` IS NOT THE CARRIER FOR A PER-SITE SENSE, and that was measured rather than argued. It
|
|
473
|
+
// reads the branch RANGE, exactly as the table above says — so in any function small enough for
|
|
474
|
+
// every branch to be short it is the SAME at every site, including sites whose sources wrote
|
|
475
|
+
// opposite connectives. Instrumented at the fold and run through the bench: a two-site row whose
|
|
476
|
+
// first site wrote `&&` and whose second wrote its dual reads `gIsFall=true` at BOTH
|
|
477
|
+
// (`synthetic:joinsense`), and a four-site ladder row with two sites inverted in the source reads
|
|
478
|
+
// `true` at all four (`synthetic:mixsense`). The positive control reads the other way —
|
|
479
|
+
// `synthetic:ifand_far`, the long-branch row, gives `false` against
|
|
480
|
+
// `synthetic:ifand_near`'s `true`. So carrying this boolean to L3
|
|
481
|
+
// as a node stamp (the `#144` `Expr.baseOrdered` shape) would hand every site of such a function
|
|
482
|
+
// one answer and reach exactly the two configurations `negateJoinedBranchSense` already reaches.
|
|
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
|
+
//
|
|
508
|
+
// Every refusal falls through untouched — a miss, never a miscompile.
|
|
509
|
+
/** Per-call options for `recognizeBranchShortCircuit` — the tree-ownership refusal's two ends. */
|
|
510
|
+
export interface BranchShortCircuitOptions {
|
|
511
|
+
/** Take the fold at a site the PAIRWISE comparison-tree refusal owns, spelling the connective
|
|
512
|
+
* where the default leaves the tree for switch-recover.ts. rank.ts's `/connective` variation; see the
|
|
513
|
+
* REFUSALS note. It widens the SHAPE the fold accepts and nothing about what the fold may move —
|
|
514
|
+
* every other refusal still applies, the RELAYED clause included.
|
|
515
|
+
*
|
|
516
|
+
* A NEW REFUSAL CONDITION, stated because it is one: this is per-FUNCTION and the question is
|
|
517
|
+
* per-SITE. Every tree-owned site in a function flips together, so a function with two of them
|
|
518
|
+
* wanting OPPOSITE spellings has no candidate that spells the mix, and nothing reports the gap.
|
|
519
|
+
* A per-FUNCTION predicate cannot decide a per-SITE question — the same shape the joined-if
|
|
520
|
+
* default hit — and here it costs completeness rather than correctness. The alternative is a
|
|
521
|
+
* fork per site: `kleod:CheckWorldCompletion` refuses at 10 and goes 96 → 192 candidates as one
|
|
522
|
+
* boolean, where a per-site fork would be 1024×. That is why the boolean, not an oversight. */
|
|
523
|
+
foldTreeOwned?: boolean;
|
|
524
|
+
/** Called at each site the pairwise tree-ownership refusal is the ONE thing stopping the fold —
|
|
525
|
+
* how rank.ts learns the variation has an inhabitant here without re-running the matcher. Asked LAST,
|
|
526
|
+
* after `sameArgs` and the negatability check, so a report means a `/connective` candidate that
|
|
527
|
+
* differs from its sibling: reporting a refusal merely REACHED would double the row's whole
|
|
528
|
+
* candidate cross for a lift that produces duplicates the dedup collapses. (Its sibling gate
|
|
529
|
+
* `hasSetupArgsNarrowing` asks the same question the same way — does the variation CHANGE anything.)
|
|
530
|
+
* The pass re-scans after every rewrite, so one site can report more than once; read it as a
|
|
531
|
+
* boolean. */
|
|
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;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function recognizeBranchShortCircuit(fn: Fn, opts: BranchShortCircuitOptions = {}): boolean {
|
|
278
544
|
const term = (b: Block) => b.ops[b.ops.length - 1];
|
|
279
|
-
|
|
280
|
-
while (progress) {
|
|
281
|
-
progress = false;
|
|
545
|
+
return untilFixpoint(() => {
|
|
282
546
|
const defs = defOpMap(fn);
|
|
283
547
|
const preds = predecessors(fn);
|
|
284
|
-
|
|
548
|
+
for (const h of fn.blocks) {
|
|
285
549
|
const ht = term(h);
|
|
286
550
|
if (ht.opcode !== 'cond_br') {
|
|
287
551
|
continue;
|
|
@@ -312,29 +576,39 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
|
|
|
312
576
|
if (gTaken.block === gFall.block) {
|
|
313
577
|
continue;
|
|
314
578
|
}
|
|
315
|
-
//
|
|
316
|
-
if (sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0])) {
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
// ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
|
|
320
|
-
// see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
|
|
321
|
-
// short circuit.
|
|
579
|
+
// ^g's body must be pure — see the REFUSALS note.
|
|
322
580
|
// HOIST_UNSAFE_OPS includes `opaque`: an instruction asmlift could not model, and moving it
|
|
323
581
|
// out of the arm that guards it is the reordering this refuses. Loud either way today — a
|
|
324
582
|
// decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under `annotate`.
|
|
325
|
-
|
|
326
|
-
if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
|
|
583
|
+
if (g.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
|
|
327
584
|
continue;
|
|
328
585
|
}
|
|
329
|
-
|
|
586
|
+
// Which of ^g's edges rejoins ^h's other successor? That is the shared block. A DIRECT edge
|
|
587
|
+
// wins, so resolution only ever adds reach — it never re-picks an edge this fold already had.
|
|
588
|
+
const sharedTarget = forwardingTarget(sharedFromH.block);
|
|
589
|
+
const rejoins = (e: Successor): boolean => forwardingTarget(e.block) === sharedTarget;
|
|
590
|
+
const direct = gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
|
|
591
|
+
// With neither edge direct, both may resolve onto the shared block — and then there is no
|
|
592
|
+
// "other" arm left and nothing decides which side the connective guards.
|
|
593
|
+
if (direct === null && rejoins(gTaken) && rejoins(gFall)) {
|
|
330
594
|
continue;
|
|
331
595
|
}
|
|
332
|
-
|
|
333
|
-
const sharedEdge =
|
|
334
|
-
gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
|
|
596
|
+
const sharedEdge = direct ?? (rejoins(gTaken) ? gTaken : rejoins(gFall) ? gFall : null);
|
|
335
597
|
if (!sharedEdge) {
|
|
336
598
|
continue;
|
|
337
599
|
}
|
|
600
|
+
// A comparison TREE over one scrutinee belongs to switch recovery, not to this fold. A relay
|
|
601
|
+
// is what agbcc puts on a tree's default edge, so a shared block reached through one is
|
|
602
|
+
// searched function-wide; a direct edge keeps the pairwise test. See the REFUSALS note —
|
|
603
|
+
// the wider test is blunt, and that is why it is not asked everywhere.
|
|
604
|
+
const throughRelay = sharedEdge.block !== sharedFromH.block;
|
|
605
|
+
if (
|
|
606
|
+
throughRelay &&
|
|
607
|
+
(inComparisonTree(fn, defs, ht.operands[0]) || inComparisonTree(fn, defs, gt.operands[0]))
|
|
608
|
+
) {
|
|
609
|
+
continue; // the relayed clause is absolute — `foldTreeOwned` does not widen it
|
|
610
|
+
}
|
|
611
|
+
const treeOwned = !throughRelay && sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0]);
|
|
338
612
|
const otherEdge = sharedEdge === gTaken ? gFall : gTaken;
|
|
339
613
|
if (!sameArgs(sharedFromH.args, sharedEdge.args)) {
|
|
340
614
|
continue;
|
|
@@ -343,16 +617,40 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
|
|
|
343
617
|
// reach the SHARED block", `logic_and` asks "does ^g reach the OTHER block".
|
|
344
618
|
const wantEdge = gIsFall ? sharedEdge : otherEdge;
|
|
345
619
|
const c2 = gt.operands[0];
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
620
|
+
// ^g's condition may itself be a CONNECTIVE — the fold is iterative, so an inner diamond is
|
|
621
|
+
// already fused by the time the outer one is tried, and negating one is De Morgan rather
|
|
622
|
+
// than an opcode swap. `negateCondOps` does both, and returns null on anything else.
|
|
623
|
+
const negation = wantEdge !== gTaken ? negateCondOps(defs, c2, NEGATE_BUDGET) : null;
|
|
624
|
+
if (wantEdge !== gTaken && !negation) {
|
|
625
|
+
continue;
|
|
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
|
+
}
|
|
638
|
+
// LAST refusal, so `onTreeOwned` reports a site where tree ownership is the ONE thing in the
|
|
639
|
+
// way — which is why the negatability check stays above it even though it MINTS the negated
|
|
640
|
+
// cone (up to NEGATE_BUDGET ops) and discards it whenever this gate refuses. That discard is
|
|
641
|
+
// free rather than merely cheap: `mkValue` is `{ type }` with no identity counter
|
|
642
|
+
// (ir/core.ts) and nothing is spliced until below, so a site refused here leaves the CFG
|
|
643
|
+
// byte-identical. See the option's doc for why the position is the gate's meaning.
|
|
644
|
+
if (treeOwned) {
|
|
645
|
+
opts.onTreeOwned?.();
|
|
646
|
+
if (!opts.foldTreeOwned) {
|
|
351
647
|
continue;
|
|
352
648
|
}
|
|
353
|
-
second = mkValue(T.unk(32));
|
|
354
|
-
negated.push(mkOp(NEGATED_ICMP[c2Def.opcode], { operands: [...c2Def.operands], results: [second] }));
|
|
355
649
|
}
|
|
650
|
+
rereadInArm(fn, g, otherEdge.block, reread);
|
|
651
|
+
const body = g.ops.slice(0, -1);
|
|
652
|
+
const second = negation ? negation.result : c2;
|
|
653
|
+
const negated: Op[] = negation ? negation.ops : [];
|
|
356
654
|
const res = mkValue(T.unk(32));
|
|
357
655
|
const connective = mkOp(gIsFall ? 'logic_or' : 'logic_and', {
|
|
358
656
|
operands: [ht.operands[0], second],
|
|
@@ -361,7 +659,69 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
|
|
|
361
659
|
// ^g's body moves ahead of ^h's terminator; ^h keeps the successor SLOT that did not change
|
|
362
660
|
// (taken=shared for `||`, taken=other for `&&`), so the frontend's branch sense survives.
|
|
363
661
|
h.ops.splice(h.ops.length - 1, 1, ...body, ...negated, connective, {
|
|
364
|
-
|
|
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
|
+
}),
|
|
365
725
|
successors: gIsFall
|
|
366
726
|
? [
|
|
367
727
|
{ block: sharedEdge.block, args: [...sharedEdge.args] },
|
|
@@ -372,63 +732,664 @@ export function recognizeBranchShortCircuit(fn: Fn): boolean {
|
|
|
372
732
|
{ block: sharedEdge.block, args: [...sharedEdge.args] },
|
|
373
733
|
],
|
|
374
734
|
});
|
|
735
|
+
foldWriteOrder(fn.writeOrder, g, h); // ^g's writes now follow ^h's own (ir/core.ts)
|
|
375
736
|
fn.blocks = fn.blocks.filter((x) => x !== g);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
737
|
+
// ^h's old shared edge is gone, so the relay it pointed at may have become unreachable —
|
|
738
|
+
// and once that link goes, so may the next, all the way down the chain. `dominators`
|
|
739
|
+
// (ir/core.ts) gives a block with no in-edges only ITSELF, which empties the intersection at
|
|
740
|
+
// everything it still branches to, so a half-dropped chain declines two passes later with a
|
|
741
|
+
// def-does-not-dominate-use out of `verify`.
|
|
742
|
+
//
|
|
743
|
+
// REACHABILITY, not predecessor count: in-edges from blocks that are themselves unreachable
|
|
744
|
+
// leave a block just as orphaned, and the thumb frontend does hand over unreachable blocks
|
|
745
|
+
// (see raise/gvn.ts). Only relays are dropped — the chain ends at the first block that does
|
|
746
|
+
// real work, and that one stays whatever its in-edges look like.
|
|
747
|
+
//
|
|
748
|
+
// The relay test below is `isBodyless` (ir/core.ts) MINUS its parameter clause, and the
|
|
749
|
+
// omission is what the reachability guard above buys: an unreachable block binds nothing, so
|
|
750
|
+
// a param on one says nothing about a live edge, while refusing it would leave the chain
|
|
751
|
+
// HALF-DROPPED — and a half-dropped chain fails `verify` two passes later with a
|
|
752
|
+
// def-does-not-dominate-use. `isBodyless` itself is unchanged and right for its own three
|
|
753
|
+
// callers, every one of which asks about a block that is still reached.
|
|
754
|
+
for (let link = sharedFromH.block; link.ops.length === 1 && link.ops[0].opcode === 'br';) {
|
|
755
|
+
const dead = link;
|
|
756
|
+
if (dead === fn.blocks[0] || reachableBlocks(fn).has(dead)) {
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
link = dead.ops[0].successors[0].block;
|
|
760
|
+
fn.blocks = fn.blocks.filter((x) => x !== dead);
|
|
761
|
+
}
|
|
762
|
+
return true; // defs/preds are stale after the mutation — the driver rescans
|
|
379
763
|
}
|
|
380
764
|
}
|
|
381
|
-
|
|
382
|
-
|
|
765
|
+
return false;
|
|
766
|
+
});
|
|
383
767
|
}
|
|
384
768
|
|
|
385
|
-
/**
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
769
|
+
/** How many ops `negateCondOps` may KEEP for one negation. De Morgan rebuilds the cone PER PATH and
|
|
770
|
+
* shares nothing — deliberately, see the helper's note — so a fold's cost is linear in the cone's
|
|
771
|
+
* nodes and this is the cheap stop on it.
|
|
772
|
+
*
|
|
773
|
+
* What 8 decides, said as the shape rather than as headroom, because "8" reads like more room than
|
|
774
|
+
* it is. A negation mints one op per cone node and a binary expansion has leaves = internal + 1, so
|
|
775
|
+
* a minted count is always ODD: 1, 3, 5, 7, 9. At 8 a FOUR-clause inner conjunct (7 ops) folds and
|
|
776
|
+
* a FIVE-clause one (9 ops) does not; 7 and 8 are therefore one gate, and so is 9 over everything
|
|
777
|
+
* measured here, the deepest cone in the 2,047 lifted klonoa+sa3 functions minting 5 (17
|
|
778
|
+
* connective negations, 0 refused). Clause COUNT is not the measure either: a FLAT `a || b || c || …`
|
|
779
|
+
* chain pays nothing at all, because ^g's condition is never a connective in that shape.
|
|
780
|
+
*
|
|
781
|
+
* The bound is on ops KEPT, and the frontier is a NODE COUNT — not a shape. Pinned as such rather
|
|
782
|
+
* than argued: branch-shortcircuit.test.ts's "the fold's frontier is the cone's NODE COUNT, not its
|
|
783
|
+
* shape" enumerates all 23,714 binary cone shapes with up to 10 internal nodes (21 nodes) and
|
|
784
|
+
* asserts the PUBLIC fold takes one exactly when `2k + 1 <= budget`.
|
|
785
|
+
* Shape decides only WHICH guard refuses and how many ops had been
|
|
786
|
+
* minted when it did, neither of them visible to a caller — at budget 8 a 15-node cone is caught by
|
|
787
|
+
* the ENTRY guard at 9 (left chain) or 8 (balanced cone) or by the POST-check at 15 (right chain).
|
|
788
|
+
* Because `go` pushes a parent only after its children, a refused walk transiently mints more than
|
|
789
|
+
* the budget before unwinding — `2 * budget - 1` at the worst shape, that right chain, at every
|
|
790
|
+
* budget measured. Bounded, and nothing escapes it.
|
|
791
|
+
*
|
|
792
|
+
* A refusal here is SILENT, unlike the `onTreeOwned` gate above which exists so a sweep need not
|
|
793
|
+
* re-instrument. So raising this constant is not free advice: finding a corpus site that wants it
|
|
794
|
+
* means patching a hook back into this file. No callback is added because no consumer has asked for
|
|
795
|
+
* one; test/branch-shortcircuit.test.ts pins both sides of the frontier instead. */
|
|
796
|
+
const NEGATE_BUDGET = 8;
|
|
797
|
+
|
|
798
|
+
/** The ops computing `!v`, or `null` when `v` cannot be negated.
|
|
799
|
+
*
|
|
800
|
+
* Two cases, and no third:
|
|
801
|
+
* - an `icmp_*` in `NEGATED_ICMP` → the swapped-opcode comparison over the SAME operands.
|
|
802
|
+
* - a `logic_and`/`logic_or` → the DUAL connective over its two recursively negated operands.
|
|
803
|
+
* De Morgan: `!(a || b)` is `!a && !b`. This is the only reason the helper is recursive, and
|
|
804
|
+
* it is what lets a chain fold past its first level (`a || !(b || c)`).
|
|
805
|
+
*
|
|
806
|
+
* REFUSALS — each returns `null`, which the caller turns into its existing `continue`, so the CFG
|
|
807
|
+
* is left exactly as it was and the structurer emits today's tail-duplicated spelling. A bytes
|
|
808
|
+
* miss, never a wrong answer:
|
|
809
|
+
* - the value has no def in this function, which means a block param — a call RESULT has one,
|
|
810
|
+
* `call` declaring `results: 1` (ir/opcodes.ts), and is refused by the next bullet instead;
|
|
811
|
+
* - the def is neither a negatable icmp nor a connective (a call, any arithmetic) — there is no
|
|
812
|
+
* sound inverse to build, and `!x` as `x == 0` is a DIFFERENT spelling, not this fold's
|
|
813
|
+
* business;
|
|
814
|
+
* - ANY leaf anywhere in the cone is non-negatable ⇒ the WHOLE negation is refused. Half a
|
|
815
|
+
* De Morgan is not a conservative approximation of one;
|
|
816
|
+
* - the cone exceeds `NEGATE_BUDGET` minted ops.
|
|
817
|
+
*
|
|
818
|
+
* Ops are MINTED, never mutated: the originals stay where the caller hoisted them and die to the
|
|
819
|
+
* pass list's own `dce: true` (raise/pre-recovery.ts). Only `icmp_*`/`logic_and`/`logic_or` are
|
|
820
|
+
* ever rebuilt, all of them pure, so no effect can be duplicated or reordered by construction.
|
|
821
|
+
*
|
|
822
|
+
* Nothing is SHARED between paths, and that is the point rather than a limitation. Memoizing `go`
|
|
823
|
+
* in a `Map<Value, Value>` is three lines and would make the budget unnecessary — and it would
|
|
824
|
+
* create exactly the hazard this fold exists to remove: a shared negated sub-condition has two
|
|
825
|
+
* consumers, and analysis.ts renders a value with two consumers as a statement BEFORE the `if`.
|
|
826
|
+
* Nothing collapses the duplicates later either — `numberPureValues` runs as `addrnum`, far ahead
|
|
827
|
+
* of both folds. So the per-path rebuild is the mechanism and NEGATE_BUDGET is its price.
|
|
828
|
+
*
|
|
829
|
+
* What it GUARANTEES about its result, which one caller leans on: every op it mints is an `icmp_*`
|
|
830
|
+
* or a `logic_and`/`logic_or`, so the returned value is always a C boolean. The value form hands an
|
|
831
|
+
* un-negated head straight on as the merge VALUE, which is why that caller checks booleanness for
|
|
832
|
+
* itself before deciding whether to call here at all (see its head gate); the branch form needs no
|
|
833
|
+
* such check, because its result only ever feeds a `cond_br`.
|
|
834
|
+
*
|
|
835
|
+
* PRECONDITION, earned by the caller and not checked here: every value in the cone must dominate
|
|
836
|
+
* the point the caller splices `ops` into — the minted comparisons reuse the ORIGINAL leaf
|
|
837
|
+
* operands. The branch fold gets it free from ^g's single-predecessor gate: with ^h ^g's only
|
|
838
|
+
* predecessor, every def in the cone either sits in ^g's body (spliced in ahead of these ops) or
|
|
839
|
+
* dominates ^h. A caller without that invariant emits a def that does not dominate its use — loud
|
|
840
|
+
* at `verify`, but this helper does not look.
|
|
841
|
+
*
|
|
842
|
+
* It stays in this file rather than joining `NEGATED_ICMP` in ir/opcodes.ts: that table is a fact
|
|
843
|
+
* about opcodes, this MINTS ops, and both consumers are the two folds above — the value form and
|
|
844
|
+
* the branch form, which is the whole reason it is a helper and not inline. */
|
|
845
|
+
function negateCondOps(defs: Map<Value, Op>, v: Value, budget: number): { ops: Op[]; result: Value } | null {
|
|
846
|
+
const ops: Op[] = [];
|
|
847
|
+
const go = (x: Value): Value | null => {
|
|
848
|
+
if (ops.length >= budget) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
const d = defs.get(x);
|
|
852
|
+
if (!d) {
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
const out = mkValue(T.unk(32));
|
|
856
|
+
if (NEGATED_ICMP[d.opcode]) {
|
|
857
|
+
ops.push(mkOp(NEGATED_ICMP[d.opcode], { operands: [...d.operands], results: [out] }));
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
if (d.opcode === 'logic_and' || d.opcode === 'logic_or') {
|
|
861
|
+
const a = go(d.operands[0]);
|
|
862
|
+
if (a === null) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
const b = go(d.operands[1]);
|
|
866
|
+
if (b === null) {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
// Operands are pushed BEFORE the connective, so the op list is already in dominating order.
|
|
870
|
+
ops.push(mkOp(d.opcode === 'logic_and' ? 'logic_or' : 'logic_and', { operands: [a, b], results: [out] }));
|
|
871
|
+
return out;
|
|
872
|
+
}
|
|
873
|
+
return null;
|
|
874
|
+
};
|
|
875
|
+
const result = go(v);
|
|
876
|
+
return result === null || ops.length > budget ? null : { ops, result };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** Do `c1` and `c2` compare the SAME value against CONSTANTS? The signature of a comparison-tree
|
|
880
|
+
* `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests only: a switch
|
|
881
|
+
* dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the range check) is a
|
|
882
|
+
* genuine connective this fold should still take. */
|
|
389
883
|
function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
884
|
+
const s1 = constTestScrutinee(defs, c1);
|
|
885
|
+
const isEq = (v: Value): boolean => {
|
|
886
|
+
const op = defs.get(v)?.opcode;
|
|
887
|
+
return op === 'icmp_eq' || op === 'icmp_ne';
|
|
888
|
+
};
|
|
889
|
+
return s1 !== null && s1 === constTestScrutinee(defs, c2) && isEq(c1) && isEq(c2);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Is `c`'s scrutinee compared against constants by MORE THAN ONE `cond_br` in the function?
|
|
893
|
+
*
|
|
894
|
+
* The function-wide question, for the case where the shared block was reached through a relay. A
|
|
895
|
+
* tree's split node is RELATIONAL and its children are equalities (`if (x > 10) { if (x == 20) }`),
|
|
896
|
+
* so the two tests in hand need not look alike, and the one that would give the tree away may be
|
|
897
|
+
* neither of them. Counting every constant test on the scrutinee catches the split either way. */
|
|
898
|
+
function inComparisonTree(fn: Fn, defs: Map<Value, Op>, c: Value): boolean {
|
|
899
|
+
const scrutinee = constTestScrutinee(defs, c);
|
|
900
|
+
if (scrutinee === null) {
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
let seen = 0;
|
|
904
|
+
for (const b of fn.blocks) {
|
|
905
|
+
const t = b.ops[b.ops.length - 1];
|
|
906
|
+
if (t?.opcode === 'cond_br' && constTestScrutinee(defs, t.operands[0]) === scrutinee && ++seen > 1) {
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return false;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** The value an `icmp_* <value>, <const>` tests, or null when `c` is not one. */
|
|
914
|
+
function constTestScrutinee(defs: Map<Value, Op>, c: Value): Value | null {
|
|
915
|
+
const d = defs.get(c);
|
|
916
|
+
if (!d || NEGATED_ICMP[d.opcode] === undefined) {
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
const [x, y] = d.operands;
|
|
920
|
+
const xc = defs.get(x)?.opcode === 'const';
|
|
921
|
+
const yc = defs.get(y)?.opcode === 'const';
|
|
922
|
+
// exactly one side constant — `x == y` between two variables is no switch test
|
|
923
|
+
return xc === yc ? null : xc ? y : x;
|
|
924
|
+
}
|
|
925
|
+
|
|
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)) {
|
|
393
1121
|
return null;
|
|
394
1122
|
}
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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;
|
|
400
1131
|
};
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
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;
|
|
404
1161
|
}
|
|
405
1162
|
|
|
406
|
-
|
|
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.
|
|
407
1361
|
*
|
|
408
1362
|
* The VALUE form above needs no such check, and the asymmetry is real rather than drift: its feeder
|
|
409
1363
|
* ends in `br M`, so the feeder has no successor of its own to dominate and every value it defines
|
|
410
1364
|
* is either read in the feeder or carried to `M` as the phi argument the fold consumes. Here ^g
|
|
411
1365
|
* ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
|
|
412
|
-
* escape, and only this check
|
|
1366
|
+
* escape, and only this check and the arm's copy stop it.
|
|
413
1367
|
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
function definedValuesStayLocal(fn: Fn, g: Block): boolean {
|
|
1368
|
+
* What the two folds genuinely DO share is the `fn.blocks[0]` refusal, and each has a test that
|
|
1369
|
+
* pins its own half: 'a feeder that is the entry block is not folded away' for the value form, and
|
|
1370
|
+
* 'the ENTRY block is never folded away' for the branch form. They are deliberately NOT routed
|
|
1371
|
+
* through one shared `isEntry` helper — a helper enforces nothing, and it is the two tests that
|
|
1372
|
+
* hold each fold to the refusal. When changing either fold, check the other. */
|
|
1373
|
+
function definedValuesStayLocal(fn: Fn, g: Block, { copy, drop }: ArmReread): boolean {
|
|
420
1374
|
const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
|
|
421
1375
|
if (defined.size === 0) {
|
|
422
1376
|
return true;
|
|
423
1377
|
}
|
|
1378
|
+
const copied = new Set<Value>([...copy].flatMap((op) => op.results));
|
|
424
1379
|
const uses = new Map<Value, number>();
|
|
425
1380
|
for (const b of fn.blocks) {
|
|
426
1381
|
for (const op of b.ops) {
|
|
427
|
-
|
|
1382
|
+
if (drop.has(op)) {
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
for (const v of readsOf(op)) {
|
|
428
1386
|
if (!defined.has(v)) {
|
|
429
1387
|
continue;
|
|
430
1388
|
}
|
|
431
1389
|
if (b !== g) {
|
|
1390
|
+
if (copied.has(v)) {
|
|
1391
|
+
continue; // re-derived in the arm (`rereadInArm`)
|
|
1392
|
+
}
|
|
432
1393
|
return false; // escapes ^g — the structurer would render it before the `if`
|
|
433
1394
|
}
|
|
434
1395
|
uses.set(v, (uses.get(v) ?? 0) + 1);
|