From 2c344ddfebcbd65b152ac2944f9a3039bb8af669 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 15 Jun 2026 16:45:26 +0000 Subject: [PATCH] perf(cfg): bound the SSA value-graph, fall back to dense when oversized (#2201 review R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/core/ingestion/cfg/reaching-defs.ts | 47 +++++++++++++++ gitnexus/test/unit/cfg/reaching-defs.test.ts | 57 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts index cb60db228..f3a2e8f2a 100644 --- a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts +++ b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts @@ -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). diff --git a/gitnexus/test/unit/cfg/reaching-defs.test.ts b/gitnexus/test/unit/cfg/reaching-defs.test.ts index fb29eed2f..9fd8c39f0 100644 --- a/gitnexus/test/unit/cfg/reaching-defs.test.ts +++ b/gitnexus/test/unit/cfg/reaching-defs.test.ts @@ -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)', () => {