mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
* fix(csharp): eliminate global-namespace typeBindings O(files²) OOM (#1871) Large C# solutions with tens of thousands of files in the global (unnamed) namespace OOM'd / hung for hours at "Resolving types (Csharp 2/3)". PR #1905 fixed the BindingRef twin of this via the `workspaceFqnBindings` fast-path, but left the typeBindings propagation loop in `populateCsharpNamespaceSiblings` untouched: it copies every global file's module-scope return-type bindings into every OTHER global file's `Scope.typeBindings`. With S files in the `''` bucket and K distinct method names, that is O(S²) time and O(S·K) memory — ~1.3B Map entries (~65-130 GB) at 36k files. Measured on a concentrated global-namespace fixture: the per-file copy went quadratic (1000→2000 files = 3.06× for 2× the files, 65s at 2000). Route global-namespace module typeBindings through a new scope-independent `workspaceTypeBindings` channel populated ONCE (O(K)) and consulted as a fallback by the typeBindings chain-walkers (`findReceiverTypeBinding`, `followChainPostFinalize`), instead of the per-file copy. After: 2000 files 6.3s, 4000 files 6.8s, heap linear. This also makes resolution MORE correct, not just faster. The C# spec makes the unnamed namespace a single declaration space whose members are "available for use in a named namespace", so global types are visible from every file. The old per-file copy only exposed them to OTHER no-namespace files; named-namespace files never saw them. Consulting the shared channel from every scope chain mirrors how Roslyn resolves against a single `Compilation.GlobalNamespace` symbol rather than copying symbols per file. Strengthen csharp-pipeline-benchmark.test.ts so it would catch this: give each file a unique method name (a shared name collapses the module-typeBinding key and skips all copies, hiding the blow-up) and raise the concentrated scales to 2000 so the sub-quadratic assertion trips on the regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(csharp): generalize shared-channel resolution to concentrated named namespaces (#1871) #1954 eliminated the namespace-siblings O(files²) OOM only for the global ('' / no-declared-namespace) bucket. A solution with all files under one named namespace (e.g. file-scoped `namespace Company.Product;`, common in modern .NET) still reproduced the #1871 blow-up — and in BOTH loops: the BindingRef per-scope augmentation (#1905's twin) AND the typeBindings per-file copy (#1954's twin) were each still O(N²) for a named bucket. Generalize the shared-channel approach to named namespaces: - Add namespace-keyed channels `namespaceFqnBindings` / `namespaceTypeBindings` (the per-namespace analogues of `workspaceFqnBindings` / `workspaceTypeBindings`) plus `accessibleNamespacesByScope`, populated ONCE per named bucket from the existing `expandedNamespaces` derivation — O(defs), not O(files × defs). - Make the shared walkers (`findReceiverTypeBinding`, `lookupBindingsAt`, `followChainPostFinalize`) namespace-aware: after the per-scope chain and the flat global channel miss, consult the per-namespace channels gated by the caller module's accessible namespaces. Language-neutral — only the C# hook populates the channels; the machinery names no language (AGENTS rule). - Precedence preserved: local chain → named namespace → global. Named is consulted before the flat global channel because pre-#1871 named siblings lived in the chain / bindingAugmentations (above the workspace channel), so a name in both a named and the global namespace must still resolve named-first. - `using static` member exposure and the global '' fast-paths are unchanged. Parity-neutral: `run-parity.ts --language csharp` passes (legacy DAG == registry-primary, 218 tests each); the C# resolver suite (386 tests) is green. Measured: a concentrated named namespace at 500/1000/2000 files now scales linearly (~0.57×) and ~5.6s at 2000 files, vs the quadratic blow-up before. Tests: - New always-run unit coverage for the walker fallbacks (namespace-channel-lookup.test.ts): global `workspaceTypeBindings` (the #1954 channel previously covered only by a gated benchmark), namespace gating / no-leak, named-before-global precedence, local shadowing, loop termination. - Extend the immutability validator + invariant I8 to the new channels and `workspaceTypeBindings`; update the `mkIndexes` factory. - Add a concentrated-NAMED-namespace shape to the C# pipeline benchmark with the sub-quadratic scaling assertion and an edge-count sanity check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
304 lines
12 KiB
TypeScript
304 lines
12 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 — parsing,
|
||
* scope extraction, C# namespace-siblings (same-namespace cross-file
|
||
* visibility, using-static, cross-namespace imports), and call
|
||
* resolution.
|
||
*
|
||
* Mirrors test/integration/php-pipeline-benchmark.test.ts. Two shapes:
|
||
* 1. "spread" — files distributed across many namespaces (the common
|
||
* case; each namespace bucket stays small).
|
||
* 2. "concentrated" — every file in the SAME (or global/no) namespace,
|
||
* so a single namespace bucket holds all type defs. This is the
|
||
* shape that drove the Unity-solution OOM: `populateCsharpNamespaceSiblings`
|
||
* materialises O(scopes × defs) BindingRefs into that one bucket.
|
||
* The concentrated test is the regression guard for that path.
|
||
*
|
||
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/csharp-pipeline-benchmark.test.ts
|
||
*/
|
||
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;
|
||
classCount: number;
|
||
namespaceCount: number;
|
||
elapsedMs: number;
|
||
peakHeapMB: number;
|
||
nodeCount: number;
|
||
edgeCount: number;
|
||
}
|
||
|
||
type FixtureShape = 'spread' | 'concentrated' | 'concentrated-named';
|
||
|
||
function generateCsharpFixture(
|
||
fileCount: number,
|
||
namespacesPerLevel: number,
|
||
shape: FixtureShape,
|
||
): { dir: string; classCount: number; namespaceCount: number } {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `csharp-bench-${shape}-${fileCount}-`));
|
||
|
||
// "spread": square grid of namespaces. "concentrated": a single global
|
||
// (no-namespace) bucket so every type lands in the `''` bucket — the #1954
|
||
// OOM-prone path. "concentrated-named": every file in ONE named namespace
|
||
// (`namespace App;`) — the #1871 named-namespace twin, which the global-only
|
||
// fix did not cover. Both concentrated shapes put all N type defs in a single
|
||
// bucket; "concentrated-named" exercises the per-namespace channel rather than
|
||
// the flat workspace channel.
|
||
const namespaces: string[] = [];
|
||
if (shape === 'spread') {
|
||
for (let i = 0; i < namespacesPerLevel; i++) {
|
||
for (let j = 0; j < namespacesPerLevel; j++) {
|
||
namespaces.push(`App.Module${i}.Sub${j}`);
|
||
}
|
||
}
|
||
} else if (shape === 'concentrated-named') {
|
||
namespaces.push('App'); // single named namespace — all files share it
|
||
} else {
|
||
namespaces.push(''); // global / no namespace declaration
|
||
}
|
||
|
||
const classCount = fileCount;
|
||
const namespaceCount = namespaces.length;
|
||
|
||
for (let f = 0; f < fileCount; f++) {
|
||
const ns = namespaces[f % namespaces.length]!;
|
||
const className = `Class${f}`;
|
||
// Concentrated files share a flat directory; spread files mirror the
|
||
// namespace as a directory tree (matches typical C# project layout).
|
||
const targetDir = ns === '' ? dir : path.join(dir, ns.replace(/\./g, '/'));
|
||
fs.mkdirSync(targetDir, { recursive: true });
|
||
|
||
const siblingIdx = (f + 1) % fileCount;
|
||
const siblingClass = `Class${siblingIdx}`;
|
||
|
||
const crossNsIdx = (f + Math.floor(fileCount / 3)) % fileCount;
|
||
const crossNs = namespaces[crossNsIdx % namespaces.length]!;
|
||
const crossClass = `Class${crossNsIdx}`;
|
||
const usesCross = ns !== '' && ns !== crossNs;
|
||
|
||
const body = [
|
||
ns !== '' ? `namespace ${ns};` : '',
|
||
usesCross ? `using ${crossNs};` : '',
|
||
'',
|
||
`public class ${className}`,
|
||
'{',
|
||
' private int id;',
|
||
' private string name;',
|
||
'',
|
||
' public int GetId()',
|
||
' {',
|
||
' return this.id;',
|
||
' }',
|
||
'',
|
||
// Unique method name per file. Each method's return-type binding is
|
||
// hoisted to the file's MODULE scope keyed by the method name, so unique
|
||
// names give the global ('') namespace a DISTINCT module-typeBinding key
|
||
// per file. A shared name (e.g. plain `Process`) collapses every file's
|
||
// key to one, which made the per-file typeBindings propagation skip all
|
||
// copies and HID the O(files²) #1871 blow-up. Unique names exercise the
|
||
// real concentrated-global path that OOM'd large no-namespace solutions.
|
||
` public ${siblingClass} Process${f}()`,
|
||
' {',
|
||
` var sibling = new ${siblingClass}();`,
|
||
' return sibling;',
|
||
' }',
|
||
usesCross
|
||
? [
|
||
'',
|
||
` public ${crossClass} CrossCall()`,
|
||
' {',
|
||
` var cross = new ${crossClass}();`,
|
||
' cross.GetId();',
|
||
' return cross;',
|
||
' }',
|
||
].join('\n')
|
||
: '',
|
||
'}',
|
||
'',
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
|
||
fs.writeFileSync(path.join(targetDir, `${className}.cs`), body);
|
||
}
|
||
|
||
// Minimal SDK-style csproj so the C# project-loading phase engages
|
||
// (matches the real-world Unity/.NET solution path).
|
||
const csproj = [
|
||
'<Project Sdk="Microsoft.NET.Sdk">',
|
||
' <PropertyGroup>',
|
||
' <TargetFramework>net8.0</TargetFramework>',
|
||
' <Nullable>enable</Nullable>',
|
||
' </PropertyGroup>',
|
||
'</Project>',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(dir, 'Bench.csproj'), csproj);
|
||
|
||
return { dir, classCount, namespaceCount };
|
||
}
|
||
|
||
async function runBenchmark(
|
||
fileCount: number,
|
||
nsLevels: number,
|
||
shape: FixtureShape,
|
||
budgetMs: number,
|
||
): Promise<BenchResult> {
|
||
const { dir, classCount, namespaceCount } = generateCsharpFixture(fileCount, nsLevels, shape);
|
||
|
||
let peakHeapMB = 0;
|
||
const heapSampler = setInterval(() => {
|
||
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
||
if (heap > peakHeapMB) peakHeapMB = heap;
|
||
}, 50);
|
||
|
||
let budgetTimer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
const start = Date.now();
|
||
const result = await Promise.race([
|
||
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
|
||
new Promise<never>((_, reject) => {
|
||
budgetTimer = setTimeout(
|
||
() =>
|
||
reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files (${shape})`)),
|
||
budgetMs,
|
||
);
|
||
}),
|
||
]);
|
||
const elapsedMs = Date.now() - start;
|
||
|
||
return {
|
||
fileCount,
|
||
classCount,
|
||
namespaceCount,
|
||
elapsedMs,
|
||
peakHeapMB: Math.round(peakHeapMB),
|
||
nodeCount: result.graph.nodeCount,
|
||
edgeCount: result.graph.relationshipCount,
|
||
};
|
||
} finally {
|
||
clearInterval(heapSampler);
|
||
clearTimeout(budgetTimer);
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
function printResults(label: string, results: BenchResult[]) {
|
||
console.log(`\n${label}`);
|
||
console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐');
|
||
console.log('│ Files │ Classes │ NS Count │ Time (ms) │ Heap MB │ Nodes │ Edges │');
|
||
console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤');
|
||
for (const r of results) {
|
||
console.log(
|
||
`│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).padStart(7)} │ ${String(r.namespaceCount).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 — namespaces spread across the solution', async () => {
|
||
const scales = [100, 250, 500];
|
||
const results: BenchResult[] = [];
|
||
|
||
for (const fileCount of scales) {
|
||
const nsLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4)));
|
||
const result = await runBenchmark(fileCount, nsLevels, 'spread', 180_000);
|
||
results.push(result);
|
||
console.log(
|
||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||
);
|
||
}
|
||
|
||
printResults('C# Pipeline — Namespaces Spread', 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;
|
||
expect(timeRatio / fileRatio).toBeLessThan(3);
|
||
}
|
||
}, 600_000);
|
||
|
||
it('scales with file count — all types in one (global) namespace bucket', async () => {
|
||
// Regression guard for the Unity-solution OOM: a single namespace
|
||
// bucket holds every type def, so naive per-scope binding
|
||
// materialisation is O(files²). Time must stay sub-quadratic and the
|
||
// run must not OOM.
|
||
//
|
||
// Scales reach 2000 deliberately: with the #1871 regression present
|
||
// (per-file global typeBindings copy), the 1000→2000 step measured ~3.06×
|
||
// for a 2× file increase — failing the <3 sub-quadratic assertion below.
|
||
// The workspaceTypeBindings fast-path keeps it ~linear (~1.2×).
|
||
const scales = [500, 1000, 2000];
|
||
const results: BenchResult[] = [];
|
||
|
||
for (const fileCount of scales) {
|
||
const result = await runBenchmark(fileCount, 1, 'concentrated', 180_000);
|
||
results.push(result);
|
||
console.log(
|
||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||
);
|
||
}
|
||
|
||
printResults('C# Pipeline — Concentrated Global Namespace', 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;
|
||
expect(timeRatio / fileRatio).toBeLessThan(3);
|
||
}
|
||
}, 600_000);
|
||
|
||
it('scales with file count — all types in one (named) namespace bucket', async () => {
|
||
// Regression guard for the #1871 named-namespace twin: every file under one
|
||
// `namespace App;`, so the `App` bucket holds every type def. The global-only
|
||
// fix (#1954, workspaceTypeBindings) did NOT cover this — the per-file
|
||
// namespace-siblings copy (both the BindingRef augmentation and the
|
||
// typeBindings mirror) was still O(files²) for a concentrated named
|
||
// namespace. The per-namespace channels (namespaceFqnBindings /
|
||
// namespaceTypeBindings) keep it sub-quadratic; with the fix reverted the
|
||
// 1000→2000 step goes quadratic and trips the <3 assertion below.
|
||
const scales = [500, 1000, 2000];
|
||
const results: BenchResult[] = [];
|
||
|
||
for (const fileCount of scales) {
|
||
const result = await runBenchmark(fileCount, 1, 'concentrated-named', 180_000);
|
||
results.push(result);
|
||
console.log(
|
||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||
);
|
||
}
|
||
|
||
printResults('C# Pipeline — Concentrated Named Namespace', results);
|
||
|
||
// The fixture must actually exercise cross-file resolution (not just
|
||
// parsing), or the scaling assertion would pass vacuously.
|
||
expect(results[results.length - 1].edgeCount).toBeGreaterThan(0);
|
||
|
||
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;
|
||
expect(timeRatio / fileRatio).toBeLessThan(3);
|
||
}
|
||
}, 600_000);
|
||
});
|