GitNexus/gitnexus/test/integration/cpp-pipeline-benchmark.test.ts
Gergő Magyar f1b8438388
perf(cpp): index ADL candidates once instead of per-site rescans (#1990)
* perf(cpp): index ADL candidates once instead of per-site rescans

C++ scope-resolution `emit` dominated large-repo analysis (~6.76h on a
5,969-file repo — ~70% of the total run). `pickCppAdlCandidates` ran once
per unresolved ADL-eligible call site and each time:

- rescanned every parsed file (rebuilding a per-file scope map per call),
- scanned every workspace def (`findCppClassDefBySimpleName`), and
- used an O(scopes²) child-scope walk for hidden friends.

That is O(unresolved sites × files); with hundreds of thousands of
unresolved C++ sites the emit phase went super-linear. `resolve` (registry
lookup) was only 3.5s — the cost was entirely in fallback edge emission.

Build an `AdlCandidateIndex` once per run (lazy, guarded by `parsedFiles`
identity, reset in `clearCppAdlState`) and query it per site:

- `classDefsBySimple` — preserves `defs.byId` order so first-match /
  ambiguous semantics are identical to the legacy linear scan.
- `nsCandidates` — namespace-owned callables, with inline-namespace
  transparency.
- `friendCandidates` — hidden-friend + class-member callables; a
  parent→children scope index replaces the O(scopes²) walk.
- `nsFunctionsByQName` / `nsFunctionsBySimple` — function-reference ADL path.

A monotonic `seqByNodeId` (file-major; namespace defs before friend/member
defs within a file) lets the per-site query merge candidates across
associated namespaces, dedup by nodeId, and sort — reproducing the exact
legacy candidate set and order.

Per-site cost drops from O(sites × files) to O(associated namespaces); the
emit phase goes from linear-in-sites to flat. Benchmark (files=80): emit at
1000 sites 232ms → 9ms, 2000 sites flat at 17ms; the eliminated term scales
with file count, so the speedup is ~1000×+ on the real 5,969-file repo.

Behavior is unchanged: synthetic candidate output is byte-identical
before/after, all 270 C++ integration resolver tests and 4/4
resolver-parity-expected-failures pass, and tsc + eslint are clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cpp): correct ADL state-lifecycle and cache-guard comments

The header lifecycle block listed three module-level maps and named
clearFileLocalNames as the reset caller; both became inaccurate when the
candidate index was added. Enumerate all five state pieces, name the real
caller (loadResolutionConfig), and document that ensureAdlIndex's staleness
guard keys on parsedFiles identity while the index also depends on scopes
and classToNamespaceQualifiedName.

Addresses PR #1990 tri-review (U1, U3). Doc-only; no behavior change.

* test(cpp): guard the ADL seq-coverage invariant in dev/test

pickCppAdlCandidates sorts merged candidates by seqByNodeId with a `?? 0`
fallback. That fallback is unreachable today (every bucketed def is
seq-assigned in the same build block), but a future regression could break
it and silently collapse two seq-0 candidates, dropping a CALLS edge with no
error. Add validateAdlSeqCoverage and run it from buildAdlIndex under the
resolver's opt-in validation gate (NODE_ENV!=production && VALIDATE_SEMANTIC_MODEL!=0),
so a broken invariant throws loudly in dev/CI instead. Production behavior
and the hot path are unchanged. Unit-tested; 270/270 cpp integration tests
pass with the guard active.

Addresses PR #1990 tri-review (U2).

* test(cpp): parity fixture for ADL hidden-friend + namespace-callable merge

pickCppAdlCandidates merges friendCandidates (hidden friends of associated
classes) and nsCandidates (namespace-owned callables) for a single associated
namespace. The byte-identical-parity claim rested only on an uncommitted
harness. Add a fixture that reaches one callable through each bucket — combine
only via a hidden friend, process only via a namespace member — so dropping
either bucket from the merge fails the suite. Candidate order is not observable
(narrowing resolves a unique survivor or suppresses), so the guard is on the set.

Addresses PR #1990 tri-review (U4).

* test(cpp): add ADL emit-scaling benchmark

