perf(scope-resolution): index C/C++ #include resolution in finalize (O(n²)→O(n))

Kernel-scale C/C++ analysis ground in finalizeScopeModel because three
per-#include operations each did a full O(F) scan with no index — the
finalize O(n²) that surfaced once the #1983 parse-phase OOM was fixed:

- expand{C,Cpp}WildcardNames: parsedFiles.find() per wildcard edge → O(R·F)
- resolveImportTarget: new Set(allFilePaths) rebuilt per #include
- resolveCImportTarget: suffix-match scanned all workspace paths

Each is replaced with a WeakMap-per-pass index keyed on the stable
parsedFiles/allFilePaths references that scope-resolution run.ts passes
once per pass:

- Map<ScopeId,ParsedFile> for wildcard expansion (c/static-linkage.ts +
  cpp/file-local-linkage.ts)
- memoized augmented header set (c/scope-resolver.ts + cpp/scope-resolver.ts)
- basename-bucketed suffix index in resolveCImportTarget (c/import-target.ts),
  shared by C and C++ since resolveCppImportTarget delegates to it

Collapses the C/C++ finalize from O(R·F) to O(R+F). Pure-perf, byte-identical
edge output: 962 targeted tests green (490 C + 472 C/C++ scope-resolution);
the basename index preserves the exact endsWith('/'+target) match and the
fewest-path-components-then-lexicographic tie-break.

The kernel's ~25-30k .h headers are classified C++, so both providers must
be fixed. Proven on the Linux kernel: the C finalize completed
(sr-post-finalize lang=c → sr-end lang=c), which the pre-fix run never
reached in 16+ min of grinding.

