From 974e6c8b957054ecba6053726546ea5ef4c1d9d0 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 21 Jul 2026 00:20:03 -0500 Subject: [PATCH] =?UTF-8?q?perf(resolution):=20incremental=20receiver-infe?= =?UTF-8?q?rence=20scan=20memo=20+=20compiled-pattern=20memo=20=E2=80=94?= =?UTF-8?q?=20kong=20=E2=88=928%=20more=20(=E2=88=9223%=20cumulative),=20b?= =?UTF-8?q?yte-identical=20(#1392)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + src/resolution/index.ts | 11 +- src/resolution/name-matcher.ts | 206 ++++++++++++++++++++++++++------- 3 files changed, 173 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5693a5c..38c9c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 82914db..b5b071c 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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). */ diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index d98c98c..2caa9e7 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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(); +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>(); + +function getInferScanStates(context: ResolutionContext): Map { + 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 | null = process.env.CODEGRAPH_RESOLVE_PROFILE === '2' ? new Map() : null; -function nmTimed(stage: string, ref: UnresolvedRef, fn: () => ResolvedRef | null): ResolvedRef | null { +function nmTimedT(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;