perf(scope): reduce post-resolution memory overhead (#3766)

* perf(scope): trim post-resolution memory

Skip signatures and solver work without indirect consumers.
Reuse emit skip sets and add per-pass heap probes.

Refs #3695

* ci(scope): gate post-resolution memory probes

Refs #3695.

* test(scope): make dense Java calls type-correct

Refs #3695.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
Gergő Magyar 2026-10-29 16:22:37 +00:00 • committed by GitHub
parent 136003377a
commit 0c7e775aad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 391 additions and 19 deletions

View file

@ -1341,12 +1341,26 @@ jobs:
- name: Measure all languages and check graph and binding baselines
run: node --import tsx bench/scope-resolution/measure.mts --check --output scope-resolution-benchmark.json
working-directory: gitnexus
# #3695 probes share this runner and artifact; the language matrix runs once.
- name: Check ordinary-call signature allocation
shell: bash
run: node --expose-gc --import tsx bench/scope-resolution/call-signature-memory.mts 500000 --check | tee scope-resolution-call-signature-memory.json
working-directory: gitnexus
- name: Measure dense Java and check its graph baseline
shell: bash
env:
GITNEXUS_DEBUG_HEAP: '1'
run: node --import tsx bench/scope-resolution/dense-java.mts 300 20 10 --check 2>&1 | tee scope-resolution-dense-java.log
working-directory: gitnexus
- name: Upload scope-resolution measurements
if: always()
uses: ./.github/actions/upload-test-artifact
with:
name: scope-resolution-benchmark
path: gitnexus/scope-resolution-benchmark.json
path: |
gitnexus/scope-resolution-benchmark.json
gitnexus/scope-resolution-call-signature-memory.json
gitnexus/scope-resolution-dense-java.log
retention-days: 5
# Locked eval suite. setup-uv and uv itself are immutable so CI exercises

View file

@ -76,3 +76,45 @@ preparation, import-target resolution, SCCs, ambiguity checks, transitive export
(only if requested), linking, wildcard expansion, bindings and workspace indexes.
`sr-extract-start` precedes `sr-post-nodeLookup`; a last `sr-post-nodeLookup`
marker therefore points into finalization after extraction.
## Post-resolution memory probes (#3695)
```sh
node --expose-gc --import tsx bench/scope-resolution/call-signature-memory.mts 500000
GITNEXUS_DEBUG_HEAP=1 node --import tsx bench/scope-resolution/dense-java.mts 300 20 10
```
CI runs both probes once with `--check` in the existing
`scope-resolution-benchmark` job, alongside its all-language measurements.
The ordinary-call check rejects any retained signatures. The dense Java check
requires the default 300/20/10 workload and its reviewed full graph fingerprint
below. Heap and timing remain measurements, without absolute thresholds.
Both outputs join the existing `scope-resolution-benchmark` artifact: the
collector's JSON and dense Java's log, including `sr-emit-*` heap checkpoints.
Omit `--check` for comparisons against older revisions or custom workloads.
The first probe isolates ordinary calls with no indirect-call facts. Previously
the deferred-call collector retained a signature object and position key for
every call even though none could be consumed. At 500,000 calls, three fresh
processes measured a median 90.29 MiB retained overhead before the change and
0.08 MiB afterward. With `--max-old-space-size=160`, the old collector exhausted
the heap (exit 134); the updated collector completed. Input records remain live
through both measurements. This measures one allocation source, not total
indexing memory.
The second probe uses real worker parsing and scope resolution on generated
Java: 300 files, 20 methods per file, 10 receiver calls per method. It reports
per-stage heap samples and a fingerprint of complete nodes and relationships.
Before and after the change it produced 66,300 nodes and 126,600 relationships,
with fingerprint `3209302e91e7023328464540ec7d0ac72ac03bf49fa73303817a56ed3d415ef5`.
It uses the in-memory graph helper, so its RSS is not a measurement of the CLI's
streaming graph sink. Calls pass `null` to match each receiver's parameter type;
the 300-file workload compiles with `javac --release 17`. The probe measures
analysis and does not execute the generated Java.
`GITNEXUS_DEBUG_HEAP=1` now adds `sr-emit-*` checkpoints after receiver calls,
free calls, replay, references, property passes, callable flow, imports and
provider hooks, including elapsed emit time and graph counts. `sr-post-resolve`
marks the end of the initial registry lookup only; substantial resolution still
follows it. These markers distinguish that work on the next reporter run.
The private repository's complete OOM has not been reproduced by these probes.

View file

@ -0,0 +1,59 @@
/** #3695: retained overhead for ordinary calls with no indirect invocations.
* Run: node --expose-gc --import tsx bench/scope-resolution/call-signature-memory.mts 500000
* Compare in fresh processes on each revision; heap figures exclude the input.
*/
import assert from 'node:assert/strict';
import { parseArgs } from 'node:util';
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
import { collectDeferredIndirectCollection } from '../../src/core/ingestion/scope-resolution/passes/callable-value-flow.js';
const { values, positionals } = parseArgs({
options: { check: { type: 'boolean', default: false } },
allowPositionals: true,
});
assert.ok(positionals.length <= 1, 'Expected at most one call count');
const total = Number(positionals[0] ?? 500_000);
assert.ok(Number.isSafeInteger(total) && total > 0);
assert.ok(global.gc, 'Run with --expose-gc to measure retained heap');
const callsPerFile = 1_000;
const files: ParsedFile[] = Array.from({ length: Math.ceil(total / callsPerFile) }, (_, file) => ({
filePath: `src/main/java/example/services/Service${file}.java`,
moduleScope: `scope:${file}` as ScopeId,
scopes: [],
localDefs: [],
parsedImports: [],
callableFlowSites: [],
referenceSites: Array.from(
{ length: Math.min(callsPerFile, total - file * callsPerFile) },
(_, line) => ({
kind: 'call',
name: 'save',
inScope: `file${file}:method${line}` as ScopeId,
atRange: { startLine: line + 1, startCol: 4, endLine: line + 1, endCol: 16 },
arity: 0,
}),
),
}));
global.gc();
const before = process.memoryUsage().heapUsed;
const started = performance.now();
const result = collectDeferredIndirectCollection(files);
const elapsedMs = performance.now() - started;
const after = process.memoryUsage().heapUsed;
global.gc();
const retained = process.memoryUsage().heapUsed;
assert.equal(result.sites.size, 0);
console.log(
JSON.stringify({
totalCallSites: total,
signaturesAllocated: result.callSignaturesBySite.size,
elapsedMs,
endpointHeapGrowthMiB: (after - before) / 2 ** 20,
retainedHeapGrowthMiB: (retained - before) / 2 ** 20,
peakRssMiB: process.resourceUsage().maxRSS / 1024,
inputStillLive: files.reduce((n, file) => n + file.referenceSites.length, 0),
}),
);
if (values.check) {
assert.equal(result.callSignaturesBySite.size, 0, 'Ordinary calls retained unused signatures');
}

View file

@ -0,0 +1,94 @@
/** Dense Java workload for investigating #3695; run in a fresh process. */
import { createHash } from 'node:crypto';
import assert from 'node:assert/strict';
import { parseArgs } from 'node:util';
import { SupportedLanguages } from 'gitnexus-shared';
import { parseFilesWithWorkers } from '../../test/helpers/worker-parse.js';
import { SCOPE_RESOLVERS } from '../../src/core/ingestion/scope-resolution/pipeline/registry.js';
import { runScopeResolution } from '../../src/core/ingestion/scope-resolution/pipeline/run.js';
const { values, positionals } = parseArgs({
options: { check: { type: 'boolean', default: false } },
allowPositionals: true,
});
assert.ok(positionals.length <= 3, 'Expected file, method and call counts');
const count = Number(positionals[0] ?? 300);
const methods = Number(positionals[1] ?? 20);
const calls = Number(positionals[2] ?? 10);
assert.ok([count, methods, calls].every((value) => Number.isSafeInteger(value) && value > 0));
assert.ok(count >= 2, 'Use at least two files for cross-file calls');
if (values.check) {
assert.deepEqual(
[count, methods, calls],
[300, 20, 10],
'--check requires the baseline workload',
);
}
// Each receiver takes the next class in the ring; null keeps calls type-correct.
const files = Array.from({ length: count }, (_, i) => ({
path: `src/p${i}/Service${i}.java`,
content: `package p${i};
import p${(i + 1) % count}.Service${(i + 1) % count};
public class Service${i} {
${Array.from(
{ length: methods },
(_, m) => `
public Service${i} method${m}(Service${(i + 1) % count} other) {
${Array.from({ length: calls }, (_, c) => ` var value${c} = other.method${(m + c) % methods}(null);`).join('\n')}
return this;
}`,
).join('\n')}
}`,
}));
const parsed = await parseFilesWithWorkers(files, { poolSize: 1 });
const counts: Record<string, number> = {};
for (const file of parsed.data.parsedFiles) {
for (const fact of file.callableFlowSites ?? []) counts[fact.kind] = (counts[fact.kind] ?? 0) + 1;
counts.callResults = (counts.callResults ?? 0) + (file.callResultAssignmentSites?.length ?? 0);
}
console.log(
'DENSE_INPUT',
JSON.stringify({ files: files.length, counts, memory: process.memoryUsage() }),
);
const started = performance.now();
runScopeResolution(
{
graph: parsed.graph,
model: parsed.model,
files,
preExtractedParsedFiles: new Map(parsed.data.parsedFiles.map((file) => [file.filePath, file])),
},
SCOPE_RESOLVERS.get(SupportedLanguages.Java)!,
);
const ended = performance.now();
const memory = process.memoryUsage();
assert.equal(parsed.data.parsedFiles.length, files.length, 'Scope extraction omitted files');
assert.ok([...parsed.graph.iterRelationshipsByType('CALLS')].length > 0, 'No calls resolved');
const nodes = [...parsed.graph.iterNodes()].map((n) => JSON.stringify(n));
const edges = [...parsed.graph.iterRelationships()].map((e) => JSON.stringify(e));
const peakRssBytes = process.resourceUsage().maxRSS * 1024;
const fingerprint = createHash('sha256')
.update([...nodes, ...edges].sort().join('\n'))
.digest('hex');
console.log(
'DENSE_RESULT',
JSON.stringify({
count,
methods,
calls,
resolveMs: ended - started,
memory,
peakRssBytes,
nodes: nodes.length,
edges: edges.length,
fingerprint,
}),
);
if (values.check) {
// Reviewed before/after #3695: 66,300 nodes and 126,600 relationships.
assert.equal(
fingerprint,
'3209302e91e7023328464540ec7d0ac72ac03bf49fa73303817a56ed3d415ef5',
'Dense Java graph differs from the reviewed baseline',
);
}

View file

@ -123,13 +123,21 @@ export function collectDeferredIndirectSites(
return collectDeferredIndirectCollection(parsedFiles, scopes).sites;
}
/** One `referenceSites` walk for deferred keys and call-signature evidence. */
/** Collect signatures only for argument facts consumed by indirect-call flow. */
export function collectDeferredIndirectCollection(
parsedFiles: readonly ParsedFile[],
scopes?: ScopeResolutionIndexes,
): DeferredIndirectCollection {
const out = new Set<string>();
const callSignaturesBySite = new Map<string, CallableFlowExpectedSignature>();
// Ordinary methods also produce formal/argument facts. Without an invoke
// there is no indirect edge to resolve; indexing every direct call here kept
// an otherwise unused signature object and position string per call (#3695).
if (
!parsedFiles.some((parsed) => parsed.callableFlowSites?.some((site) => site.kind === 'invoke'))
) {
return { sites: out, callSignaturesBySite };
}
const flowCells = new Set<string>();
if (scopes !== undefined) {
for (const parsed of parsedFiles) {
@ -144,11 +152,21 @@ export function collectDeferredIndirectCollection(
}
}
for (const parsed of parsedFiles) {
const invokeKeys = new Set<string>();
const argumentKeys = new Set<string>();
for (const site of parsed.callableFlowSites ?? []) {
if (site.kind === 'invoke')
invokeKeys.add(callableFlowSiteKey(parsed.filePath, site.callSite));
if (site.kind === 'argument')
argumentKeys.add(callableFlowSiteKey(parsed.filePath, site.callSite));
}
if (invokeKeys.size === 0 && argumentKeys.size === 0) continue;
const canonical = new Set<string>();
for (const site of parsed.referenceSites) {
if (site.kind !== 'call') continue;
const key = callableFlowSiteKey(parsed.filePath, site.atRange);
canonical.add(key);
if (invokeKeys.has(key)) canonical.add(key);
if (!argumentKeys.has(key)) continue;
const signature: CallableFlowExpectedSignature = {
...(site.arity !== undefined ? { parameterCount: site.arity } : {}),
...(site.argumentTypes !== undefined ? { parameterTypes: site.argumentTypes } : {}),
@ -175,6 +193,7 @@ export function collectDeferredIndirectCollection(
}
}
}
if (out.size === 0) callSignaturesBySite.clear();
return { sites: out, callSignaturesBySite };
}
@ -213,7 +232,9 @@ export function emitCallableValueFlow(input: EmitCallableValueFlowInput): Callab
}
}
}
if (facts.length === 0 && invokes.length === 0) {
// Facts propagate values only to the fixed set of canonical invokes. If it
// is empty, building whole-workspace callable indexes cannot emit an edge.
if (invokes.length === 0) {
return { emitted: 0, resolvedInvokes: 0, ambiguousInvokes: 0, unmatchedInvokes, iterations: 0 };
}

View file

@ -125,7 +125,7 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
import { narrowOverloadCandidates } from '../passes/overload-narrowing.js';
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
import { withdrawAnsweredReceiverDrops } from '../resolution-outcome.js';
import { logHeapProbe } from '../../utils/heap-probe.js';
import { isDebugHeapEnabled, logHeapProbe } from '../../utils/heap-probe.js';
import { parseTruthyEnv } from '../../utils/env.js';
/**
@ -1229,6 +1229,23 @@ export function runScopeResolution(
const tResolve = PROF ? process.hrtime.bigint() : 0n;
logHeapProbe('sr-post-resolve', `lang=${provider.language}`);
const emitStarted = Date.now();
const probeEmit = (stage: string, detail = ''): void => {
if (!isDebugHeapEnabled()) return;
logHeapProbe(
`sr-emit-${stage}`,
`lang=${provider.language} elapsed_ms=${Date.now() - emitStarted}` +
` nodes=${graph.nodeCount} edges=${graph.relationshipCount} ${detail}`,
);
};
if (isDebugHeapEnabled()) {
probeEmit(
'start',
`references=${indexes.referenceSites.length}` +
` callableFacts=${parsedFiles.reduce((total, parsed) => total + (parsed.callableFlowSites?.length ?? 0), 0)}`,
);
}
// Value defs bound at MODULE LEVEL. A read of a block-local `const` must not
// mint an edge — that would retain the inert locals `pruneLocalSymbols` drops.
//
@ -1299,6 +1316,7 @@ export function runScopeResolution(
}
}
probeEmit('locals-end', `localValues=${functionLocalValueDefIds.size}`);
// ── Out-of-core scope seal boundary ─────────────────────────────────────
// Pass-A (finalize + propagate + resolve) is done; all whole-language reads
// of `Scope.bindings` are behind us. Emit reaches scopes ONLY via
@ -1330,6 +1348,10 @@ export function runScopeResolution(
const handledSites = new Set<string>(preEmittedInheritanceSites);
const deferredIndirectCollection = collectDeferredIndirectCollection(emitParsedFiles, indexes);
const deferredIndirectSites = deferredIndirectCollection.sites;
probeEmit(
'indirect-collection-end',
`deferred=${deferredIndirectSites.size} signatures=${deferredIndirectCollection.callSignaturesBySite.size}`,
);
const callableArgumentSites = new Set<string>();
const callResultAssignmentSites = new Set<string>();
for (const parsed of emitParsedFiles) {
@ -1395,6 +1417,7 @@ export function runScopeResolution(
unresolvedInheritanceByClass,
},
);
probeEmit('receiver-end', `handled=${handledSites.size} emitted=${receiverBound.emitted}`);
let receiverExtras = receiverBound.emitted;
if (receiverBound.dispatchFanoutSkipped > 0) {
// Never drop dispatch coverage silently (#2829) — same contract as the
@ -1462,6 +1485,7 @@ export function runScopeResolution(
skipSites: deferredIndirectSites,
},
);
probeEmit('free-calls-end', `handled=${handledSites.size} emitted=${freeCallExtras}`);
const replayedCallResultBindings =
callableFlowOnly || calleeIdAccumulator === undefined
? 0
@ -1526,7 +1550,13 @@ export function runScopeResolution(
}
}
}
const referenceSkipSites = new Set(handledSites);
// Receiver/free-call passes have finished reading this set. Extend it for
// the remaining passes instead of retaining three whole-program copies.
const referenceSkipSites = handledSites;
probeEmit(
'replay-end',
`handled=${handledSites.size} replayedBindings=${replayedCallResultBindings}`,
);
for (const key of deferredIndirectSites) referenceSkipSites.add(key);
const { emitted, skipped } = callableFlowOnly
? { emitted: 0, skipped: 0 }
@ -1551,7 +1581,8 @@ export function runScopeResolution(
// first-write-wins per edge id, which stops a DUPLICATE but not a second
// edge to a DIFFERENT target, and second-guessing a resolved receiver is
// exactly the wrong-edge-in-the-safety-gate case this must not create.
const uniqueNameSkipSites = new Set(referenceSkipSites);
const uniqueNameSkipSites = referenceSkipSites;
probeEmit('references-end', `emitted=${emitted} skipped=${skipped}`);
for (const [fromScope, refs] of referenceIndex.bySourceScope) {
const fromFilePath = indexes.scopeTree.getScope(fromScope)?.filePath;
if (fromFilePath === undefined) continue;
@ -1589,6 +1620,7 @@ export function runScopeResolution(
// several functions returning the same field name. Sites it resolves are
// added to the skip set, so the fallback below never second-guesses them.
const sharedPropertyIndex = input.prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph);
probeEmit('imported-values-end', `skipSites=${uniqueNameSkipSites.size}`);
const answeredAfterReceiverPass = new Set<string>();
const returnShapeMembers = callableFlowOnly
? { emitted: 0, memberNotOnShape: 0 }
@ -1625,6 +1657,7 @@ export function runScopeResolution(
answeredAfterReceiverPass,
);
withdrawAnsweredReceiverDrops(resolutionOutcomes, answeredAfterReceiverPass);
probeEmit('properties-end', `outcomes=${resolutionOutcomes.length}`);
// value-ref registrations (#2437): USES edges at the registration sites
// plus field-based dispatch — synthesized CALLS from member-call sites to
@ -1668,6 +1701,7 @@ export function runScopeResolution(
'property-dispatch: keys over the fan-out cap were dropped (no CALLS synthesized for them)',
);
}
probeEmit('property-dispatch-end');
const callableValueFlow =
calleeIdAccumulator === undefined
? {
@ -1697,9 +1731,11 @@ export function runScopeResolution(
'callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted',
),
});
probeEmit('callable-flow-end', `iterations=${callableValueFlow.iterations}`);
const importsEmitted = callableFlowOnly
? 0
: emitImportEdges(graph, indexes.imports, indexes.scopeTree, provider.importEdgeReason);
probeEmit('imports-end', `emitted=${importsEmitted}`);
// Language-specific supplementary edges (e.g. Vue template-derived
// BINDS_EVENT_HANDLER / EMITS_EVENT / CALLS / ACCESSES edges).
@ -1714,6 +1750,7 @@ export function runScopeResolution(
recordSourceBinding,
});
}
probeEmit('provider-end');
// ── CFG/PDG emission (#2081 M1, opt-in via `--pdg`) ──────────────────────
// Emit BasicBlock nodes + CFG edges from each ParsedFile's worker-built

View file

@ -206,26 +206,131 @@ describe('callable-value-flow dependency worklist', () => {
});
describe('collectDeferredIndirectCollection', () => {
it('fills call signatures from the first referenceSites walk', () => {
it('retains no per-call signature index when there are no indirect invocations', () => {
const parsed: ParsedFile = {
filePath: FILE,
moduleScope: MODULE,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [
{
name: 'target',
kind: 'call',
callForm: 'free',
atRange: range(1),
inScope: MODULE,
arity: 0,
},
],
referenceSites: Array.from({ length: 10_000 }, (_, line) => ({
name: 'target',
kind: 'call' as const,
callForm: 'free' as const,
atRange: range(line + 1),
inScope: MODULE,
arity: 0,
})),
callableFlowSites: [],
};
const collected = collectDeferredIndirectCollection([parsed]);
expect(collected.callSignaturesBySite.get(`${FILE}:1:0`)).toEqual({ parameterCount: 0 });
expect(collected.sites.size).toBe(0);
expect(collected.callSignaturesBySite.size).toBe(0);
});
it('keeps direct-call argument signatures across files when an indirect invoke needs them', () => {
const direct: ParsedFile = {
filePath: 'caller.ts',
moduleScope: MODULE,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [
{ name: 'register', kind: 'call', atRange: range(1), inScope: MODULE, arity: 1 },
{ name: 'unrelated', kind: 'call', atRange: range(2), inScope: MODULE, arity: 0 },
// Multiple captures at one call keep the richest overload evidence.
{
name: 'register',
kind: 'call',
atRange: range(1),
inScope: MODULE,
arity: 1,
argumentTypes: ['Handler'],
},
],
callableFlowSites: [
{
kind: 'argument',
callSite: range(1),
parameterIndex: 0,
source: operand('handler', 1),
directCalleeName: 'register',
},
],
};
const indirect: ParsedFile = {
filePath: FILE,
moduleScope: MODULE,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [{ name: 'callback', kind: 'call', atRange: range(3), inScope: ENTRY_SCOPE }],
callableFlowSites: [
{
kind: 'invoke',
callSite: range(3),
inScope: ENTRY_SCOPE,
callee: operand('callback', 3),
invocationKind: 'indirect',
arity: 0,
},
],
};
const collected = collectDeferredIndirectCollection([direct, indirect]);
expect([...collected.sites]).toEqual([`${FILE}:3:0`]);
expect([...collected.callSignaturesBySite]).toEqual([
['caller.ts:1:0', { parameterCount: 1, parameterTypes: ['Handler'] }],
]);
const unmatched = collectDeferredIndirectCollection([
direct,
{ ...indirect, referenceSites: [] },
]);
expect(unmatched.sites.size).toBe(0);
expect(unmatched.callSignaturesBySite.size).toBe(0);
});
it('skips the callable solver for ordinary facts and reports unmatched invokes', () => {
const graph = createKnowledgeGraph();
const result = emitCallableValueFlow({
graph,
scopes: indexes([]),
nodeLookup: buildGraphNodeLookup(graph),
calleeIds: createCalleeIdAccumulator(),
language: 'typescript',
parsedFiles: [
{
filePath: FILE,
moduleScope: MODULE,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [],
callableFlowSites: [
{
kind: 'seed',
destination: operand('unused', 1),
targetName: 'target',
targetRange: range(1),
},
{
kind: 'invoke',
callSite: range(3),
inScope: ENTRY_SCOPE,
callee: operand('stale', 3),
invocationKind: 'indirect',
arity: 0,
},
],
},
],
});
expect(result).toEqual({
emitted: 0,
resolvedInvokes: 0,
ambiguousInvokes: 0,
unmatchedInvokes: 1,
iterations: 0,
});
expect(graph.relationshipCount).toBe(0);
});
});