Sfoglia il codice sorgente

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>
Colby Mchenry 1 mese fa
parent
commit
974e6c8b95
3 ha cambiato i file con 173 aggiunte e 45 eliminazioni
  1. 1 0
      CHANGELOG.md
  2. 7 4
      src/resolution/index.ts
  3. 165 41
      src/resolution/name-matcher.ts

+ 1 - 0
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.

+ 7 - 4
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). */

+ 165 - 41
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<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;