Guards the PR #1990 optimization against reintroducing the O(sites x files)
ADL candidate scan. Generates many UNRESOLVED ADL sites (class-typed arg +
a callee declared nowhere) and co-scales files and sites with N, so the old
cost is O(N^2) and the new cost O(N). Isolates the scope-resolution emit ms
from parse-dominated wall time via the logger test destination (capture
verified) and asserts the end-to-end emit ratio stays under fileRatio^1.5.
Gated by GITNEXUS_BENCH=1; runs build-free (workerPoolSize: 0).

Addresses the benchmark request alongside PR #1990 (U5).

* test(cpp): add cpp pipeline file-count benchmark

Fills the one missing per-language pipeline benchmark (cobol/csharp/go/php/
ruby/rust already have one); modeled on cobol-pipeline-benchmark.test.ts.
Generates synthetic C++ with constant per-file work and constant header
fan-out, sweeps file count through the full pipeline, and guards linearity
with a coarse time-ratio bound plus a deterministic node-ratio bound (the
non-flaky guard against reintroducing O(fileCount^2) work). Gated by
GITNEXUS_BENCH=1; runs build-free (workerPoolSize: 0).

Addresses the benchmark request alongside PR #1990 (U6).

* style(cpp): prettier-format adl benchmark

* test(cpp): rebaseline scope-capture fingerprint for new ADL fixture

The U4 parity fixture (cpp-adl-ns-plus-hidden-friend-same-name) lives under
test/fixtures/lang-resolution/cpp-*, so its lib.h + app.cpp join the cpp
scope-capture bench corpus (bench/scope-capture/measure.mjs). That is pure
fixture-corpus growth — no scope-extractor change, existing fixtures' captures
byte-identical — so the cpp fingerprint legitimately drifts (fixture_count
265->267). Rebaseline cpp to match, as #1965/#1975 did for earlier fixture
additions. Verified: --check PASS for all 14 languages.

Addresses PR #1990 tri-review (U4 follow-on).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:25:42 +01:00

206 lines
7.9 KiB
TypeScript