Build-independent follow-ups (separate from this finalize fix), documented
for later: emitFreeCallFallback same-name buckets (emit phase),
buildGraphNodeLookup + precount global setup, the ParsedFile store-load,
the dart/go/ruby expand-wildcards .find siblings, and the ~26GB
scope-resolution memory floor (full kernel completion needs >~40GB RAM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-05 17:41:39 +00:00
parent 716cd8aa9b
commit b71c77b84a
5 changed files with 211 additions and 17 deletions

View file

@ -1,5 +1,54 @@
import { dirname, join } from 'path';
/**
* A workspace file path pre-decomposed for the suffix-match fallback:
* `original` is returned verbatim (preserving the prior `bestMatch = filePath`
* contract); `normalized` and `depth` are precomputed so the hot path does no
* per-element regex/`split`.
*/
interface CSuffixCandidate {
original: string;
normalized: string;
depth: number;
}
/**
* Per-pass memo: workspace paths bucketed by basename (last path segment),
* keyed on the `allFilePaths` set identity.
*
* `resolveCImportTarget` is called once per (quoted) C/C++ `#include` with the
* same `allFilePaths` set per pass (the augmented set is itself memoized in
* the C resolver). The old suffix-match fallback scanned ALL workspace paths
* per include with a per-element `.replace`/`.split` and no early exit
* (the fewest-path-components tie-break forces a full scan) i.e.
* O(R_suffix × (F+H)). A path can satisfy `endsWith('/'+target)` (or equal
* the target) ONLY IF its basename equals the target's last segment, so we
* pre-bucket by basename once (O(F+H), `normalized`/`depth` precomputed) and
* the fallback inspects a single small bucket O(F+H) build + ~O(1)/include.
* `WeakMap`-keyed so it is reclaimed with the pass (no cross-pass staleness).
* Shared by C and C++ (`resolveCppImportTarget` delegates here).
*/
const suffixIndexByPaths = new WeakMap<ReadonlySet<string>, Map<string, CSuffixCandidate[]>>();
function suffixIndex(allFilePaths: ReadonlySet<string>): Map<string, CSuffixCandidate[]> {
let index = suffixIndexByPaths.get(allFilePaths);
if (index === undefined) {
index = new Map<string, CSuffixCandidate[]>();
for (const original of allFilePaths) {
const normalized = original.replace(/\\/g, '/');
const basename = normalized.slice(normalized.lastIndexOf('/') + 1);
let bucket = index.get(basename);
if (bucket === undefined) {
bucket = [];
index.set(basename, bucket);
}
bucket.push({ original, normalized, depth: normalized.split('/').length });
}
suffixIndexByPaths.set(allFilePaths, index);
}
return index;
}
/**
* Resolve a C #include path to a file in the workspace.
*
@ -41,21 +90,31 @@ export function resolveCImportTarget(
// Exact match (path as-is in the workspace)
if (allFilePaths.has(normalizedTarget)) return normalizedTarget;
// Suffix match: find files ending with /targetRaw or equal to targetRaw
// Suffix match: find files ending with /targetRaw or equal to targetRaw.
// A path can only match `=== normalizedTarget` or `endsWith('/'+target)` if
// its basename equals the target's last segment, so we inspect only that
// basename bucket (built once per pass) instead of scanning every workspace
// path. Match condition + tie-break (fewest path components, then
// lexicographic on the normalized path) are byte-identical to the prior scan.
const suffix = '/' + normalizedTarget;
const targetBasename = normalizedTarget.slice(normalizedTarget.lastIndexOf('/') + 1);
const bucket = suffixIndex(allFilePaths).get(targetBasename);
if (bucket === undefined) return null;
let bestMatch: string | null = null;
let bestDepth = Infinity;
let bestNormalized = '';
for (const filePath of allFilePaths) {
const normalized = filePath.replace(/\\/g, '/');
if (normalized === normalizedTarget || normalized.endsWith(suffix)) {
for (const cand of bucket) {
if (cand.normalized === normalizedTarget || cand.normalized.endsWith(suffix)) {
// Prefer shortest path (closest match)
const depth = normalized.split('/').length;
if (depth < bestDepth || (depth === bestDepth && normalized < bestNormalized)) {
bestDepth = depth;
bestMatch = filePath;
bestNormalized = normalized;
if (
cand.depth < bestDepth ||
(cand.depth === bestDepth && cand.normalized < bestNormalized)
) {
bestDepth = cand.depth;
bestMatch = cand.original;
bestNormalized = cand.normalized;
}
}
}

View file

@ -9,6 +9,42 @@ import { scanHeaderFiles } from './header-scan.js';
import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js';
import { applyCStaticLinkageSideChannel } from './capture-side-channel.js';
/**
* Per-pass memo of the augmented `#include`-resolution file set
* (`allFilePaths` header `.h` paths), keyed on the two stable source sets.
*
* `resolveImportTarget` is called once per C `#include`; the old code rebuilt
* a fresh ~F-entry `Set` on EVERY call (O(R × (F+H)) inserts + GC churn) and,
* worse, defeated `resolveCImportTarget`'s own per-set suffix-index memo by
* handing it a new set identity each time. Both `allFilePaths` (built once in
* scope-resolution `run.ts`) and the header set (`loadResolutionConfig`
* result) are stable per pass, so the union is built once and reused.
* `WeakMap`-keyed reclaimed with the pass (no cross-pass staleness).
*/
const augmentedPathsByPass = new WeakMap<
ReadonlySet<string>,
WeakMap<ReadonlySet<string>, ReadonlySet<string>>
>();
function augmentedFilePaths(
allFilePaths: ReadonlySet<string>,
headerPaths: ReadonlySet<string>,
): ReadonlySet<string> {
let byHeaders = augmentedPathsByPass.get(allFilePaths);
if (byHeaders === undefined) {
byHeaders = new WeakMap();
augmentedPathsByPass.set(allFilePaths, byHeaders);
}
let augmented = byHeaders.get(headerPaths);
if (augmented === undefined) {
const set = new Set(allFilePaths);
for (const h of headerPaths) set.add(h);
augmented = set;
byHeaders.set(headerPaths, augmented);
}
return augmented;
}
/**
* C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
@ -55,9 +91,11 @@ export const cScopeResolver: ScopeResolver = {
// targets .h files classified as C++ in language detection.
const headerPaths = resolutionConfig as ReadonlySet<string> | undefined;
if (headerPaths !== undefined && headerPaths.size > 0) {
const augmented = new Set(allFilePaths);
for (const h of headerPaths) augmented.add(h);
return resolveCImportTarget(targetRaw, fromFile, augmented);
return resolveCImportTarget(
targetRaw,
fromFile,
augmentedFilePaths(allFilePaths, headerPaths),
);
}
return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
},

View file

@ -46,6 +46,41 @@ export function clearStaticNames(): void {
staticNames.clear();
}
/**
* Per-pass memo: `moduleScope` owning `ParsedFile`, keyed on the
* `parsedFiles` array identity.
*
* The shared finalize Phase-4 loop calls `expandsWildcardTo`
* ( `expandCWildcardNames`) ONCE PER RESOLVED `#include` edge, every time
* with the SAME `parsedFiles` reference (wired at scope-resolution
* `run.ts` `allFilePaths`/`parsedFiles` are built once per pass). The old
* `parsedFiles.find(...)` therefore did a full O(F) scan per edge
* O(R_include × F) overall; at Linux-kernel scale (F 63k C files, tens of
* thousands of resolved includes) that is ~10^10+ comparisons on a single
* thread the dominant term in the scope-resolution finalize grind.
*
* Building the lookup once collapses it to O(R_include + F). `WeakMap`-keyed
* on the array so the index is reclaimed with the pass no cross-pass
* staleness (mirrors the {@link clearStaticNames} discipline for server-mode
* / multi-repo reuse), and a fresh array transparently rebuilds.
*/
const moduleScopeIndexByPass = new WeakMap<readonly ParsedFile[], Map<ScopeId, ParsedFile>>();
function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map<ScopeId, ParsedFile> {
let index = moduleScopeIndexByPass.get(parsedFiles);
if (index === undefined) {
index = new Map<ScopeId, ParsedFile>();
// First-wins to preserve `Array.find` semantics (returns the first match).
// `moduleScope` is unique per file in practice, so collisions are absent;
// the guard only formalises identical behaviour to the prior `.find`.
for (const p of parsedFiles) {
if (!index.has(p.moduleScope)) index.set(p.moduleScope, p);
}
moduleScopeIndexByPass.set(parsedFiles, index);
}
return index;
}
/**
* Return the names visible through a C wildcard import (`#include`).
* All module-scope defs from the target file are visible EXCEPT those
@ -55,7 +90,7 @@ export function expandCWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
const target = moduleScopeIndex(parsedFiles).get(targetModuleScope);
if (target === undefined) return [];
const seen = new Set<string>();

View file

@ -276,11 +276,37 @@ export function isCppDefGloballyVisible(filePath: string, nodeId: string): boole
* does, mirror this filter or harden registration so class/namespace
* members never enter `localDefs` unqualified.
*/
/**
* Per-pass memo: `moduleScope` owning `ParsedFile`, keyed on the
* `parsedFiles` array identity. The shared finalize Phase-4 loop calls
* `expandsWildcardTo` ( this) ONCE PER RESOLVED `#include` edge with the same
* `parsedFiles` reference; the old `parsedFiles.find(...)` was therefore O(F)
* per edge O(R·F) overall (at kernel scale the ~2530k `.h` headers are
* classified C++, so this fires hard the C twin in `c/static-linkage.ts`).
* Building the lookup once collapses it to O(R+F). `WeakMap`-keyed so it is
* reclaimed with the pass (no cross-pass staleness; mirrors
* {@link clearFileLocalNames}).
*/
const moduleScopeIndexByPass = new WeakMap<readonly ParsedFile[], Map<ScopeId, ParsedFile>>();
function moduleScopeIndex(parsedFiles: readonly ParsedFile[]): Map<ScopeId, ParsedFile> {
let index = moduleScopeIndexByPass.get(parsedFiles);
if (index === undefined) {
index = new Map<ScopeId, ParsedFile>();
// First-wins to preserve `Array.find` semantics (returns the first match).
for (const p of parsedFiles) {
if (!index.has(p.moduleScope)) index.set(p.moduleScope, p);
}
moduleScopeIndexByPass.set(parsedFiles, index);
}
return index;
}
export function expandCppWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
const target = moduleScopeIndex(parsedFiles).get(targetModuleScope);
if (target === undefined) return [];
// Build nodeId → owning Scope map from the structural scope tree.

View file

@ -43,6 +43,40 @@ import {
populateCppUserDefinedConversions,
} from './user-defined-conversions.js';
/**
* Per-pass memo of the augmented `#include`-resolution file set
* (`allFilePaths` header paths), keyed on the two stable source sets.
* `resolveImportTarget` is called once per C++ `#include`; the old code rebuilt
* a fresh ~F-entry `Set` on every call AND defeated the shared
* `resolveCImportTarget` suffix-index memo (in `c/import-target.ts`) by handing
* it a new set identity each time. Both inputs are stable per pass, so the
* union is built once and reused. `WeakMap`-keyed reclaimed with the pass.
* (Twin of the C resolver's `augmentedFilePaths`.)
*/
const augmentedPathsByPass = new WeakMap<
ReadonlySet<string>,
WeakMap<ReadonlySet<string>, ReadonlySet<string>>
>();
function augmentedFilePaths(
allFilePaths: ReadonlySet<string>,
headerPaths: ReadonlySet<string>,
): ReadonlySet<string> {
let byHeaders = augmentedPathsByPass.get(allFilePaths);
if (byHeaders === undefined) {
byHeaders = new WeakMap();
augmentedPathsByPass.set(allFilePaths, byHeaders);
}
let augmented = byHeaders.get(headerPaths);
if (augmented === undefined) {
const set = new Set(allFilePaths);
for (const h of headerPaths) set.add(h);
augmented = set;
byHeaders.set(headerPaths, augmented);
}
return augmented;
}
/**
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
* the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
@ -79,9 +113,11 @@ export const cppScopeResolver: ScopeResolver = {
// detection but are importable from .cpp files via #include.
const headerPaths = resolutionConfig as ReadonlySet<string> | undefined;
if (headerPaths !== undefined && headerPaths.size > 0) {
const augmented = new Set(allFilePaths);
for (const h of headerPaths) augmented.add(h);
return resolveCppImportTarget(targetRaw, fromFile, augmented);
return resolveCppImportTarget(
targetRaw,
fromFile,
augmentedFilePaths(allFilePaths, headerPaths),
);
}
return resolveCppImportTarget(targetRaw, fromFile, allFilePaths);
},