perf(cfg): bound the reaching-defs fixpoint with a per-block visit ceiling (#2195)

The per-language verification swarm reproduced, AT PRODUCTION DEFAULTS, a
reaching-defs blow-up: a machine-generated ~2000-line all-loops function (under
DEFAULT_PDG_MAX_FUNCTION_LINES) reaches ~10k basic blocks because loops emit ~5
blocks/line, and the dataflow fixpoint is O(blocks^2.3) on deep loop nests —
measured 62s (C/C++) and 2.05s + 810MB (Go) for ONE function. maxFacts does not
help: the fact count stays LINEAR, so it never fires.

Iterative reaching-defs on a reducible CFG converges in O(loop-nesting-depth)
passes, so a worklist re-visits each block a small multiple of times for real
code. Add a maxBlockVisits ceiling (emit passes blocks.length × 64 — far beyond
any hand-written nesting depth, ~15) that bails when the fixpoint has not
converged. An unconverged fixpoint's in/out sets are not sound, so it returns
NO facts (status 'truncated', like the existing 'overflow' guard) — a per-
function coverage gap, never wrong facts. Real code is byte-identical: full cfg
suites 725 passed, bench --check fingerprints unchanged.

NOTE: computeControlDependence's O(N²) up-walk on deep post-dom chains is the
sibling concern but stays ~13ms in production (bounded by the line cap + the
CDG materialization cap); a CDG work-budget is a documented follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 21:29:59 +00:00
parent 6fed52e1a6
commit cda4cac27f
3 changed files with 69 additions and 1 deletions

View file

@ -95,6 +95,21 @@ export const REACHING_DEF_FACTS_PER_EDGE_CAP = 4;
export const DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION =
REACHING_DEF_FACTS_PER_EDGE_CAP * DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION;
/**
* Fixpoint-iteration budget for {@link computeReachingDefs}, as a multiple of
* the function's block count ({@link emitFileReachingDefs} passes
* `blocks.length × this` as `maxBlockVisits`). Iterative reaching-defs on a
* reducible CFG converges in O(loop-nesting-depth) passes, so a worklist
* re-visits each block a small multiple of times for real code; this budget
* tolerates a nesting depth far beyond any hand-written function (real code is
* ≤ ~15 deep) while truncating the pathological deep nest that otherwise drives
* the solver to O(blocks²) — measured at seconds + GB on a machine-generated
* 2000-line all-loops function whose fact count stays linear (so `maxFacts`
* never fires). Truncation degrades to a sound empty REACHING_DEF for that one
* function (status `truncated`), never wrong facts.
*/
export const DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS = 64;
export interface CfgEmitResult {
blocks: number;
edges: number;
@ -359,7 +374,10 @@ export function emitFileReachingDefs(
);
continue;
}
const r = computeReachingDefs(cfg, { maxFacts });
const r = computeReachingDefs(cfg, {
maxFacts,
maxBlockVisits: cfg.blocks.length * DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS,
});
if (r.status === 'no-facts') continue;
result.facts += r.facts.length;

View file

@ -67,6 +67,19 @@ export interface ReachingDefsLimits {
* `status: 'truncated'`. `undefined`/0 ⇒ unlimited.
*/
readonly maxFacts?: number;
/**
* Maximum total block dequeues in the dataflow fixpoint. Iterative
* reaching-defs on a reducible CFG converges in O(loop-nesting-depth) passes,
* so a worklist visits each block a small multiple of times for real code; a
* pathologically deep loop nest (machine-generated / obfuscated) drives the
* pass count — and thus the visit total — to O(blocks²) and the solver to
* seconds + GB of heap (`maxFacts` does not help: fact count stays linear).
* When the visit total exceeds this budget the fixpoint has NOT converged, so
* any facts would be unsound — the solver bails to a sound empty
* `status: 'truncated'` (like the `overflow` guard). `undefined`/0 ⇒ unlimited
* (the default for direct callers; the emit path sets a per-function budget).
*/
readonly maxBlockVisits?: number;
}
export interface FunctionDefUse {
@ -222,11 +235,24 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit
const inWorklist = new Array(n).fill(true);
let pending = n;
// Fixpoint-iteration ceiling (see ReachingDefsLimits.maxBlockVisits): bound the
// total block dequeues so a pathologically deep loop nest can't drive the
// worklist to O(blocks²). undefined/0 ⇒ unlimited.
const maxBlockVisits =
limits?.maxBlockVisits && limits.maxBlockVisits > 0 ? limits.maxBlockVisits : Infinity;
let blockVisits = 0;
while (pending > 0) {
for (const b of order) {
if (!inWorklist[b]) continue;
inWorklist[b] = false;
pending -= 1;
if (++blockVisits > maxBlockVisits) {
// Did NOT converge within the budget — the in/out sets are not at the
// fixpoint, so any facts would be unsound. Bail to a sound empty
// `truncated` result (a coverage gap, not an error), carrying the def/use
// telemetry already gathered.
return { status: 'truncated', bindings: cfg.bindings, facts: [], defCount, useCount };
}
const p = preds[b];
const inB: Lattice =

View file

@ -347,6 +347,30 @@ describe('computeReachingDefs — determinism and convergence', () => {
expect(capped.defCount).toBe(full.defCount);
expect(capped.useCount).toBe(full.useCount);
});
it('maxBlockVisits ceiling: a budget below convergence bails to a sound empty truncated', () => {
// entry → body (self-loop, forces re-processing) → exit; body defs+uses x.
const blocks: BlockSpec[] = [{}, {}, { stmts: [stmt(3, [0], [0])] }];
const edges: [number, number][] = [
[0, 2],
[2, 2], // self-loop → the fixpoint re-visits block 2
[2, 1],
];
// Unbounded (and a generous budget) converge with the loop-carried fact.
const full = computeReachingDefs(mkCfg(blocks, edges, ['x']));
expect(full.status).toBe('computed');
expect(full.facts.length).toBeGreaterThan(0);
const budgeted = computeReachingDefs(mkCfg(blocks, edges, ['x']), { maxBlockVisits: 1000 });
expect(budgeted.status).toBe('computed');
expect(render(budgeted.facts)).toEqual(render(full.facts)); // byte-identical for normal code
// A budget below convergence cannot reach the fixpoint, so facts would be
// unsound → return NONE (sound), status 'truncated', telemetry preserved.
const capped = computeReachingDefs(mkCfg(blocks, edges, ['x']), { maxBlockVisits: 1 });
expect(capped.status).toBe('truncated');
expect(capped.facts).toEqual([]);
expect(capped.defCount).toBe(full.defCount);
});
});
describe('computeReachingDefs — parser-direct acceptance (with U1/U2)', () => {