/**
* C++ ingestion pipeline benchmark.
*
* Generates synthetic C++ codebases at increasing scales and measures
* wall-clock time and peak heap through the full pipeline — scanning, parsing,
* structure extraction, scope resolution, and graph emission. Fills the one
* missing slot in the per-language benchmark suite (cobol/csharp/go/php/ruby/
* rust already have one); modeled on cobol-pipeline-benchmark.test.ts.
*
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/cpp-pipeline-benchmark.test.ts
*
* Runs build-free (workerPoolSize: 0 → no dist/parse-worker.js needed), so it
* parses single-threaded; scales are kept modest accordingly.
*
* IMPORTANT — this benchmark measures scaling in FILE COUNT, so per-file work
* must stay constant as fileCount grows. Each translation unit therefore
* #includes a FIXED number of shared headers (HEADERS_PER_FILE), independent of
* fileCount. Do NOT make every TU include all headers: headerCount grows as
* floor(fileCount/5), so include-all makes emitted symbol nodes — and thus total
* work — O(fileCount²), which measures header fan-out rather than file-count
* scaling. With constant fan-out the pipeline is O(fileCount); the deterministic
* node-ratio assertion below guards against reintroducing the O(n²) pattern.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
interface BenchResult {
fileCount: number;
headerCount: number;
methodCount: number;
elapsedMs: number;
peakHeapMB: number;
nodeCount: number;
edgeCount: number;
}
const METHODS_PER_CLASS = 4;
const HEADERS_PER_FILE = 3;
function generateCppFixture(fileCount: number): {
dir: string;
headerCount: number;
methodCount: number;
} {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `cpp-bench-${fileCount}-`));
// Shared headers (1 per 5 TUs, at least 2): each a small namespace with a
// struct and a free function the TUs call cross-file (constant fan-in).
const headerCount = Math.max(2, Math.floor(fileCount / 5));
const headerNames: string[] = [];
for (let h = 0; h < headerCount; h++) {
const ns = `hdr${h}`;
headerNames.push(ns);
fs.writeFileSync(
path.join(dir, `${ns}.h`),
[
`#pragma once`,
`namespace ${ns} {`,
`struct Rec${h} { int value; };`,
`void use${h}(Rec${h}& r);`,
`}`,
'',
].join('\n'),
);
}
const methodCount = fileCount * METHODS_PER_CLASS;
for (let f = 0; f < fileCount; f++) {
const className = `C${String(f).padStart(5, '0')}`;
// Constant include fan-out, chosen by index so headers stay shared.
const includes = [
...new Set(
Array.from({ length: HEADERS_PER_FILE }, (_, k) => headerNames[(f + k) % headerCount]),
),
];
const methods: string[] = [];
for (let m = 0; m < METHODS_PER_CLASS; m++) {
// Intra-file call (resolves locally) + one cross-file call into an
// included header's free function (constant cross-file fan-out).
const nextM = (m + 1) % METHODS_PER_CLASS;
const hdr = includes[m % includes.length];
const hdrIdx = hdr.replace('hdr', '');
methods.push(
` void m${m}() {`,
` m${nextM}();`,
` ${hdr}::Rec${hdrIdx} r;`,
` ${hdr}::use${hdrIdx}(r);`,
` }`,
);
}
const content = [
...includes.map((h) => `#include "${h}.h"`),
`class ${className} {`,
`public:`,
...methods,
`};`,
'',
].join('\n');
fs.writeFileSync(path.join(dir, `${className}.cpp`), content);
}
return { dir, headerCount, methodCount };
}
async function runBenchmark(fileCount: number, budgetMs: number): Promise<BenchResult> {
const { dir, headerCount, methodCount } = generateCppFixture(fileCount);
let peakHeapMB = 0;
const heapSampler = setInterval(() => {
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
if (heap > peakHeapMB) peakHeapMB = heap;
}, 50);
try {
const start = Date.now();
const result = await Promise.race([
runPipelineFromRepo(dir, () => {}, { workerPoolSize: 0 }),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)),
budgetMs,
),
),
]);
const elapsedMs = Date.now() - start;
return {
fileCount,
headerCount,
methodCount,
elapsedMs,
peakHeapMB: Math.round(peakHeapMB),
nodeCount: result.graph.nodeCount,
edgeCount: result.graph.relationshipCount,
};
} finally {
clearInterval(heapSampler);
fs.rmSync(dir, { recursive: true, force: true });
}
}
function printResults(results: BenchResult[]) {
console.log('\nC++ Pipeline');
console.log('┌──────────┬──────────┬──────────┬───────────┬──────────┬───────┬───────┐');
console.log('│ Files │ Headers │ Methods │ Time (ms) │ Heap MB │ Nodes │ Edges │');
console.log('├──────────┼──────────┼──────────┼───────────┼──────────┼───────┼───────┤');
for (const r of results) {
console.log(
`${String(r.fileCount).padStart(8)}${String(r.headerCount).padStart(8)}${String(r.methodCount).padStart(8)}${String(r.elapsedMs).padStart(9)}${String(r.peakHeapMB).padStart(8)}${String(r.nodeCount).padStart(5)}${String(r.edgeCount).padStart(5)}`,
);
}
console.log('└──────────┴──────────┴──────────┴───────────┴──────────┴───────┴───────┘');
if (results.length >= 2) {
console.log('\nScaling ratios (time_ratio / file_ratio):');
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
const scaling = timeRatio / fileRatio;
console.log(
` ${results[i - 1].fileCount}${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
);
}
}
}
describe.skipIf(!BENCH_ENABLED)('C++ pipeline benchmark', () => {
it('scales with file count', async () => {
const scales = [50, 100, 200, 400];
const results: BenchResult[] = [];
for (const fileCount of scales) {
const result = await runBenchmark(fileCount, 300_000);
results.push(result);
console.log(
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
);
}
printResults(results);
for (let i = 1; i < results.length; i++) {
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
// Wall-clock is noisy (GC/CI load); keep a coarse upper bound here.
expect(timeRatio / fileRatio).toBeLessThan(4);
// Deterministic regression guard: with constant per-file include fan-out
// the emitted node count is linear in fileCount (ratio ≈ 1.0). If someone
// reintroduces O(fileCount²) work — e.g. by making every TU include all
// headers — node growth jumps and this fails. Node count is deterministic,
// so this is a non-flaky guard unlike the wall-clock check above.
const nodeRatio = results[i].nodeCount / results[i - 1].nodeCount;
expect(nodeRatio / fileRatio).toBeLessThan(1.3);
}
}, 600_000);
});