@asmlift/core 0.4.0 → 0.5.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.
@@ -5,6 +5,7 @@
5
5
  // interference check in structure.ts;
6
6
  // • the effect-ordering model — which call/load defs must MATERIALIZE as named temps at
7
7
  // their own program position instead of inlining at their use.
8
+ import { globalCellOf, mayWriteGlobal } from '../ir/alias';
8
9
  import { Block, Fn, Op, Value, successorsOf } from '../ir/core';
9
10
 
10
11
  export interface UseSite {
@@ -32,7 +33,34 @@ export interface StructureAnalysis {
32
33
  memWriteBetween: (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean) => boolean;
33
34
  }
34
35
 
35
- export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
36
+ export interface AnalyzeOptions {
37
+ /** the fn's def map (`defOpMap`) — the structurer already holds one, so it is passed rather than
38
+ * rebuilt. Absent ⇒ the global-aware alias rule below cannot resolve anything and every write
39
+ * bars, exactly as before it existed. */
40
+ defs?: Map<Value, Op>;
41
+ /** THE value-home axis (rank.ts `/reread-globals`). A read of a named global is barred from
42
+ * rendering at its use by any write in between — even a store to an unrelated global, which
43
+ * cannot possibly change what it sees. That over-conservatism is what invents the locals the
44
+ * round-5 dogfood measured as its highest-cost defect ("hoists what agbcc re-reads"):
45
+ *
46
+ * gA = v; gB = v; with `s32 v = gValue;` where the source said `gA = gValue; gB = gValue;`
47
+ *
48
+ * With this on, the barrier scan for a load whose address resolves to a named global uses THE
49
+ * shared disjointness query (ir/alias.ts) instead of "any write at all". Materializing is always
50
+ * sound, so today's spelling is never wrong — only sometimes not the one the compiler was given.
51
+ * Which side matches is genuinely per-function (the same dogfood watched agbcc go both ways
52
+ * inside ONE function), so this is a differ-refereed candidate axis, never a default. */
53
+ rereadGlobals?: boolean;
54
+ /** "does the project declare this global volatile?" — a read of a volatile object may NOT be
55
+ * duplicated or moved, so the axis above refuses on one. Answers false for a symbol the map
56
+ * does not carry (and for no map at all), which is the same posture the multi-render rule has
57
+ * always had: without a declaration nothing here can know, and the differ referees the extra
58
+ * load. Where the map DOES know, the axis is silent about it rather than wrong. */
59
+ volatileGlobal?: (name: string) => boolean;
60
+ }
61
+
62
+ export function analyze(fn: Fn, returnsVoid: boolean, opts: AnalyzeOptions = {}): StructureAnalysis {
63
+ const { defs, rereadGlobals = false, volatileGlobal } = opts;
36
64
  // ── use registry ────────────────────────────────────────────────────────────────────────
37
65
  // Every use of a value, POSITIONED: the consuming op and its block/index. Successor args are
38
66
  // uses AT the terminator (they render in argAssigns at block end). A void function's `ret`
@@ -204,29 +232,116 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
204
232
  // terminator, materialized def) it inlines into, transitively through single-use pure ops.
205
233
  // null = renders in several places / unresolvable (treated conservatively by the caller).
206
234
  const emitPosCache = new Map<Op, { blk: Block; idx: number } | null>();
235
+ /** an op that renders AT ITS OWN position: a statement, a terminator, a materialized or dead def */
236
+ const anchored = (op: Op): boolean =>
237
+ op.successors.length > 0 ||
238
+ op.opcode === 'ret' ||
239
+ op.opcode === 'store' ||
240
+ op.opcode === 'astore' ||
241
+ materialize.has(op) ||
242
+ !op.results.length ||
243
+ !useSitesOf.has(op.results[0]);
244
+ const consumersOf = (op: Op): Op[] => [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))];
207
245
  const emitPos = (op: Op): { blk: Block; idx: number } | null => {
208
246
  if (emitPosCache.has(op)) {
209
247
  return emitPosCache.get(op)!;
210
248
  }
211
- const own = { blk: opBlock.get(op)!, idx: opIndex.get(op)! };
212
249
  let res: { blk: Block; idx: number } | null;
213
- if (
214
- op.successors.length ||
215
- op.opcode === 'ret' ||
216
- op.opcode === 'store' ||
217
- op.opcode === 'astore' ||
218
- materialize.has(op) ||
219
- !op.results.length ||
220
- !useSitesOf.has(op.results[0])
221
- ) {
222
- res = own; // statements, terminators, materialized/dead defs
250
+ if (anchored(op)) {
251
+ res = { blk: opBlock.get(op)!, idx: opIndex.get(op)! };
223
252
  } else {
224
- const consumers = [...new Set((useSitesOf.get(op.results[0]) ?? []).map((s) => s.op))];
253
+ const consumers = consumersOf(op);
225
254
  res = consumers.length === 1 ? emitPos(consumers[0]) : null;
226
255
  }
227
256
  emitPosCache.set(op, res);
228
257
  return res;
229
258
  };
259
+ // EVERY position a value's expression renders at — `emitPos` generalized to the whole set (it
260
+ // answers one place or gives up), by following ALL consumers transitively. That matters for
261
+ // the value-home axis: a pure expression with two consumers (`gOut = e; return e;`) has no single
262
+ // emit position, so `emitPos` answers null and every memory read feeding it is forced into a
263
+ // local — even when re-reading at both places is provably equivalent. Null only for a genuine
264
+ // cycle (defensive: SSA use-def is acyclic through ops), which the caller treats as unresolvable.
265
+ //
266
+ // NEVER for a call: two render positions mean two executions, so a call whose consumer renders in
267
+ // several places must keep answering null and materialize.
268
+ const emitPosSetCache = new Map<Op, { blk: Block; idx: number }[] | null>();
269
+ const emitPositions = (op: Op, visiting: Set<Op> = new Set()): { blk: Block; idx: number }[] | null => {
270
+ const hit = emitPosSetCache.get(op);
271
+ if (hit !== undefined) {
272
+ return hit;
273
+ }
274
+ if (visiting.has(op)) {
275
+ return null;
276
+ }
277
+ let res: { blk: Block; idx: number }[] | null;
278
+ if (anchored(op)) {
279
+ res = [{ blk: opBlock.get(op)!, idx: opIndex.get(op)! }];
280
+ } else {
281
+ visiting.add(op);
282
+ const seenPos = new Set<string>();
283
+ const acc: { blk: Block; idx: number }[] = [];
284
+ res = acc;
285
+ for (const c of consumersOf(op)) {
286
+ const sub = emitPositions(c, visiting);
287
+ if (!sub) {
288
+ res = null;
289
+ break;
290
+ }
291
+ for (const p of sub) {
292
+ const key = `${blockPos.get(p.blk)}:${p.idx}`;
293
+ if (!seenPos.has(key)) {
294
+ seenPos.add(key);
295
+ acc.push(p);
296
+ }
297
+ }
298
+ }
299
+ visiting.delete(op);
300
+ }
301
+ emitPosSetCache.set(op, res);
302
+ return res;
303
+ };
304
+ // THE def→render path discipline — one implementation, three callers (the two materialization
305
+ // rules below and structure.ts's bitfield fold, which imports it). May an op `isWrite` accepts
306
+ // execute between `def` and a statement at `render`, on any def-avoiding path? The def block's
307
+ // tail, the render block's head, and every between-block on a path; a path re-crossing the def
308
+ // is the NEXT dynamic instance and does not count. Path-based on purpose: `fn.blocks` is ADDRESS
309
+ // order, so a linear-position scan misses a block laid out after the render that executes
310
+ // between def and render on the taken path (an audit round broke exactly that way).
311
+ const memWriteBetween = (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean): boolean => {
312
+ const b = opBlock.get(def)!;
313
+ const oi = opIndex.get(def)!;
314
+ const wDirty = (list: Op[], from: number, to: number): boolean => {
315
+ for (let k = from; k < to; k++) {
316
+ if (isWrite(list[k])) {
317
+ return true;
318
+ }
319
+ }
320
+ return false;
321
+ };
322
+ // Same block: the only def-avoiding path is the straight line between the two indices
323
+ // (leaving and re-entering the block re-crosses the def). A render BEFORE the def cannot
324
+ // happen — within a block, uses follow defs — and falls through to the path walk, whose
325
+ // answer is the conservative one.
326
+ if (render.blk === b && oi < render.idx) {
327
+ return wDirty(b.ops, oi + 1, render.idx);
328
+ }
329
+ if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(render.blk.ops, 0, render.idx)) {
330
+ return true;
331
+ }
332
+ for (const x of reachAvoiding(b, b)) {
333
+ if (x === render.blk && !reachAvoiding(render.blk, b).has(render.blk)) {
334
+ continue; // acyclic render block: head checked
335
+ }
336
+ if (x !== render.blk && !reachAvoiding(x, b).has(render.blk)) {
337
+ continue; // not on a def→render path
338
+ }
339
+ if (wDirty(x.ops, 0, x.ops.length)) {
340
+ return true;
341
+ }
342
+ }
343
+ return false;
344
+ };
230
345
  // Decide in REVERSE program order so a consumer's own materialization is settled before any
