@asmlift/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/backend/cfamily.ts +125 -2
- package/src/backend/cpp.ts +3 -1
- package/src/backend/pascal.ts +11 -0
- package/src/contracts.ts +15 -2
- package/src/declare.ts +35 -9
- package/src/frontend/mips.ts +24 -23
- package/src/frontend/opaque.ts +39 -2
- package/src/frontend/ssa.ts +32 -53
- package/src/frontend/thumb.ts +301 -26
- package/src/ir/opcodes.ts +44 -0
- package/src/ir/simplify.ts +72 -0
- package/src/l3/argbase.ts +216 -0
- package/src/l3/ast.ts +118 -4
- package/src/l3/basecse.ts +3 -40
- package/src/l3/coalesce.ts +146 -0
- package/src/l3/dce.ts +2 -23
- package/src/l3/hoist.ts +65 -0
- package/src/l3/reindex.ts +7 -0
- package/src/l3/scopebase.ts +436 -0
- package/src/l3/tailmerge.ts +120 -0
- package/src/macros.ts +222 -13
- package/src/pattern/engine.ts +99 -6
- package/src/pipeline.ts +5 -2
- package/src/raise/divpow2.ts +226 -0
- package/src/raise/gvn.ts +141 -0
- package/src/raise/pre-recovery.ts +37 -3
- package/src/raise/recover.ts +24 -7
- package/src/raise/retsink.ts +36 -7
- package/src/raise/shortcircuit.ts +264 -22
- package/src/raise/structs.ts +12 -2
- package/src/rank.ts +172 -20
- package/src/structure/analysis.ts +42 -1
- package/src/structure/structure.ts +399 -31
- package/src/structure/switch-recover.ts +21 -3
- package/src/symbols.ts +128 -13
- package/src/target.ts +4 -2
- package/src/trace.ts +9 -0
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
// asmlift —
|
|
1
|
+
// asmlift — short-circuit connective recovery (F-CFG; successor-aware, agbcc-class).
|
|
2
|
+
//
|
|
3
|
+
// A `&&`/`||` reaches the IR in two shapes, and this module recovers BOTH into the SAME pair of
|
|
4
|
+
// opcodes (`logic_and`/`logic_or`) the backend already prints as `&&`/`||`:
|
|
5
|
+
//
|
|
6
|
+
// - the VALUE form (`return a && b`) — a diamond whose merge phi is the boolean. That is
|
|
7
|
+
// `recognizeShortCircuit`, described below.
|
|
8
|
+
// - the CONTROL-FLOW form (`if (a || b) X else Y`) — no value at all, just two `cond_br` blocks
|
|
9
|
+
// that share a target. That is `recognizeBranchShortCircuit`, at the bottom of this file.
|
|
10
|
+
//
|
|
11
|
+
// They are one concept and stay in one file, but they are separate passes because their inputs do
|
|
12
|
+
// not overlap: the value form needs the second block to end in `br` carrying a phi argument, the
|
|
13
|
+
// branch form needs it to end in `cond_br`.
|
|
2
14
|
//
|
|
3
15
|
// `return a && b` compiles (agbcc) to a value-producing diamond: `if (a==0) result=0; else result=(b!=0)`,
|
|
4
16
|
// where the merge block returns the phi. The structurer lowers that as `if (a==0){v0=0}else{v0=(-b|b)>>31}
|
|
@@ -27,26 +39,10 @@
|
|
|
27
39
|
// Guards stay conservative: the CONST is exactly 0/1, Vb is a bool op or 0/1 const, the head condition is a
|
|
28
40
|
// negatable icmp, and any deviation falls through untouched (a miss, never a miscompile).
|
|
29
41
|
import { Block, Fn, Op, Value, defOpMap, mkOp, mkValue, predecessors, replaceAllUsesWith } from '../ir/core';
|
|
30
|
-
import
|
|
31
|
-
import { EFFECTFUL_OPS } from '../ir/opcodes';
|
|
42
|
+
import { HOIST_UNSAFE_OPS, NEGATED_ICMP } from '../ir/opcodes';
|
|
32
43
|
import { T } from '../ir/types';
|
|
33
44
|
|
|
34
|
-
const
|
|
35
|
-
icmp_eq: 'icmp_ne',
|
|
36
|
-
icmp_ne: 'icmp_eq',
|
|
37
|
-
icmp_slt: 'icmp_sge',
|
|
38
|
-
icmp_sge: 'icmp_slt',
|
|
39
|
-
icmp_sgt: 'icmp_sle',
|
|
40
|
-
icmp_sle: 'icmp_sgt',
|
|
41
|
-
icmp_ult: 'icmp_uge',
|
|
42
|
-
icmp_uge: 'icmp_ult',
|
|
43
|
-
icmp_ugt: 'icmp_ule',
|
|
44
|
-
icmp_ule: 'icmp_ugt',
|
|
45
|
-
};
|
|
46
|
-
const BOOL_OPS = new Set([...Object.keys(NEGATE_ICMP), 'logic_and', 'logic_or']);
|
|
47
|
-
// Ops with an observable side effect — unsafe to HOIST out of a short-circuit's conditional arm
|
|
48
|
-
// (they would run unconditionally). Derived from the ONE effect table in ir/opcodes.ts.
|
|
49
|
-
const SIDE_EFFECT = EFFECTFUL_OPS;
|
|
45
|
+
const BOOL_OPS = new Set([...Object.keys(NEGATED_ICMP), 'logic_and', 'logic_or']);
|
|
50
46
|
|
|
51
47
|
/** Fold `(-x | x) >> 31` (logical shift) → `x != 0`, in place. agbcc's branchless is-nonzero idiom. */
|
|
52
48
|
// NOT exported: it must run before the diamond fold, an ordering only recognizeShortCircuit's
|
|
@@ -124,6 +120,16 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
124
120
|
if (bp.length !== 1) {
|
|
125
121
|
continue;
|
|
126
122
|
}
|
|
123
|
+
// The ENTRY block is never a feeder, for the same reason it is never ^g in the branch form
|
|
124
|
+
// below: `predecessors()` walks successor edges only, so an entry block that is also a loop
|
|
125
|
+
// header shows one predecessor while actually running BEFORE it on the first iteration.
|
|
126
|
+
// Hoisting its body then reorders it and deleting it moves `fn.blocks[0]`. Silent — verify,
|
|
127
|
+
// assertResolved and assertDerefsTyped all pass. PRE-EXISTING (this fold predates the branch
|
|
128
|
+
// form and `main` miscompiles the same MIPS input); fixed here because the branch form's
|
|
129
|
+
// note used to assert this one was safe.
|
|
130
|
+
if (bfeed === fn.blocks[0]) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
127
133
|
const h = bp[0];
|
|
128
134
|
const ht = term(h);
|
|
129
135
|
if (ht.opcode !== 'cond_br') {
|
|
@@ -157,7 +163,7 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
157
163
|
}
|
|
158
164
|
const cond = ht.operands[0];
|
|
159
165
|
const condDef = defs.get(cond);
|
|
160
|
-
if (!condDef || !
|
|
166
|
+
if (!condDef || !NEGATED_ICMP[condDef.opcode]) {
|
|
161
167
|
continue;
|
|
162
168
|
} // head condition must be a negatable icmp
|
|
163
169
|
|
|
@@ -171,14 +177,14 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
171
177
|
// expression, where C's own short-circuit re-guards them. Any side effect ⇒ DECLINE the fold — the
|
|
172
178
|
// merge-variable spelling the fall-through leaves is correct (the side effect stays in B's block),
|
|
173
179
|
// just possibly non-matching.
|
|
174
|
-
if (bfeed.ops.slice(0, -1).some((op) =>
|
|
180
|
+
if (bfeed.ops.slice(0, -1).some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
|
|
175
181
|
continue;
|
|
176
182
|
}
|
|
177
183
|
bfeed.ops.slice(0, -1).forEach(before); // hoist B's pure body (defines Vb; harmless if a dead const)
|
|
178
184
|
let condSide = cond;
|
|
179
185
|
if (wantNeg) {
|
|
180
186
|
condSide = mkValue(T.unk(32));
|
|
181
|
-
before(mkOp(
|
|
187
|
+
before(mkOp(NEGATED_ICMP[condDef.opcode], { operands: [...condDef.operands], results: [condSide] }));
|
|
182
188
|
}
|
|
183
189
|
// Vb const → the phi reduces to the (possibly negated) condition; Vb bool → a && / || connective.
|
|
184
190
|
let res = condSide;
|
|
@@ -205,3 +211,239 @@ export function recognizeShortCircuit(fn: Fn): boolean {
|
|
|
205
211
|
}
|
|
206
212
|
return changed;
|
|
207
213
|
}
|
|
214
|
+
|
|
215
|
+
// ── the CONTROL-FLOW form ───────────────────────────────────────────────────────────────────────
|
|
216
|
+
//
|
|
217
|
+
// `if (a || b) X else Y` produces no value: it is two `cond_br` blocks that SHARE a target.
|
|
218
|
+
//
|
|
219
|
+
// ^h: cond_br c1, ^X, ^g <- `a`
|
|
220
|
+
// ^g: … ; cond_br c2, ^Y, ^X <- `b` (sole predecessor ^h)
|
|
221
|
+
//
|
|
222
|
+
// Nothing in the tower recognizes that today, so the structurer reaches ^X from two arms and
|
|
223
|
+
// TAIL-DUPLICATES it — `if (a) X else { if (b') Y else X }`. The duplicate is correct C but it is
|
|
224
|
+
// not the C the compiler compiled, and the duplicated tail costs every byte it contains.
|
|
225
|
+
//
|
|
226
|
+
// The fold rewrites ^h's terminator to one `cond_br` over a connective and drops ^g. Which
|
|
227
|
+
// connective, and which successor slot, follows from WHICH of ^h's edges leads to ^g:
|
|
228
|
+
//
|
|
229
|
+
// ^g is ^h's FALL → ^g runs iff !c1, so the SHARED block is taken iff `c1 || cShared`
|
|
230
|
+
// ⇒ cond_br(logic_or(c1, cShared))[shared, other]
|
|
231
|
+
// ^g is ^h's TAKEN → ^g runs iff c1, so the OTHER block is taken iff `c1 && cOther`
|
|
232
|
+
// ⇒ cond_br(logic_and(c1, cOther))[other, shared]
|
|
233
|
+
//
|
|
234
|
+
// where `cShared`/`cOther` is ^g's own condition ORIENTED at that block — ^g's `cond_br` operand
|
|
235
|
+
// when it already branches there, otherwise its negation (so ^g's condition must be a negatable
|
|
236
|
+
// icmp, exactly as in the value form). Both spellings keep ^h's original successor ORDER for the
|
|
237
|
+
// edge that did not change, so the branch sense the frontend read out of the asm is preserved.
|
|
238
|
+
//
|
|
239
|
+
// REFUSALS (each one a real way this could be wrong, not a hypothetical):
|
|
240
|
+
//
|
|
241
|
+
// - ^g is the ENTRY block. `predecessors()` walks successor edges only — it does not model the
|
|
242
|
+
// implicit edge into `fn.blocks[0]` — so an entry block that is ALSO a loop header (its one
|
|
243
|
+
// real predecessor being its own latch) passes the sole-predecessor test below while the whole
|
|
244
|
+
// soundness argument fails for it: on the first iteration ^g runs BEFORE ^h, so hoisting ^g's
|
|
245
|
+
// body into ^h reorders it, and deleting ^g moves the entry to another block entirely. That
|
|
246
|
+
// turns an entry-guarded `while` into a `do…while` whose body runs once unconditionally —
|
|
247
|
+
// silent wrong code, caught by no contract (verify, assertResolved and assertDerefsTyped all
|
|
248
|
+
// pass). MIPS and PPC reach this: only thumb.ts inserts a synthetic preheader that would give
|
|
249
|
+
// the header a second predecessor. `retsink.ts` guards the same way (`fn.blocks[0] !== m`).
|
|
250
|
+
// - ^g has a predecessor other than ^h — folding would delete a block still reachable elsewhere.
|
|
251
|
+
// - ^g has block params — ^h's edge binds them, and dropping the edge drops the binding.
|
|
252
|
+
// - ^h and ^g both test the SAME value against CONSTANTS — that is a comparison-tree `switch`,
|
|
253
|
+
// not a hand-written `||`. switch-recover.ts requires every test's `cond_br` operand to be an
|
|
254
|
+
// `icmp` (its `isCmpOpcode` gate), and a `logic_or` is not one, so folding first PERMANENTLY
|
|
255
|
+
// disqualifies the recovery and a clean `switch (x) { case 1: case 2: … }` degrades to a chain
|
|
256
|
+
// of nested `if`s. The switch is the better recovery and it is the more specific one, so it
|
|
257
|
+
// wins the shape. Cost: a genuine source-level `x == 1 || x == 2` that is NOT part of a wider
|
|
258
|
+
// tree also declines — the same conservative trade loops.ts makes when it refuses to infer a
|
|
259
|
+
// header from `cond_br` shape.
|
|
260
|
+
// - ^g holds a side effect — its ops move into ^h, which runs UNCONDITIONALLY. A store in `b`
|
|
261
|
+
// would then execute even when `a` already decided the branch. (`a || (*p = 1)`.)
|
|
262
|
+
// - a value defined in ^g is used outside ^g, or used more than once. Then the structurer
|
|
263
|
+
// MATERIALIZES it into a local, which renders as a statement BEFORE the `if` — turning `b`'s
|
|
264
|
+
// conditional computation into an unconditional one. Single-use-and-local is precisely the
|
|
265
|
+
// shape analysis.ts inlines into the connective's right operand, where C's own short-circuit
|
|
266
|
+
// re-guards it. This is what keeps a load in `b` from being hoisted across the guard in `a`.
|
|
267
|
+
// - the two edges into the shared block carry DIFFERENT args. Only one edge survives the fold,
|
|
268
|
+
// so it can only carry one argument list; picking either would silently drop the other path's
|
|
269
|
+
// phi input.
|
|
270
|
+
// - ^g's two successors are the same block, or ^g's condition is not a negatable icmp when the
|
|
271
|
+
// orientation needs negating.
|
|
272
|
+
//
|
|
273
|
+
// Every refusal falls through untouched, leaving the tail-duplicated spelling — a miss, never a
|
|
274
|
+
// miscompile. Applied ITERATIVELY, so `a || b || c` folds left-to-right, each round consuming one
|
|
275
|
+
// more condition block.
|
|
276
|
+
export function recognizeBranchShortCircuit(fn: Fn): boolean {
|
|
277
|
+
let changed = false;
|
|
278
|
+
const term = (b: Block) => b.ops[b.ops.length - 1];
|
|
279
|
+
let progress = true;
|
|
280
|
+
while (progress) {
|
|
281
|
+
progress = false;
|
|
282
|
+
const defs = defOpMap(fn);
|
|
283
|
+
const preds = predecessors(fn);
|
|
284
|
+
outer: for (const h of fn.blocks) {
|
|
285
|
+
const ht = term(h);
|
|
286
|
+
if (ht.opcode !== 'cond_br') {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const [taken, fall] = ht.successors;
|
|
290
|
+
// Try ^g = the fall edge, then ^g = the taken edge. `gIsFall` picks the connective.
|
|
291
|
+
for (const gIsFall of [true, false]) {
|
|
292
|
+
const gEdge = gIsFall ? fall : taken;
|
|
293
|
+
const sharedFromH = gIsFall ? taken : fall;
|
|
294
|
+
const g = gEdge.block;
|
|
295
|
+
if (g === h || g === sharedFromH.block || g.params.length > 0) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// The ENTRY block is never ^g — see the REFUSALS note. `predecessors()` cannot see the
|
|
299
|
+
// implicit entry edge, so this is the only thing standing between an entry-block loop
|
|
300
|
+
// header and a silently reordered function body.
|
|
301
|
+
if (g === fn.blocks[0]) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if ((preds.get(g) ?? []).length !== 1) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const gt = term(g);
|
|
308
|
+
if (gt.opcode !== 'cond_br') {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const [gTaken, gFall] = gt.successors;
|
|
312
|
+
if (gTaken.block === gFall.block) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// A comparison TREE over one scrutinee belongs to switch recovery, not to this fold.
|
|
316
|
+
if (sameScrutineeConstTests(defs, ht.operands[0], gt.operands[0])) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
// ^g's body must be pure, and every value it defines must be consumed only by ^g itself —
|
|
320
|
+
// see the REFUSALS note: an escaping or reused value becomes a statement hoisted out of the
|
|
321
|
+
// short circuit.
|
|
322
|
+
// HOIST_UNSAFE_OPS, not EFFECTFUL_OPS: a live `opaque` is an instruction asmlift could not
|
|
323
|
+
// model, and moving it out of the arm that guards it is the reordering this refuses. Loud
|
|
324
|
+
// either way today (a decline under `onGap: 'strict'`, an ASMLIFT_ERROR marker under
|
|
325
|
+
// `annotate`, the CLI and benchmark default), so this closes a model gap rather than fixing
|
|
326
|
+
// an observed bug.
|
|
327
|
+
const body = g.ops.slice(0, -1);
|
|
328
|
+
if (body.some((op) => HOIST_UNSAFE_OPS.has(op.opcode))) {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!definedValuesStayLocal(fn, g)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
// Which of ^g's edges rejoins ^h's other successor? That is the shared block.
|
|
335
|
+
const sharedEdge =
|
|
336
|
+
gTaken.block === sharedFromH.block ? gTaken : gFall.block === sharedFromH.block ? gFall : null;
|
|
337
|
+
if (!sharedEdge) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const otherEdge = sharedEdge === gTaken ? gFall : gTaken;
|
|
341
|
+
if (!sameArgs(sharedFromH.args, sharedEdge.args)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
// The second operand, oriented at the block whose slot it decides: `logic_or` asks "does ^g
|
|
345
|
+
// reach the SHARED block", `logic_and` asks "does ^g reach the OTHER block".
|
|
346
|
+
const wantEdge = gIsFall ? sharedEdge : otherEdge;
|
|
347
|
+
const c2 = gt.operands[0];
|
|
348
|
+
const c2Def = defs.get(c2);
|
|
349
|
+
let second = c2;
|
|
350
|
+
const negated: Op[] = [];
|
|
351
|
+
if (wantEdge !== gTaken) {
|
|
352
|
+
if (!c2Def || !NEGATED_ICMP[c2Def.opcode]) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
second = mkValue(T.unk(32));
|
|
356
|
+
negated.push(mkOp(NEGATED_ICMP[c2Def.opcode], { operands: [...c2Def.operands], results: [second] }));
|
|
357
|
+
}
|
|
358
|
+
const res = mkValue(T.unk(32));
|
|
359
|
+
const connective = mkOp(gIsFall ? 'logic_or' : 'logic_and', {
|
|
360
|
+
operands: [ht.operands[0], second],
|
|
361
|
+
results: [res],
|
|
362
|
+
});
|
|
363
|
+
// ^g's body moves ahead of ^h's terminator; ^h keeps the successor SLOT that did not change
|
|
364
|
+
// (taken=shared for `||`, taken=other for `&&`), so the frontend's branch sense survives.
|
|
365
|
+
h.ops.splice(h.ops.length - 1, 1, ...body, ...negated, connective, {
|
|
366
|
+
...mkOp('cond_br', { operands: [res] }),
|
|
367
|
+
successors: gIsFall
|
|
368
|
+
? [
|
|
369
|
+
{ block: sharedEdge.block, args: [...sharedEdge.args] },
|
|
370
|
+
{ block: otherEdge.block, args: [...otherEdge.args] },
|
|
371
|
+
]
|
|
372
|
+
: [
|
|
373
|
+
{ block: otherEdge.block, args: [...otherEdge.args] },
|
|
374
|
+
{ block: sharedEdge.block, args: [...sharedEdge.args] },
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
fn.blocks = fn.blocks.filter((x) => x !== g);
|
|
378
|
+
changed = true;
|
|
379
|
+
progress = true;
|
|
380
|
+
break outer; // defs/preds are stale after the mutation — recompute on the next round
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return changed;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Do `c1` and `c2` compare the SAME value against CONSTANTS? That is the signature of a
|
|
388
|
+
* comparison-tree `switch`, which switch-recover.ts owns — see the REFUSALS note. Equality tests
|
|
389
|
+
* only: a switch tree dispatches on `==`/`!=`, while a RELATIONAL pair (`x >= lo && x <= hi`, the
|
|
390
|
+
* range check) is a genuine connective this fold should still take. */
|
|
391
|
+
function sameScrutineeConstTests(defs: Map<Value, Op>, c1: Value, c2: Value): boolean {
|
|
392
|
+
const eqTest = (v: Value): { scrutinee: Value } | null => {
|
|
393
|
+
const d = defs.get(v);
|
|
394
|
+
if (!d || (d.opcode !== 'icmp_eq' && d.opcode !== 'icmp_ne')) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
const [x, y] = d.operands;
|
|
398
|
+
const xc = defs.get(x)?.opcode === 'const';
|
|
399
|
+
const yc = defs.get(y)?.opcode === 'const';
|
|
400
|
+
// exactly one side constant — `x == y` between two variables is no switch test
|
|
401
|
+
return xc === yc ? null : { scrutinee: xc ? y : x };
|
|
402
|
+
};
|
|
403
|
+
const a = eqTest(c1);
|
|
404
|
+
const b = eqTest(c2);
|
|
405
|
+
return a !== null && b !== null && a.scrutinee === b.scrutinee;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** True when every value `g` defines is read at most once, and any read is inside `g`.
|
|
409
|
+
*
|
|
410
|
+
* The VALUE form above needs no such check, and the asymmetry is real rather than drift: its feeder
|
|
411
|
+
* ends in `br M`, so the feeder has no successor of its own to dominate and every value it defines
|
|
412
|
+
* is either read in the feeder or carried to `M` as the phi argument the fold consumes. Here ^g
|
|
413
|
+
* ends in `cond_br` and its `other` successor IS ^g-dominated, so a ^g-defined value genuinely can
|
|
414
|
+
* escape, and only this check stops it.
|
|
415
|
+
*
|
|
416
|
+
* An earlier version of this note justified the asymmetry by "the feeder dominates nothing but
|
|
417
|
+
* itself because M has 2+ predecessors", and told the reader not to unify the guards. That was
|
|
418
|
+
* WRONG — the entry block dominates every block whatever M's predecessor count — and it was wrong
|
|
419
|
+
* about the one guard the two folds genuinely DO share, the `fn.blocks[0]` refusal, which the value
|
|
420
|
+
* form was missing entirely. Both now have it. When changing either fold, check the other. */
|
|
421
|
+
function definedValuesStayLocal(fn: Fn, g: Block): boolean {
|
|
422
|
+
const defined = new Set<Value>(g.ops.flatMap((op) => op.results));
|
|
423
|
+
if (defined.size === 0) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
const uses = new Map<Value, number>();
|
|
427
|
+
for (const b of fn.blocks) {
|
|
428
|
+
for (const op of b.ops) {
|
|
429
|
+
for (const v of [...op.operands, ...op.successors.flatMap((s) => s.args)]) {
|
|
430
|
+
if (!defined.has(v)) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (b !== g) {
|
|
434
|
+
return false; // escapes ^g — the structurer would render it before the `if`
|
|
435
|
+
}
|
|
436
|
+
uses.set(v, (uses.get(v) ?? 0) + 1);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// ZERO uses is fine — a dead op renders nothing at all, so it cannot escape the short circuit.
|
|
441
|
+
// TWO or more is not: analysis.ts materializes a multi-consumer value into a local, which is a
|
|
442
|
+
// statement, and a statement lands before the `if`.
|
|
443
|
+
return [...defined].every((v) => (uses.get(v) ?? 0) <= 1);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Two successor argument lists that a fold may collapse into one: same values, same order. */
|
|
447
|
+
function sameArgs(a: Value[], b: Value[]): boolean {
|
|
448
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
449
|
+
}
|
package/src/raise/structs.ts
CHANGED
|
@@ -196,8 +196,18 @@ export function recognizeStructs(fn: Fn): number {
|
|
|
196
196
|
return count;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/** The distinct struct types this function
|
|
200
|
-
* deduped by name and sorted, for the backend to declare above the function.
|
|
199
|
+
/** The distinct struct types this function's L2 GRAPH mentions (unwrapping struct pointers on every
|
|
200
|
+
* value), deduped by name and sorted, for the backend to declare above the function.
|
|
201
|
+
*
|
|
202
|
+
* "Mentions", not "references": this walks `fn.blocks` at the moment structuring runs, and the
|
|
203
|
+
* result is CACHED on the SFn (`structure.ts`) and carried by every later `{...sfn}` pass, so a
|
|
204
|
+
* struct whose last use a subsequent L3 pass removed would still be declared. Sibling `locals` is
|
|
205
|
+
* reference-pruned after dead-store elimination (`l3/dce.ts`) for exactly that reason; this list is
|
|
206
|
+
* not. No pass drops such a use today — the IR-level DCE runs long before recognition, and l3/dce's
|
|
207
|
+
* `mustKeep` never drops a `field`/`index` — so the staleness is LATENT, not live, which is why it
|
|
208
|
+
* is recorded here rather than fixed speculatively. The fix, if a pass ever makes it reachable, is
|
|
209
|
+
* the one l3/symbol-refs.ts already applies to symbol references: derive at the consumption point
|
|
210
|
+
* (`backend/cfamily.ts` structDecls) instead of caching. */
|
|
201
211
|
export function collectStructs(fn: Fn): StructType[] {
|
|
202
212
|
const seen = new Map<string, StructType>();
|
|
203
213
|
const consider = (t: IrType) => {
|
package/src/rank.ts
CHANGED
|
@@ -15,9 +15,12 @@ import { frontendFor } from './frontend/registry';
|
|
|
15
15
|
import { Fn, type Value, defOpMap } from './ir/core';
|
|
16
16
|
import { T } from './ir/types';
|
|
17
17
|
import { verify } from './ir/verify';
|
|
18
|
+
import { materializeArgBases } from './l3/argbase';
|
|
18
19
|
import type { LanguageBackend, SFn } from './l3/ast';
|
|
20
|
+
import { coalesceCandidates } from './l3/coalesce';
|
|
19
21
|
import { registerishSpellings } from './l3/regspell';
|
|
20
22
|
import { reindexWalks } from './l3/reindex';
|
|
23
|
+
import { hoistScopedBases } from './l3/scopebase';
|
|
21
24
|
import { type SymbolRef, collectSymbolRefs } from './l3/symbol-refs';
|
|
22
25
|
import { RewritePattern } from './pattern/engine';
|
|
23
26
|
import { applyIdiomPatterns, raiseRecovered, structureChecked } from './pipeline';
|
|
@@ -117,12 +120,30 @@ export interface EnumerateOptions {
|
|
|
117
120
|
asmData?: AsmData;
|
|
118
121
|
/** address→symbol map (symbols.ts) — same contract as DecompileOptions.symbols */
|
|
119
122
|
symbols?: SymbolMap;
|
|
123
|
+
/** Called when a re-spelling lever THROWS or fails a boundary contract, so the failure is visible
|
|
124
|
+
* instead of the candidate silently not existing. Enumeration continues either way — the primary
|
|
125
|
+
* spelling is unaffected — but a lever that never fires because it always throws is a defect, and
|
|
126
|
+
* without this it looks identical to a lever that correctly declined. */
|
|
127
|
+
onLeverError?: (label: string, error: string) => void;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
|
-
/** One distinct candidate spelling
|
|
130
|
+
/** One distinct candidate spelling — a point in the axis cross (signedness × branch sense ×
|
|
131
|
+
* def-site anchoring × bitfield spelling × symbol-map variant, plus the L3 re-spellings) —
|
|
132
|
+
* emitted to source. */
|
|
123
133
|
export interface Candidate {
|
|
124
134
|
label: string;
|
|
125
135
|
source: string;
|
|
136
|
+
/** Which PREFERENCE GROUP this spelling belongs to — the symbol-variant index (0 = the map's own
|
|
137
|
+
* named spellings, 1 = their `/raw-globals` siblings). Enumeration emits the groups in
|
|
138
|
+
* preference order, and a lower group WINS a score tie: when both compile to the same bytes the
|
|
139
|
+
* reader should get `gCounter.field`, not a byte offset off a hoisted `(u8 *)` base.
|
|
140
|
+
*
|
|
141
|
+
* Carried structurally rather than left to enumeration order because the readability tie-break
|
|
142
|
+
* (compareScored) must compare only spellings that are genuinely alternatives of the same
|
|
143
|
+
* thing. Ranking a named spelling against a raw-address one on cast count is not a readability
|
|
144
|
+
* comparison at all — the raw form's `(u8 *)` base is not counted, so it would win by
|
|
145
|
+
* construction, trading named struct fields for anonymous byte offsets. */
|
|
146
|
+
group: number;
|
|
126
147
|
/** the map-derived VALUE references this candidate's tree contains — what the scoring
|
|
127
148
|
* layer's declaration synthesis renders. DERIVED, never carried: computed once from the
|
|
128
149
|
* exact tree this candidate's source was emitted from, at the moment the candidate is
|
|
@@ -179,10 +200,32 @@ export function enumerateCandidates(
|
|
|
179
200
|
// and let the differ referee. The default sense is always among them, so this never scores
|
|
180
201
|
// worse; it only wins where the flip matches.
|
|
181
202
|
const defSense = baseOpts.preserveDivergentBranchSense ?? true;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
// `/defsite` — def-site-anchored constant merge copies (structure.ts anchorConstCopies) — is a
|
|
204
|
+
// structuring axis on the same footing as branch sense: where the asm materialized a merge
|
|
205
|
+
// constant is placement evidence, but whether the SOURCE spelled it there is genuinely
|
|
206
|
+
// ambiguous, so both placements are emitted and the differ referees. Crossed with branch sense
|
|
207
|
+
// (an anchored copy empties an arm, which is exactly what changes which sense wins); the dedup
|
|
208
|
+
// below collapses every variant the anchoring left unchanged.
|
|
209
|
+
const baseSense = [
|
|
210
|
+
{ suffix: '', sense: defSense, anchor: false, bitfields: true },
|
|
211
|
+
{ suffix: '/flip-branch', sense: !defSense, anchor: false, bitfields: true },
|
|
212
|
+
{ suffix: '/defsite', sense: defSense, anchor: true, bitfields: true },
|
|
213
|
+
{ suffix: '/flip-branch/defsite', sense: !defSense, anchor: true, bitfields: true },
|
|
185
214
|
];
|
|
215
|
+
// `/no-bitfield` — keep the honest shift spelling where the map would name a bitfield member.
|
|
216
|
+
// The named read recompiles at the DECLARATION's access width; where that diverges from the
|
|
217
|
+
// asm's load width, the shifts are the spelling that matches — so both are emitted and the
|
|
218
|
+
// differ referees. Enumerated only when the map carries any bitfield member at all (checked
|
|
219
|
+
// below), so the 2× cross is paid exactly by the functions it can help; the dedup collapses
|
|
220
|
+
// every variant where no fold fired.
|
|
221
|
+
const mapHasBitfields =
|
|
222
|
+
opts.symbols !== undefined &&
|
|
223
|
+
[...opts.symbols.values()].some((infos) =>
|
|
224
|
+
infos.some((i) => [...(i.layout ?? []), ...(i.pointee?.layout ?? [])].some((f) => f.bitWidth !== undefined)),
|
|
225
|
+
);
|
|
226
|
+
const senseCands = mapHasBitfields
|
|
227
|
+
? [...baseSense, ...baseSense.map((s) => ({ ...s, suffix: `${s.suffix}/no-bitfield`, bitfields: false }))]
|
|
228
|
+
: baseSense;
|
|
186
229
|
// Probe: recover ONCE with no signedness pin, to learn which entry params are pointers/aggregates
|
|
187
230
|
// so they are excluded from the signedness axis (see NO_PIN_KINDS). One extra lift+recover, no
|
|
188
231
|
// compile. (The probe deliberately stops after recoverTypes — it only reads the param KINDS, so
|
|
@@ -211,7 +254,7 @@ export function enumerateCandidates(
|
|
|
211
254
|
{ suffix: '/raw-globals', symbols: undefined },
|
|
212
255
|
]
|
|
213
256
|
: [{ suffix: '' }];
|
|
214
|
-
for (const sv of symbolVariants) {
|
|
257
|
+
for (const [svIndex, sv] of symbolVariants.entries()) {
|
|
215
258
|
const svOpts = sv.symbols ? baseOpts : { ...baseOpts, symbols: undefined };
|
|
216
259
|
for (const cand of SIGN_CANDS) {
|
|
217
260
|
const fn = frontend.lift(name, asm, target, prototypes, opts.asmData, sv.symbols);
|
|
@@ -223,7 +266,23 @@ export function enumerateCandidates(
|
|
|
223
266
|
for (const s of senseCands) {
|
|
224
267
|
// structure() reads `fn` and produces a fresh SFn (it does not mutate `fn`), so both branch
|
|
225
268
|
// senses structure the same recovered function without re-lifting.
|
|
226
|
-
|
|
269
|
+
let sfn: SFn;
|
|
270
|
+
try {
|
|
271
|
+
sfn = structureChecked(fn, {
|
|
272
|
+
...svOpts,
|
|
273
|
+
preserveDivergentBranchSense: s.sense,
|
|
274
|
+
anchorConstCopies: s.anchor,
|
|
275
|
+
spellBitfieldMembers: s.bitfields,
|
|
276
|
+
});
|
|
277
|
+
} catch (e) {
|
|
278
|
+
if (!s.anchor && s.bitfields) {
|
|
279
|
+
throw e; // the base axes keep their behavior: a structuring failure aborts the row
|
|
280
|
+
}
|
|
281
|
+
// an anchored variant that fails structuring or its contracts is a dropped lever, never
|
|
282
|
+
// an aborted enumeration — same rule as respell below
|
|
283
|
+
opts.onLeverError?.(name + s.suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
227
286
|
// The walk→index re-spelling (l3/reindex.ts) is a THIRD lever on the same footing as
|
|
228
287
|
// signedness and branch sense: whether the source spelled `*p; p++` or `arr[i]` is
|
|
229
288
|
// genuinely ambiguous from asm (compilers strength-reduce the latter into the former), so
|
|
@@ -266,24 +325,72 @@ export function enumerateCandidates(
|
|
|
266
325
|
// to the user — a semantically-wrong re-spelling there is plausible-but-wrong output, the
|
|
267
326
|
// defect class this project exists to avoid. Hence each lever's decline-over-approximate
|
|
268
327
|
// gates, adversarially audited.
|
|
269
|
-
|
|
328
|
+
// Takes a THUNK, so the lever's own computation is inside the try too. A lever that threw
|
|
329
|
+
// from the pass itself — rather than from the contracts or the backend — would escape and
|
|
330
|
+
// abort the whole enumeration for this row, primary included: the one way a lever can cost
|
|
331
|
+
// a match. Making that structural rather than per-call-site means no lever can opt out.
|
|
332
|
+
const respell = (suffix: string, make: () => SFn | null | undefined): void => {
|
|
270
333
|
try {
|
|
334
|
+
const alt = make();
|
|
335
|
+
if (!alt) {
|
|
336
|
+
return; // the lever declined to fire — no candidate, not a duplicate of the primary
|
|
337
|
+
}
|
|
271
338
|
assertResolved(alt);
|
|
272
339
|
assertDerefsTyped(alt);
|
|
273
340
|
spellings.push({ suffix, source: backend.emit(alt), ...refsOf(alt) });
|
|
274
|
-
} catch {
|
|
275
|
-
// contract
|
|
341
|
+
} catch (e) {
|
|
342
|
+
// A throwing lever, a contract failure, or an unspellable re-spelling: keep the primary.
|
|
343
|
+
// REPORTED, not swallowed. `dropped` (below) records only spellings the SCORER refused,
|
|
344
|
+
// so without this a lever that fails here vanishes with no trace — indistinguishable
|
|
345
|
+
// from one that correctly declined, which is exactly the hidden failure
|
|
346
|
+
// DroppedCandidate exists to surface.
|
|
347
|
+
opts.onLeverError?.(name + suffix, e instanceof Error ? e.message.split('\n')[0] : String(e));
|
|
276
348
|
}
|
|
277
349
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
350
|
+
// `/argbase` — name a call's argument bases before the call (l3/argbase.ts). A lever on the
|
|
351
|
+
// same footing as the others: the primary inline spelling stays in the list, so the differ
|
|
352
|
+
// referees and this can never cost a match.
|
|
353
|
+
respell('/argbase', () => materializeArgBases(sfn));
|
|
354
|
+
// `/scopebase` — name a reused global base at the INNERMOST scope holding its uses
|
|
355
|
+
// (l3/scopebase.ts). Distinct from basecse's function-top hoist, which the primary already
|
|
356
|
+
// carries: this one fires exactly where that placement would extend a live range the
|
|
357
|
+
// original never had.
|
|
358
|
+
// `/scopebase`, and its COALESCED variants. Which locals a register allocator shared is not
|
|
359
|
+
// derivable from the tree — on the row this was built for the two legal merges score 18 and
|
|
360
|
+
// 40 against a no-merge 21, so committing to one by declaration order costs 19 points and
|
|
361
|
+
// discards the winner. Every variant is emitted and the differ referees, exactly as
|
|
362
|
+
// `/regcopy` does for its allocator-ambiguous tail choice.
|
|
363
|
+
//
|
|
364
|
+
// POLICY NOTE: rank.ts's rule is that re-spellings derive from the BASE spelling only —
|
|
365
|
+
// levers do not compose. These are not a second lever composed onto the first: coalescing is
|
|
366
|
+
// enumerated as alternative OUTPUTS of the base hoist, in the one place that knows the hoist
|
|
367
|
+
// just happened. The un-coalesced `/scopebase` stays in the list, so nothing is lost.
|
|
368
|
+
//
|
|
369
|
+
// EVERY pass invocation stays INSIDE a thunk — see the paragraph above on why a pass that
|
|
370
|
+
// runs outside `respell`'s try is the one way a lever can cost a match. `enumerate` re-runs
|
|
371
|
+
// the hoist per candidate, which is pure and cheap, rather than caching it outside the guard.
|
|
372
|
+
respell('/scopebase', () => hoistScopedBases(sfn));
|
|
373
|
+
const enumerate = (label: string, from: () => SFn | null | undefined): void => {
|
|
374
|
+
let variants: { merged: string; sfn: SFn }[] = [];
|
|
375
|
+
try {
|
|
376
|
+
const base = from();
|
|
377
|
+
variants = base ? coalesceCandidates(base) : [];
|
|
378
|
+
} catch (e) {
|
|
379
|
+
opts.onLeverError?.(name + label, e instanceof Error ? e.message.split('\n')[0] : String(e));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
for (const c of variants) {
|
|
383
|
+
respell(`${label}-${c.merged}`, () => c.sfn);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
enumerate('/scopebase-coalesce', () => hoistScopedBases(sfn));
|
|
387
|
+
enumerate('/coalesce', () => sfn);
|
|
388
|
+
respell('/indexed', () => reindexWalks(sfn));
|
|
282
389
|
// the register-copy spelling (l3/regspell.ts): 0–3 variants (base; tail assign-back reusing
|
|
283
390
|
// the dead value var; tail assign-back into a fresh var — the tail choice is allocator-
|
|
284
391
|
// ambiguous, so both are ranked)
|
|
285
392
|
const REGCOPY_LABELS = ['/regcopy', '/regcopy-ret', '/regcopy-ret-fresh'];
|
|
286
|
-
registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, alt));
|
|
393
|
+
registerishSpellings(sfn).forEach((alt, i) => respell(REGCOPY_LABELS[i] ?? `/regcopy-${i}`, () => alt));
|
|
287
394
|
for (const sp of spellings) {
|
|
288
395
|
const source = sp.source;
|
|
289
396
|
// Collapse a spelling that produced identical source (a function with no divergent `if`
|
|
@@ -298,6 +405,7 @@ export function enumerateCandidates(
|
|
|
298
405
|
out.push({
|
|
299
406
|
label: `${cand.label}${s.suffix}${sp.suffix}${sv.suffix}`,
|
|
300
407
|
source,
|
|
408
|
+
group: svIndex,
|
|
301
409
|
...(sp.symbolRefs ? { symbolRefs: sp.symbolRefs } : {}),
|
|
302
410
|
});
|
|
303
411
|
}
|
|
@@ -332,15 +440,59 @@ export function rankBy<S extends { score: number }>(
|
|
|
332
440
|
if (results.length === 0) {
|
|
333
441
|
throw new Error(`no scorable candidate for '${symbol}': ${firstLine(lastScoreErr)}`, { cause: lastScoreErr });
|
|
334
442
|
}
|
|
335
|
-
|
|
336
|
-
// enumerateCandidates emits the symbol-map spellings before their `/raw-globals` siblings, so
|
|
337
|
-
// when both compile to the same bytes the named one wins and the reader gets `gCounter` rather
|
|
338
|
-
// than a bare address. Spelled as an explicit comparator because relying on Array#sort's
|
|
339
|
-
// stability would make the preference an accident of two unrelated decisions.
|
|
340
|
-
results.sort((a, b) => a.score.score - b.score.score || a.order - b.order);
|
|
443
|
+
results.sort(compareScored);
|
|
341
444
|
return { best: results[0], candidates: results.map(({ order: _order, ...c }) => c), dropped };
|
|
342
445
|
}
|
|
343
446
|
|
|
447
|
+
/** THE candidate ordering — score, then preference group, then readability, then enumeration
|
|
448
|
+
* order. Exported because there are TWO drivers over the same enumeration (this module's sync
|
|
449
|
+
* `rankBy` for the Node/objdiff scorer, and the webapp's async await-loop for the wasm one), and
|
|
450
|
+
* a per-driver copy would let the same input produce two different winners.
|
|
451
|
+
*
|
|
452
|
+
* SCORE dominates absolutely: the differ is the fitness function, and a tie means the axis that
|
|
453
|
+
* separates these two spellings did not change the bytes — so everything below only chooses what
|
|
454
|
+
* the READER sees, and can never cost a match.
|
|
455
|
+
*
|
|
456
|
+
* GROUP next: a named symbol-map spelling beats its `/raw-globals` sibling at equal bytes.
|
|
457
|
+
*
|
|
458
|
+
* CAST COUNT next, and only WITHIN a group. A wrong signedness pin is what manufactures casts —
|
|
459
|
+
* the C backend has to cast a shift operand back to the signedness the machine op needs, so
|
|
460
|
+
* pinning `u32` on a genuinely-signed parameter buys `s32 f(u32 a0) { return (s32)a0 >> a1; }`
|
|
461
|
+
* for the same bytes as `s32 f(s32 a0) { return a0 >> a1; }`. Before the backend synthesized that
|
|
462
|
+
* cast the wrong pin simply lost on score; now it ties, and enumeration order alone would
|
|
463
|
+
* silently install the noisier spelling.
|
|
464
|
+
*
|
|
465
|
+
* ENUMERATION ORDER last, which makes this a strict total order (indices are unique) and the
|
|
466
|
+
* result deterministic. Spelled explicitly rather than leaning on Array#sort's stability, which
|
|
467
|
+
* would make each preference an accident of two unrelated decisions. */
|
|
468
|
+
export function compareScored<S extends { score: number }>(
|
|
469
|
+
a: Candidate & { score: S; order: number },
|
|
470
|
+
b: Candidate & { score: S; order: number },
|
|
471
|
+
): number {
|
|
472
|
+
return (
|
|
473
|
+
a.score.score - b.score.score || a.group - b.group || castCount(a.source) - castCount(b.source) || a.order - b.order
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Scalar casts in a candidate's rendered source — the readability tie-break above.
|
|
478
|
+
*
|
|
479
|
+
* A TEXT count over the emitted string, matching how the benchmark's own readability metric
|
|
480
|
+
* measures the same thing (apps/benchmark/src/eval/quality.ts) — the two must agree about what
|
|
481
|
+
* "cast noise" means, or ranking optimizes for something the report then scores differently.
|
|
482
|
+
*
|
|
483
|
+
* It counts the decomp typedef vocabulary only, so a pointer or struct cast is not read as noise
|
|
484
|
+
* — those are structural spellings a candidate does not choose. And it carries `quality.ts`'s
|
|
485
|
+
* ADDRESS-CAST exemption: `(u32)&gSym` / `(s32)&gSym` is the CORRECT source spelling of integer
|
|
486
|
+
* arithmetic on a link-time address, which decomp projects write themselves. Counting it would
|
|
487
|
+
* penalize precisely the named spelling this ranking is supposed to prefer.
|
|
488
|
+
*
|
|
489
|
+
* Deterministic, and total on any string. */
|
|
490
|
+
function castCount(source: string): number {
|
|
491
|
+
const all = source.match(/\((?:u|s)(?:8|16|32)\)/g)?.length ?? 0;
|
|
492
|
+
const addr = source.match(/\((?:u|s)32\)\s*&/g)?.length ?? 0;
|
|
493
|
+
return all - addr;
|
|
494
|
+
}
|
|
495
|
+
|
|
344
496
|
/** First line of whatever the scorer threw — the compiler's own diagnostic, not a stack. */
|
|
345
497
|
function firstLine(e: unknown): string {
|
|
346
498
|
return e instanceof Error ? e.message.split('\n')[0] : String(e ?? 'no candidate produced');
|