perf(resolution): incremental receiver-inference scan memo + compiled-pattern memo — kong −8% more (−23% cumulative), byte-identical (#1392)
The kong/tokio matcher-chain residue attributed (nm:mc-* sub-stage rows, shipped here too): matchMethodCall's cost is ~entirely inferLocalReceiverType — 61µs per miss on kong, 99% miss rate (39k `self:` calls hunting a local declaration Lua never writes), re-scanning the same scope lines for every ref. Two pure memos, both semantics-preserving by construction: - Compiled-pattern memo: localReceiverTypePatterns/phpPropertyTypePatterns built 2-4 fresh RegExp objects per call; patterns are a pure function of (language, receiver) and non-global, so instances are shared via a FIFO-capped map (no per-get mutation — the §7a.6 LRU-churn lesson). - Incremental scan memo: refs for the same (file, scope, receiver) arrive in ~ascending line order and the backward declaration scan is a pure function of immutable file lines — a per-context watermark scans each line once per key (query(c) = highest match in [start..c]; monotonic calls extend the watermark over (hi..c]; non-monotonic calls fall back to the plain bounded scan). componentScoped (CFML/PHP whole-file sweep) is keyed out. States drop with the context's file caches via clearNameMatcherMemos, wired into ReferenceResolver.clearCaches. kong mc-infer misses 61→20µs (2.4s→0.8s combined); fresh index 3.43 → 3.03-3.20s (−8%; 4.07 → 3.14 cumulative with #1391). tokio unchanged (tight scopes). Gates: dubbo (49k Java instance-method HITS ride this scan), kong, tokio, Fusion dumps all byte-identical; suite 2,689 ×2 with CODEGRAPH_KERNEL_EXPECT=1. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
abb0a916f7
commit
974e6c8b95
@@ -24,6 +24,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- Indexing very large projects on multi-core machines got faster again: the parallel-resolution workers now periodically refresh their read-only database connections, which lets database housekeeping advance instead of silently building up a backlog behind long-lived readers — a backlog that was taxing the indexer's own writes. Graphs remain byte-for-byte identical; the win is largest at Linux-kernel scale on many-core machines.
|
||||
- Indexing on macOS now uses the machine's real memory headroom when sizing its parallel-resolution workers. macOS deliberately keeps RAM filled with reclaimable cache, so the previous free-memory reading came back tiny (~1GB on an otherwise idle machine) and silently halved the worker pool — a medium Java project's fresh index ran about 15–20% slower than the hardware allowed. Graphs remain byte-for-byte identical; the same fix also lets a memory-driven analysis cache engage fully on macOS for large C codebases.
|
||||
- Fresh indexing got a sizeable across-the-board speedup: during the initial build, the database's secondary lookup indexes are set aside and rebuilt once after parsing instead of being maintained row by row — the same proven trick the later linking phase already used, now applied to the whole parse lane — and the reference-resolution loop likewise stops maintaining lookup indexes it never reads, rebuilding them at the end when almost nothing is left in the table. A medium Java project's parse phase runs about 58% faster and its full fresh index about 19% faster end-to-end; a Linux-kernel-scale index that took ~15 minutes on an 8-core machine now completes in about 11, with the resolution phase alone dropping by a third. Graphs remain byte-for-byte identical, and incremental syncs are unaffected.
|
||||
- Resolving method calls through local variables (`recv.method()`, Lua's `recv:method()`, R's `recv$method()`) got much cheaper on repos where the same receiver is called over and over: the declaration scan that types the receiver now remembers what it has already scanned per scope instead of re-reading the same source lines for every call site, and the regex patterns it scans with are compiled once per receiver instead of per call. Kong's fresh index drops another 8% on top of the require-resolution fix (23% cumulative), with graphs byte-for-byte identical everywhere — including Java projects, where this same scan successfully types tens of thousands of receivers.
|
||||
- Indexing Lua and Luau projects got a sizeable speedup: resolving each `require(...)` no longer rescans the project's entire file list four times — a per-project filename index answers the same lookup instantly, cutting per-require resolution from about a millisecond to microseconds. A fresh index of Kong (1,870 Lua files) runs about 16% faster end-to-end, with the graph byte-for-byte identical. The same housekeeping also closes a latent staleness edge where COBOL copybook lookups could keep serving a cached file list after files changed.
|
||||
- Parallel reference resolution now engages adaptively instead of by a fixed project-size cutoff: the indexer measures the actual per-reference resolution rate on the first batch and spins up the worker pool mid-run whenever the remaining work justifies it. Languages whose references are expensive to resolve benefit most — Rust especially: a fresh index of tokio runs about 23% faster, with the graph byte-for-byte identical. Small projects and low-core machines (2-core CI runners) keep the single-threaded path exactly as before.
|
||||
- The dynamic-dispatch analysis at the end of indexing now skips passes that provably can't produce anything for the project at hand: React re-render bridging when no class has a `render` method, React Native and Expo cross-platform pairing when the required languages aren't present, and MyBatis mapper linking when there's no mapper XML. Previously each of these scanned the whole graph before coming up empty — on a 4,000-file Java project that was about 0.9 seconds of wasted analysis per fresh index. The interface-implementation bridging pass also got cheaper on real work: it no longer re-fetches a hub interface's method list once per implementer, and classes that extend or implement nothing are skipped before any per-class lookups. Graphs remain byte-for-byte identical.
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
FrameworkResolver,
|
||||
ImportMapping,
|
||||
} from './types';
|
||||
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile } from './name-matcher';
|
||||
import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
|
||||
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver';
|
||||
import { ResolverPool, minRefsForPool } from './resolver-pool';
|
||||
import { detectFrameworks } from './frameworks';
|
||||
@@ -373,9 +373,12 @@ export class ReferenceResolver {
|
||||
this.knownNames = null;
|
||||
this.knownFiles = null;
|
||||
this.cachesWarmed = false;
|
||||
// The import-resolver's per-context memos assume the same stable window
|
||||
// as the caches above — drop them together.
|
||||
if (this.context) clearImportResolverMemos(this.context);
|
||||
// The import-resolver's and name-matcher's per-context memos assume the
|
||||
// same stable window as the caches above — drop them together.
|
||||
if (this.context) {
|
||||
clearImportResolverMemos(this.context);
|
||||
clearNameMatcherMemos(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
/** `readFile` through the LRU content cache (null = read failed, also cached). */
|
||||
|
||||
+165
-41
@@ -1128,7 +1128,64 @@ export function normalizeInferredTypeName(raw: string): string | null {
|
||||
* PascalCase is required in the capture where the language convention allows,
|
||||
* as a cheap false-positive guard on top of resolveMethodOnType's validation.
|
||||
*/
|
||||
/**
|
||||
* Compiled-pattern memo for the receiver-type pattern builders below. They
|
||||
* run for EVERY `receiver.method()` ref the matcher attempts, compiling 2–4
|
||||
* fresh RegExp objects per call — and receivers repeat massively (`self`
|
||||
* alone accounts for tens of thousands of refs on a Lua repo, measured 41µs
|
||||
* per methodCall miss on kong with compilation a large slice). The patterns
|
||||
* are a pure function of (language, receiver) and non-global (`.match()`
|
||||
* never touches lastIndex), so shared instances are behavior-identical.
|
||||
* FIFO-capped with no per-get mutation (the §7a.6 LRU-churn lesson): a hit
|
||||
* costs one Map lookup, overflow evicts oldest, and an evicted entry simply
|
||||
* recompiles exactly as every call did before this memo.
|
||||
*/
|
||||
const PATTERN_MEMO = new Map<string, RegExp[]>();
|
||||
const PATTERN_MEMO_CAP = 8192;
|
||||
|
||||
/**
|
||||
* Per-context incremental receiver-scan states for inferLocalReceiverType
|
||||
* (see the memo comment there). Keyed (file, scopeStart, language, receiver);
|
||||
* entries are a few dozen bytes, count is bounded by distinct receiver uses
|
||||
* (same order as the context's other per-file caches). MUST drop whenever the
|
||||
* context's file caches drop — the states are derived from file lines — so
|
||||
* ReferenceResolver.clearCaches calls clearNameMatcherMemos alongside
|
||||
* clearImportResolverMemos.
|
||||
*/
|
||||
type InferScanState = { hi: number; ansIdx: number; ansType: string | null };
|
||||
const INFER_SCAN_STATES = new WeakMap<ResolutionContext, Map<string, InferScanState>>();
|
||||
|
||||
function getInferScanStates(context: ResolutionContext): Map<string, InferScanState> {
|
||||
let m = INFER_SCAN_STATES.get(context);
|
||||
if (!m) {
|
||||
m = new Map();
|
||||
INFER_SCAN_STATES.set(context, m);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Drop the per-context scan states (see ReferenceResolver.clearCaches). */
|
||||
export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
INFER_SCAN_STATES.delete(context);
|
||||
}
|
||||
|
||||
function memoPatterns(key: string, build: () => RegExp[]): RegExp[] {
|
||||
const hit = PATTERN_MEMO.get(key);
|
||||
if (hit) return hit;
|
||||
const patterns = build();
|
||||
if (PATTERN_MEMO.size >= PATTERN_MEMO_CAP) {
|
||||
const oldest = PATTERN_MEMO.keys().next().value;
|
||||
if (oldest !== undefined) PATTERN_MEMO.delete(oldest);
|
||||
}
|
||||
PATTERN_MEMO.set(key, patterns);
|
||||
return patterns;
|
||||
}
|
||||
|
||||
export function localReceiverTypePatterns(language: Language, r: string): RegExp[] {
|
||||
return memoPatterns(`${language}|${r}`, () => buildLocalReceiverTypePatterns(language, r));
|
||||
}
|
||||
|
||||
function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] {
|
||||
switch (language) {
|
||||
case 'typescript':
|
||||
case 'javascript':
|
||||
@@ -1375,6 +1432,53 @@ function inferLocalReceiverType(
|
||||
return null;
|
||||
};
|
||||
|
||||
// Incremental-scan memo (INFER_SCAN_STATES): this scan runs for EVERY
|
||||
// `receiver.method()` ref and was measured at 61µs/ref on kong (2.4s of
|
||||
// worker time, 99% misses — `self:` calls hunting a declaration Lua never
|
||||
// writes). Refs for the same (file, scope, receiver) arrive in ~ascending
|
||||
// line order, and the scan is a pure function of the file's immutable
|
||||
// lines, so each line pays its regex matches ONCE per key instead of once
|
||||
// per ref: query(c) = highest matching line in [startIdx..c]; a monotonic
|
||||
// call extends the stored watermark by scanning only (hi..c] (the region
|
||||
// at-or-below the previous answer is already proven empty above it); a
|
||||
// non-monotonic call (rare — refs are rowid-ordered) falls back to the
|
||||
// plain bounded scan and leaves the state alone. componentScoped is keyed
|
||||
// out — its position-independent whole-file sweep below has different
|
||||
// semantics.
|
||||
if (!componentScoped) {
|
||||
const states = getInferScanStates(context);
|
||||
const key = `${ref.filePath}|${startIdx}|${ref.language}|${scanReceiver}`;
|
||||
const state = states.get(key);
|
||||
if (!state) {
|
||||
for (let i = callIdx; i >= startIdx; i--) {
|
||||
const type = matchLine(i);
|
||||
if (type) {
|
||||
states.set(key, { hi: callIdx, ansIdx: i, ansType: type });
|
||||
return type;
|
||||
}
|
||||
}
|
||||
states.set(key, { hi: callIdx, ansIdx: -1, ansType: null });
|
||||
return null;
|
||||
}
|
||||
if (callIdx >= state.hi) {
|
||||
for (let i = callIdx; i > state.hi; i--) {
|
||||
const type = matchLine(i);
|
||||
if (type) {
|
||||
state.ansIdx = i;
|
||||
state.ansType = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state.hi = callIdx;
|
||||
return state.ansIdx >= startIdx ? state.ansType : null;
|
||||
}
|
||||
for (let i = callIdx; i >= startIdx; i--) {
|
||||
const type = matchLine(i);
|
||||
if (type) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nearest declaration wins: scan backward from the call to the scope start.
|
||||
for (let i = callIdx; i >= startIdx; i--) {
|
||||
const type = matchLine(i);
|
||||
@@ -1416,6 +1520,10 @@ function inferLocalReceiverType(
|
||||
* shape is handled by inferPhpAssignedPropertyType instead.
|
||||
*/
|
||||
function phpPropertyTypePatterns(r: string): RegExp[] {
|
||||
return memoPatterns(`php-prop|${r}`, () => buildPhpPropertyTypePatterns(r));
|
||||
}
|
||||
|
||||
function buildPhpPropertyTypePatterns(r: string): RegExp[] {
|
||||
return [
|
||||
new RegExp(
|
||||
`\\b(?:(?:private|protected|public|readonly|static|final)(?:\\(set\\))?\\s+)+\\??([A-Za-z_\\\\][\\w\\\\]*)\\s+&?\\$${r}\\b`,
|
||||
@@ -1564,10 +1672,10 @@ export function matchMethodCall(
|
||||
// shared source-based inferrer. resolveMethodOnType validates the method
|
||||
// exists on the inferred type, so a mis-inference produces no edge.
|
||||
if (inferableReceiver) {
|
||||
const inferredType =
|
||||
const inferredType = nmTimedT('mc-infer', ref, () =>
|
||||
ref.language === 'cpp'
|
||||
? inferCppReceiverType(objectOrClass!, ref, context)
|
||||
: inferLocalReceiverType(objectOrClass!, ref, context);
|
||||
: inferLocalReceiverType(objectOrClass!, ref, context));
|
||||
if (inferredType) {
|
||||
// Java/Kotlin: when two classes share the simple name, the file's import
|
||||
// pins WHICH one (#314). Other languages disambiguate by call-site file.
|
||||
@@ -1640,44 +1748,13 @@ export function matchMethodCall(
|
||||
// with a `Logger` in both `a/` and `b/`), try the class in the call site's
|
||||
// own file first — otherwise the first-indexed class wins and a call in `b/`
|
||||
// resolves to `a/`'s method (#1079).
|
||||
const classCandidates = preferCallSiteFile(
|
||||
context.getNodesByName(objectOrClass!),
|
||||
ref.filePath,
|
||||
);
|
||||
|
||||
for (const classNode of classCandidates) {
|
||||
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
|
||||
// Skip cross-language class matches
|
||||
if (classNode.language !== ref.language) continue;
|
||||
|
||||
const nodesInFile = context.getNodesInFile(classNode.filePath);
|
||||
const methodNode = nodesInFile.find(
|
||||
(n) =>
|
||||
n.kind === 'method' &&
|
||||
n.name === methodName &&
|
||||
n.qualifiedName.includes(classNode.name)
|
||||
);
|
||||
|
||||
if (methodNode) {
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: methodNode.id,
|
||||
confidence: 0.85,
|
||||
resolvedBy: 'qualified-name',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Instance variable receiver - try capitalized form to find class
|
||||
// e.g., "permissionEngine" → look for classes containing "PermissionEngine"
|
||||
const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1);
|
||||
if (capitalizedReceiver !== objectOrClass) {
|
||||
const fuzzyClassCandidates = preferCallSiteFile(
|
||||
context.getNodesByName(capitalizedReceiver),
|
||||
const strat1 = nmTimedT('mc-class', ref, (): ResolvedRef | null => {
|
||||
const classCandidates = preferCallSiteFile(
|
||||
context.getNodesByName(objectOrClass!),
|
||||
ref.filePath,
|
||||
);
|
||||
for (const classNode of fuzzyClassCandidates) {
|
||||
|
||||
for (const classNode of classCandidates) {
|
||||
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
|
||||
// Skip cross-language class matches
|
||||
if (classNode.language !== ref.language) continue;
|
||||
@@ -1694,18 +1771,58 @@ export function matchMethodCall(
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: methodNode.id,
|
||||
confidence: 0.8,
|
||||
resolvedBy: 'instance-method',
|
||||
confidence: 0.85,
|
||||
resolvedBy: 'qualified-name',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (strat1) return strat1;
|
||||
|
||||
// Strategy 2: Instance variable receiver - try capitalized form to find class
|
||||
// e.g., "permissionEngine" → look for classes containing "PermissionEngine"
|
||||
const capitalizedReceiver = objectOrClass!.charAt(0).toUpperCase() + objectOrClass!.slice(1);
|
||||
if (capitalizedReceiver !== objectOrClass) {
|
||||
const strat2 = nmTimedT('mc-capital', ref, (): ResolvedRef | null => {
|
||||
const fuzzyClassCandidates = preferCallSiteFile(
|
||||
context.getNodesByName(capitalizedReceiver),
|
||||
ref.filePath,
|
||||
);
|
||||
for (const classNode of fuzzyClassCandidates) {
|
||||
if (classNode.kind === 'class' || classNode.kind === 'struct' || classNode.kind === 'interface') {
|
||||
// Skip cross-language class matches
|
||||
if (classNode.language !== ref.language) continue;
|
||||
|
||||
const nodesInFile = context.getNodesInFile(classNode.filePath);
|
||||
const methodNode = nodesInFile.find(
|
||||
(n) =>
|
||||
n.kind === 'method' &&
|
||||
n.name === methodName &&
|
||||
n.qualifiedName.includes(classNode.name)
|
||||
);
|
||||
|
||||
if (methodNode) {
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: methodNode.id,
|
||||
confidence: 0.8,
|
||||
resolvedBy: 'instance-method',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (strat2) return strat2;
|
||||
}
|
||||
|
||||
// Strategy 3: Find methods by name across the codebase, match by receiver
|
||||
// name similarity with the containing class. Handles abbreviated variable
|
||||
// names like permissionEngine → PermissionRuleEngine.
|
||||
if (methodName) {
|
||||
const strat3 = nmTimedT('mc-byname', ref, (): ResolvedRef | null => {
|
||||
const methodCandidates = context.getNodesByName(methodName!);
|
||||
// Ubiquitous-method ceiling (#999): a method name re-declared across a
|
||||
// vendored theme/SDK (Metronic's `init`/`update`/… on every widget) yields
|
||||
@@ -1765,6 +1882,9 @@ export function matchMethodCall(
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (strat3) return strat3;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -2057,7 +2177,7 @@ const ARKUI_ATTRIBUTE_DECORATORS = new Set(['Extend', 'Styles', 'AnimatableExten
|
||||
const NM_PROFILE: Map<string, { n: number; ns: bigint }> | null =
|
||||
process.env.CODEGRAPH_RESOLVE_PROFILE === '2' ? new Map() : null;
|
||||
|
||||
function nmTimed(stage: string, ref: UnresolvedRef, fn: () => ResolvedRef | null): ResolvedRef | null {
|
||||
function nmTimedT<T>(stage: string, ref: UnresolvedRef, fn: () => T): T {
|
||||
if (!NM_PROFILE) return fn();
|
||||
const t0 = process.hrtime.bigint();
|
||||
const r = fn();
|
||||
@@ -2073,6 +2193,10 @@ function nmTimed(stage: string, ref: UnresolvedRef, fn: () => ResolvedRef | null
|
||||
return r;
|
||||
}
|
||||
|
||||
function nmTimed(stage: string, ref: UnresolvedRef, fn: () => ResolvedRef | null): ResolvedRef | null {
|
||||
return nmTimedT(stage, ref, fn);
|
||||
}
|
||||
|
||||
/** Dump this thread's matchReference sub-stage table to stderr (no-op unless =2). */
|
||||
export function dumpNameMatcherProfile(label: string): void {
|
||||
if (!NM_PROFILE || NM_PROFILE.size === 0) return;
|
||||
|
||||
Reference in New Issue
Block a user