perf(cfg): bound the SSA value-graph, fall back to dense when oversized (#2201 review R1)

maxFacts bounds fact materialization in sweepFacts, but nothing bounded the
SSA-sparse solver's φ/value-graph construction. A high-binding-density deep
loop routed to SSA (≥16 blocks + a reachable loop) builds an O(blocks×bindings)
value graph the dense path would have truncated at its maxBlockVisits ceiling
(~1.5 GB measured on a 3000-block × 300-binding function).

Cap the value graph: after φ-placement (where nodeKeys.length == the φ count,
the input-superlinear term) plus a 2×Σgen bound on the renaming nodes, fall
back to computeInSetsDense before paying for renaming + Tarjan SCC. The fallback
is byte-identical (dense is the equivalence oracle) and bounded (dense honors
maxBlockVisits). Mirrors the existing throw/unreachable/OOB-binding gates.

The ceiling is DEFAULT_MAX_SSA_VALUE_GRAPH_NODES (1e6 — far above any real or
benchmarked function; dense-bindings/deep-nest build <1e4), overridable per call
via ReachingDefsLimits.maxSsaValueGraphNodes. The new unit test makes the
otherwise-invisible routing flip observable by pairing the cap with a tight
maxBlockVisits (dense truncates, SSA computes). Equivalence fuzz unchanged
(byte-identical, 20k CFGs green); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-15 16:45:26 +00:00
parent 7e78026e1a
commit 2c344ddfeb
2 changed files with 104 additions and 0 deletions

View file

@ -103,6 +103,19 @@ export interface ReachingDefsLimits {
* a per-function budget).
*/
readonly maxBlockVisits?: number;
/**
* Memory bound on the SSA-sparse solver's value-graph construction (#2201
* review R1). `maxFacts` bounds fact MATERIALIZATION (sweepFacts) but nothing
* bounds the φ/value-graph the sparse path builds first; a high-binding-density
* deep loop routed to SSA (≥ SSA_MIN_BLOCKS blocks + a reachable loop) builds an
* O(blocks×bindings) graph the dense path would have truncated at the
* `maxBlockVisits` ceiling (~1.5 GB measured on a 3000-block × 300-binding
* function). When the projected node count would exceed this, the sparse solver
* falls back to the dense oracle (byte-identical, and bounded — dense honors
* `maxBlockVisits`). Honored ONLY by the sparse path; the dense solver ignores
* it. `undefined`/0 ⇒ {@link DEFAULT_MAX_SSA_VALUE_GRAPH_NODES}.
*/
readonly maxSsaValueGraphNodes?: number;
}
export interface FunctionDefUse {
@ -664,6 +677,25 @@ function computeInSetsSparse(
}
}
// ── memory bound (#2201 review R1): cap the value graph, else fall back ──
// After φ-placement, nodeKeys.length == the φ-node count — the term that grows
// superlinearly with the input on the deep-loop / dense-binding pathology.
// Renaming below adds at most ~2 nodes per gen entry (already bounded by the
// def-site universe the STMT_STRIDE overflow guard caps). If the projected
// total would exceed the budget, fall back to the dense oracle here — BEFORE
// paying for renaming + Tarjan SCC on a blown-up graph. Byte-identical (dense
// is the equivalence oracle) and bounded (dense honors maxBlockVisits). Mirrors
// the throw-edge / unreachable / OOB-binding gates at the top of this function.
const nodeBudget =
limits?.maxSsaValueGraphNodes && limits.maxSsaValueGraphNodes > 0
? limits.maxSsaValueGraphNodes
: DEFAULT_MAX_SSA_VALUE_GRAPH_NODES;
let projectedRenameNodes = 0;
for (let b = 0; b < n; b++) projectedRenameNodes += (gen[b]?.size ?? 0) * 2;
if (nodeKeys.length + projectedRenameNodes > nodeBudget) {
return computeInSetsDense(cfg, n, h, adj, limits);
}
// ── renaming (iterative dominator-tree DFS, per-binding value stacks) ──
const domChildren: number[][] = Array.from({ length: nx }, () => []);
for (let b = 0; b < nx; b++) if (b !== S && idom[b] !== -1) domChildren[idom[b]].push(b);
@ -825,6 +857,21 @@ function computeInSetsSparse(
*/
const SSA_MIN_BLOCKS = 16;
/**
* Default ceiling on the SSA-sparse solver's value-graph node count (#2201
* review R1). Above this the sparse path falls back to the dense oracle (which
* bounds its own work via `maxBlockVisits`), trading the deep-loop full-facts
* win for bounded memory on pathological inputs. Sized FAR above any real or
* benchmarked function: the suite's densest SSA scenarios (`dense-bindings`,
* `deep-nest`) build well under 10⁴ nodes, while the pathology this guards
* (thousands of blocks × hundreds of bindings) builds 10⁶–10⁷. The
* `dense-bindings` / `deep-nest` `rd_scaling_budget` gates in
* bench/cfg/baselines.json fail if this is set so low it forces those scenarios
* onto the dense path. Overridable per-call via
* {@link ReachingDefsLimits.maxSsaValueGraphNodes}.
*/
const DEFAULT_MAX_SSA_VALUE_GRAPH_NODES = 1_000_000;
/**
* True iff a cycle is reachable from `entry` (the CFG has a loop). Iterative DFS
* with a gray/black coloring; a gray successor is a back-edge. O(V+E).

View file

@ -425,6 +425,63 @@ describe('computeReachingDefs — determinism and convergence', () => {
expect(prod!.status).toBe(dense.status);
expect(render(prod!.facts)).toEqual(render(dense.facts)); // byte-identical to the tolerant dense path
});
it('#2201 R1: an oversized SSA value graph falls back to the dense oracle (byte-identical)', () => {
// A ≥16-block looping multi-binding CFG → the production dispatcher routes it
// to the SSA-sparse path. `maxFacts` bounds only fact materialization, not the
// φ/value-graph the sparse path builds first; `maxSsaValueGraphNodes` caps that
// graph and falls back to the dense oracle when it would be too large. Because
// the fallback is byte-identical to dense, the routing flip is made OBSERVABLE
// via a tight `maxBlockVisits`: dense honors the ceiling (truncates), the SSA
// path ignores it (computes) — so the same budget yields different statuses
// depending on which solver ran.
const K = 4; // bindings
const blocks: BlockSpec[] = [{}, {}]; // 0 entry, 1 exit
const edges: [number, number][] = [[0, 2]];
const BODY = 18; // body blocks 2..19 → 20 blocks total (≥ SSA_MIN_BLOCKS)
for (let i = 0; i < BODY; i++) {
const b = 2 + i;
blocks[b] = { stmts: [stmt(b * 10, [i % K], [(i + 1) % K])] };
if (i < BODY - 1) edges.push([b, b + 1]);
}
edges.push([2 + BODY - 1, 2]); // back-edge → reachable loop (forces SSA dispatch)
edges.push([2, 1]); // exit
const bindings = Array.from({ length: K }, (_, i) => `v${i}`);
const mk = () => mkCfg(blocks, edges, bindings);
expect(mk().blocks.length).toBeGreaterThanOrEqual(16);
const denseFull = computeReachingDefsDense(mk());
expect(denseFull.status).toBe('computed');
expect(denseFull.facts.length).toBeGreaterThan(0);
// Tiny node cap, unbounded visits → falls back to dense → byte-identical.
const cappedUnbounded = computeReachingDefs(mk(), { maxSsaValueGraphNodes: 1 });
expect(cappedUnbounded.status).toBe(denseFull.status);
expect(render(cappedUnbounded.facts)).toEqual(render(denseFull.facts));
// Tiny node cap + tight block-visit budget → fallback to dense, whose ceiling
// then fires (truncated, empty). This is the observable proof the cap diverted
// the solve to the dense path.
const cappedBudgeted = computeReachingDefs(mk(), {
maxSsaValueGraphNodes: 1,
maxBlockVisits: 1,
});
expect(cappedBudgeted.status).toBe('truncated');
expect(cappedBudgeted.facts).toEqual([]);
// Default (huge) cap + the SAME tight budget → SSA path runs (no fixpoint
// iteration → ceiling never fires) and computes the full facts.
const uncapped = computeReachingDefs(mk(), { maxBlockVisits: 1 });
expect(uncapped.status).toBe('computed');
expect(render(uncapped.facts)).toEqual(render(denseFull.facts));
// Boundary monotonicity: a cap well above the graph stays on SSA (computes
// under the tight budget), a cap well below falls back (truncates).
const above = computeReachingDefs(mk(), { maxSsaValueGraphNodes: 100_000, maxBlockVisits: 1 });
expect(above.status).toBe('computed');
const below = computeReachingDefs(mk(), { maxSsaValueGraphNodes: 5, maxBlockVisits: 1 });
expect(below.status).toBe('truncated');
});
});
describe('computeReachingDefs — parser-direct acceptance (with U1/U2)', () => {