231
346
  // producer asks for its emit position (SSA: uses follow defs in dominance/layout order) — and
232
347
  // iterate to a fixpoint for IR whose block layout does not follow dominance (hand-built IR):
@@ -235,6 +350,7 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
235
350
  for (let sizeBefore = -1; sizeBefore !== materialize.size;) {
236
351
  sizeBefore = materialize.size;
237
352
  emitPosCache.clear();
353
+ emitPosSetCache.clear(); // both render-position caches read `materialize`, which just grew
238
354
  for (let bi = fn.blocks.length - 1; bi >= 0; bi--) {
239
355
  const b = fn.blocks[bi];
240
356
  for (let oi = b.ops.length - 1; oi >= 0; oi--) {
@@ -274,6 +390,14 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
274
390
  if (!r || !useSitesOf.has(r)) {
275
391
  continue;
276
392
  } // dead call → exprstmt (unchanged)
393
+ // Under the value-home axis: which named global cell this op reads, if any. A constant-
394
+ // offset `load` only — an `aload`'s runtime index names no single cell, and a call reads
395
+ // everything. Null ⇒ every write bars, exactly as before.
396
+ const cell =
397
+ rereadGlobals && defs && op.opcode === 'load'
398
+ ? globalCellOf(defs, op.operands[0], op.attrs.off as number)
399
+ : null;
400
+ const barsThisRead = cell && defs && !volatileGlobal?.(cell.name) ? mayWriteGlobal(defs, cell.name) : null;
277
401
  const sites = useSitesOf.get(r)!;
278
402
  const consumers = [...new Set(sites.map((s) => s.op))];
279
403
  const isCall = op.opcode === 'call';
@@ -286,50 +410,32 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
286
410
  // per-use source spelling did (`while (*s != EOS) *d = *s;` reads *s twice per iteration),
287
411
  // so it is sound iff every render still sees the def-time memory: NO write anywhere
288
412
  // between the def and ANY render (cycle-aware, conservative write set). Otherwise a temp.
289
- if (!isCall && consumers.length > 1) {
413
+ //
414
+ // WHERE it renders. Without the axis: one position per consumer, and a consumer with no
415
+ // single position (its own value renders in several places) refuses. With the axis a load
416
+ // resolves the whole SET instead — the second half of the value-home defect, where the
417
+ // local is invented not by a barrier but because the pure expression downstream is itself
418
+ // duplicated (`gOut = (gValue << 1) + gValue; return (gValue << 1) + gValue;`). Never for a
419
+ // call: several positions there mean several executions.
420
+ const poss =
421
+ rereadGlobals && !isCall
422
+ ? emitPositions(op)
423
+ : consumers.length > 1
424
+ ? consumers.map((c) => emitPos(c))
425
+ : [emitPos(consumers[0])];
426
+ if (!poss || poss.some((p) => p === null)) {
427
+ materialize.add(op);
428
+ continue;
429
+ }
430
+ if (poss.length > 1) {
290
431
  const MW = new Set(['store', 'astore', 'call', 'opaque']);
291
- const wDirty = (list: Op[], from: number, to: number) => {
292
- for (let k = from; k < to; k++) {
293
- if (MW.has(list[k].opcode)) {
294
- return true;
295
- }
296
- }
297
- return false;
298
- };
299
- const defToRenderDirty = (q: { blk: Block; idx: number }): boolean => {
300
- // Same block: the only def-avoiding path is the straight line between the two indices
301
- // (leaving and re-entering the block re-crosses the def).
302
- if (q.blk === b && oi < q.idx) {
303
- return wDirty(b.ops, oi + 1, q.idx);
304
- }
305
- if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(q.blk.ops, 0, q.idx)) {
306
- return true;
307
- }
308
- const between = reachAvoiding(b, b);
309
- for (const x of between) {
310
- if (x === q.blk && !reachAvoiding(q.blk, b).has(q.blk)) {
311
- continue;
312
- } // acyclic render blk: head checked
313
- if (x !== q.blk && !reachAvoiding(x, b).has(q.blk)) {
314
- continue;
315
- } // not on a def→render path
316
- if (wDirty(x.ops, 0, x.ops.length)) {
317
- return true;
318
- }
319
- }
320
- return false;
321
- };
322
- const poss = consumers.map((c) => emitPos(c));
323
- if (poss.some((p) => p === null) || poss.some((p) => defToRenderDirty(p!))) {
432
+ const isWrite = barsThisRead ?? ((x: Op) => MW.has(x.opcode));
433
+ if (poss.some((p) => memWriteBetween(op, p!, isWrite))) {
324
434
  materialize.add(op);
325
435
  }
326
436
  continue;
327
437
  }
328
- const pos = emitPos(consumers[0]);
329
- if (!pos) {
330
- materialize.add(op);
331
- continue;
332
- }
438
+ const pos = poss[0]!;
333
439
  // A between-op is a BARRIER when it renders as a sequenced statement the def would cross:
334
440
  // stores/opaque always; a call/load that is dead (statement), materialized (statement), or
335
441
  // inlined into a DIFFERENT statement. A sibling effect inlined into the SAME statement is
@@ -337,6 +443,11 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
337
443
  // exactly as it originally chose to. Loads never bar a load (reads don't conflict).
338
444
  const samePos = (q: { blk: Block; idx: number } | null) => q !== null && q.blk === pos.blk && q.idx === pos.idx;
339
445
  const isBarrier = (x: Op): boolean => {
446
+ // Value-home axis: a store/astore this read is PROVABLY disjoint from (a different named
447
+ // global) does not sequence against it, so the read may still render at its use.
448
+ if (barsThisRead && (x.opcode === 'store' || x.opcode === 'astore') && !barsThisRead(x)) {
449
+ return false;
450
+ }
340
451
  if (x.opcode === 'store') {
341
452
  // A store to a PROVABLY-DISJOINT slot of the same base never aliases the load: same
342
453
  // base SSA value, both constant offset+width, ranges non-overlapping (the everyday
@@ -368,84 +479,18 @@ export function analyze(fn: Fn, returnsVoid: boolean): StructureAnalysis {
368
479
  }
369
480
  return false;
370
481
  };
371
- const gapDirty = (list: Op[], from: number, to: number) => {
372
- for (let k = from; k < to; k++) {
373
- if (isBarrier(list[k])) {
374
- return true;
375
- }
376
- }
377
- return false;
378
- };
379
- if (pos.blk === b) {
380
- if (gapDirty(b.ops, oi + 1, pos.idx)) {
381
- materialize.add(op);
382
- }
383
- continue;
384
- }
385
- // Cross-block: a call's execution would become path-dependent — always materialize. A
386
- // load may inline only if NO write exists on any DEF-AVOIDING def→render path (a path
387
- // re-crossing the def is the next dynamic instance): the def block's tail, the render
388
- // block's head, and every block between; a render block cyclic WITHOUT passing the def
389
- // (an inner loop around the render) is checked in full.
390
- if (isCall) {
482
+ // A CROSS-BLOCK call's execution would become path-dependent always materialize. Within
483
+ // its own block a call is judged like everything else, by the barrier scan below.
484
+ if (isCall && pos.blk !== b) {
391
485
  materialize.add(op);
392
486
  continue;
393
487
  }
394
- let dirty = gapDirty(b.ops, oi + 1, b.ops.length) || gapDirty(pos.blk.ops, 0, pos.idx);
395
- if (!dirty) {
396
- for (const x of reachAvoiding(b, b)) {
397
- if (x === pos.blk && !reachAvoiding(pos.blk, b).has(pos.blk)) {
398
- continue;
399
- } // acyclic render block: head checked
400
- if (x !== pos.blk && !reachAvoiding(x, b).has(pos.blk)) {
401
- continue;
402
- } // not on a def→render path
403
- if (gapDirty(x.ops, 0, x.ops.length)) {
404
- dirty = true;
405
- break;
406
- }
407
- }
408
- }
409
- if (dirty) {
488
+ // Otherwise: inline only if no barrier stands on any def-avoiding def→render path.
489
+ if (memWriteBetween(op, pos, isBarrier)) {
410
490
  materialize.add(op);
411
491
  }
412
492
  }
413
493
  }
414
494
  }
415
- // The def→render path discipline, exported for the bitfield fold's ordering gate
416
- // (structure.ts): may an op `isWrite` accepts execute between `def` and a statement at
417
- // `render`, on any def-avoiding path? Same cycle-aware rules as the materialize decisions
418
- // above — the def block's tail, the render block's head, and every between-block on a path;
419
- // a path re-crossing the def is the next dynamic instance and does not count.
420
- const memWriteBetween = (def: Op, render: { blk: Block; idx: number }, isWrite: (x: Op) => boolean): boolean => {
421
- const b = opBlock.get(def)!;
422
- const oi = opIndex.get(def)!;
423
- const wDirty = (list: Op[], from: number, to: number): boolean => {
424
- for (let k = from; k < to; k++) {
425
- if (isWrite(list[k])) {
426
- return true;
427
- }
428
- }
429
- return false;
430
- };
431
- if (render.blk === b && oi < render.idx) {
432
- return wDirty(b.ops, oi + 1, render.idx);
433
- }
434
- if (wDirty(b.ops, oi + 1, b.ops.length) || wDirty(render.blk.ops, 0, render.idx)) {
435
- return true;
436
- }
437
- for (const x of reachAvoiding(b, b)) {
438
- if (x === render.blk && !reachAvoiding(render.blk, b).has(render.blk)) {
439
- continue; // acyclic render block: head checked
440
- }
441
- if (x !== render.blk && !reachAvoiding(x, b).has(render.blk)) {
442
- continue; // not on a def→render path
443
- }
444
- if (wDirty(x.ops, 0, x.ops.length)) {
445
- return true;
446
- }
447
- }
448
- return false;
449
- };
450
495
  return { useSitesOf, opIndex, opBlock, liveIn, materialize, reachFrom, emitPos, memWriteBetween };
451
496
  }