瀏覽代碼

perf(resolution): generation-tagged supertype memo + method owner index — Swift compiler 185→98s, byte-identical

The swiftc =2/nm:mc-* attribution located the wall: the
getSupertypes conformance walk ran 971,200 times (565s of combined
worker time, 581µs each) — every resolveMethodOnType miss re-queried
implements/extends edges for every same-named type node, recursing
depth-4 through Swift's protocol landscape with no memoization, and
post-inference resolveMethodOnType averaged 1,912µs per call.

Fix 1 — generation-tagged getSupertypes memo. Supertype edges GROW
during the resolution loop (batch k persists its edges BEFORE batch
k+1 fans out — the #1320 ordering), so a plain cache would freeze an
early batch's emptier answer. Within a batch the edge state is fixed
by that same ordering, so memo entries carry a generation that
advances at every batch entry point (resolveBatchYielding /
resolveListForAdmission — covering the sequential loop, pool workers,
sync admission, and the conformance pass); a stale-gen entry
recomputes. Behavior-identical to no memo at every point in time;
walk invocation counts match the unmemoized run exactly (971,200 /
24,336 / 76,415).

Fix 2 — per-(language, method-name) owner index in getMethodMatches:
candidates bucket once by their qualifiedName's last two segments
(exactly the span the match predicate tests), so a (type, method)
query is a map lookup instead of an O(candidates) scan per
methodMatchCache miss. ObjC selectors and multi-segment typeNames
keep the legacy linear path. Also ships nm:mc-rmot / nm:rmot-supers
=2 attribution rows.

swiftc: settle 100.2→31.3s, resolveMethodOnType 1,912→202µs, wall
183.5→97.8s (was 185s at the head-to-head; cbm's same-box number is
119.1s). Gates: swiftc old-vs-new dump byte-identical (1,837,235
rows), swiftc pooled-vs-CODEGRAPH_NO_PARALLEL_RESOLVE=1 identical
(the generation-semantics risk surface), dubbo old-vs-new identical
(49k Java instance-method hits share both paths), Alamofire
identical; suite 2,689 ×2 with CODEGRAPH_KERNEL_EXPECT=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Colby McHenry 1 月之前
父節點
當前提交
fa25f20917
共有 3 個文件被更改,包括 112 次插入23 次删除
  1. 1 0
      CHANGELOG.md
  2. 97 13
      src/resolution/index.ts
  3. 14 10
      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 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.
 - 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.
 - 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.
+- Indexing Swift and other protocol/interface-heavy codebases got dramatically faster: the conformance walk that checks whether a method lives on a receiver's supertypes (protocols, base classes, extensions) now remembers its answers for the duration of each resolution batch instead of re-querying the graph for every call site — on the Swift compiler repository (27k files) that walk ran nearly a million times per index. A fresh index of that repo drops from about 185 seconds to under 100, with the graph byte-for-byte identical. Method-candidate lookup also gained a per-name owner index, so overload-heavy names (`init` in Swift, `execute` in Java) no longer pay a full candidate scan per receiver type.
 - 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.
 - 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.
 - 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.
 - 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.

+ 97 - 13
src/resolution/index.ts

@@ -233,6 +233,32 @@ export class ReferenceResolver {
   private qualifiedNameCache: LRUCache<string, Node[]>; // qualified_name → nodes cache
   private qualifiedNameCache: LRUCache<string, Node[]>; // qualified_name → nodes cache
   private fileLinesCache: LRUCache<string, string[] | null>; // file → split lines cache
   private fileLinesCache: LRUCache<string, string[] | null>; // file → split lines cache
   private methodMatchCache: LRUCache<string, Node[]>; // lang\0Type::method → matching method nodes
   private methodMatchCache: LRUCache<string, Node[]>; // lang\0Type::method → matching method nodes
+  // Per-(language, methodName) owner index for getMethodMatches: buckets a
+  // method name's candidates by their qualifiedName's last two segments so a
+  // (type, method) query is a lookup instead of an O(candidates) filter per
+  // methodMatchCache miss. Derived purely from node rows (stable through the
+  // resolution loop, same window nameCache relies on); dropped in clearCaches.
+  private methodOwnerIndexCache = new Map<string, Map<string, Node[]>>();
+  // Generation-tagged memo for getSupertypes. Supertype edges GROW during the
+  // resolution loop (batch k persists its implements/extends edges BEFORE
+  // batch k+1 fans out — the #1320 ordering), so a plain cache would freeze an
+  // early batch's emptier answer and change later batches' outcomes. Within
+  // one batch the edge state is fixed by that same ordering, so entries are
+  // tagged with a generation that advances at every batch entry point
+  // (resolveBatchYielding / resolveListForAdmission) — a stale-gen entry is
+  // recomputed, making the memo behavior-identical to no memo at every point
+  // in time. On the Swift compiler the unmemoized walk ran 971k times for
+  // 565s of combined worker time (~581µs each, recursion-multiplied).
+  private supertypeGen = 0;
+  private supertypeMemo = new Map<string, { gen: number; supers: string[] }>();
+
+  /** Invalidate the getSupertypes memo — call when resolved edges may have advanced. */
+  private advanceSupertypeGeneration(): void {
+    this.supertypeGen++;
+    // Lazy invalidation via the gen tag; bound the map so a long run over many
+    // batches doesn't accrete dead entries.
+    if (this.supertypeMemo.size > 50_000) this.supertypeMemo.clear();
+  }
   // Node kinds are a small fixed set (~24), so this is a plain Map, not an LRU.
   // Node kinds are a small fixed set (~24), so this is a plain Map, not an LRU.
   // getNodesByKind returns the FULL node list for a kind; it was previously
   // getNodesByKind returns the FULL node list for a kind; it was previously
   // uncached — a per-ref `SELECT * FROM nodes WHERE kind=?` + row-mapping. Called
   // uncached — a per-ref `SELECT * FROM nodes WHERE kind=?` + row-mapping. Called
@@ -369,6 +395,9 @@ export class ReferenceResolver {
     this.qualifiedNameCache.clear();
     this.qualifiedNameCache.clear();
     this.fileLinesCache.clear();
     this.fileLinesCache.clear();
     this.methodMatchCache.clear();
     this.methodMatchCache.clear();
+    this.methodOwnerIndexCache.clear();
+    this.supertypeMemo.clear();
+    this.supertypeGen++;
     this.nodesByKindCache.clear();
     this.nodesByKindCache.clear();
     this.knownNames = null;
     this.knownNames = null;
     this.knownFiles = null;
     this.knownFiles = null;
@@ -428,12 +457,52 @@ export class ReferenceResolver {
           this.nameCache.set(methodName, candidates);
           this.nameCache.set(methodName, candidates);
         }
         }
         const want = `${typeName}::${methodName}`;
         const want = `${typeName}::${methodName}`;
-        const matches: Node[] = [];
-        for (const m of candidates) {
-          if (m.kind !== 'method') continue;
-          if (m.language !== language) continue;
-          const qn = m.qualifiedName;
-          if (qn === want || qn.endsWith(`::${want}`)) matches.push(m);
+        let matches: Node[];
+        if (typeName.includes('::') || methodName.includes(':')) {
+          // Legacy linear filter for the shapes the owner index below can't
+          // key exactly: a multi-segment typeName (the endsWith test then
+          // spans more than two `::` segments) and ObjC selectors (whose
+          // single/empty-keyword colons defeat the segment split). Tiny
+          // populations; the per-key memo above still amortizes them.
+          matches = [];
+          for (const m of candidates) {
+            if (m.kind !== 'method') continue;
+            if (m.language !== language) continue;
+            const qn = m.qualifiedName;
+            if (qn === want || qn.endsWith(`::${want}`)) matches.push(m);
+          }
+        } else {
+          // Owner index: the linear filter above is O(all same-named methods)
+          // per CACHE MISS, and on overload-heavy landscapes the distinct
+          // (type, method) key space is so large the per-key memo never
+          // amortizes — Swift's `init` has tens of thousands of candidates
+          // and the compiler repo measured 732µs per failing call, most of it
+          // this scan (re-entered once per supertype recursion level, too).
+          // Bucket each (language, methodName)'s candidates ONCE by the
+          // qualifiedName's last two `::` segments — exactly the span the
+          // `qn === want || qn.endsWith('::' + want)` predicate tests for a
+          // segment-clean typeName — then every query is a map lookup.
+          // Bucket insertion follows candidate order, so each bucket is
+          // byte-identical to what the linear filter produced.
+          const idxKey = `${language} ${methodName}`;
+          let ownerIndex = this.methodOwnerIndexCache.get(idxKey);
+          if (!ownerIndex) {
+            ownerIndex = new Map<string, Node[]>();
+            for (const m of candidates) {
+              if (m.kind !== 'method') continue;
+              if (m.language !== language) continue;
+              const qn = m.qualifiedName;
+              const i2 = qn.lastIndexOf('::');
+              if (i2 < 0) continue; // single-segment qn can never match `T::m`
+              const i1 = qn.lastIndexOf('::', i2 - 1);
+              const bucketKey = i1 < 0 ? qn : qn.slice(i1 + 2);
+              const bucket = ownerIndex.get(bucketKey);
+              if (bucket) bucket.push(m);
+              else ownerIndex.set(bucketKey, [m]);
+            }
+            this.methodOwnerIndexCache.set(idxKey, ownerIndex);
+          }
+          matches = ownerIndex.get(want) ?? [];
         }
         }
         this.methodMatchCache.set(key, matches);
         this.methodMatchCache.set(key, matches);
         return matches;
         return matches;
@@ -531,18 +600,31 @@ export class ReferenceResolver {
         // Matching by simple name (not id) reconciles a type declared in one node
         // Matching by simple name (not id) reconciles a type declared in one node
         // (`KF::Builder`) with conformance declared in a separate extension node
         // (`KF::Builder`) with conformance declared in a separate extension node
         // (`KF.Builder: KFOptionSetter`) — both have name `Builder`.
         // (`KF.Builder: KFOptionSetter`) — both have name `Builder`.
+        // Memoized per batch generation (see supertypeMemo): within a batch the
+        // edge state is fixed, and the conformance walk re-queries the same
+        // popular supertypes (Swift stdlib protocols especially) thousands of
+        // times per batch.
+        const memoKey = `${language} ${typeName}`;
+        const hit = this.supertypeMemo.get(memoKey);
+        if (hit && hit.gen === this.supertypeGen) return hit.supers;
         const typeNodes = this.context
         const typeNodes = this.context
           .getNodesByName(typeName)
           .getNodesByName(typeName)
           .filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language);
           .filter((n) => SUPERTYPE_BEARING_KINDS.has(n.kind) && n.language === language);
-        if (typeNodes.length === 0) return [];
-        const supertypes = new Set<string>();
-        for (const tn of typeNodes) {
-          for (const edge of this.queries.getOutgoingEdges(tn.id, ['implements', 'extends'])) {
-            const target = this.queries.getNodeById(edge.target);
-            if (target?.name && target.name !== typeName) supertypes.add(target.name);
+        let supers: string[];
+        if (typeNodes.length === 0) {
+          supers = [];
+        } else {
+          const supertypes = new Set<string>();
+          for (const tn of typeNodes) {
+            for (const edge of this.queries.getOutgoingEdges(tn.id, ['implements', 'extends'])) {
+              const target = this.queries.getNodeById(edge.target);
+              if (target?.name && target.name !== typeName) supertypes.add(target.name);
+            }
           }
           }
+          supers = [...supertypes];
         }
         }
-        return [...supertypes];
+        this.supertypeMemo.set(memoKey, { gen: this.supertypeGen, supers });
+        return supers;
       },
       },
 
 
       getImportMappings: (filePath: string, language) => {
       getImportMappings: (filePath: string, language) => {
@@ -1235,6 +1317,7 @@ export class ReferenceResolver {
     maybeYield: MaybeYield
     maybeYield: MaybeYield
   ): Promise<ResolutionResult> {
   ): Promise<ResolutionResult> {
     this.warmCaches();
     this.warmCaches();
+    this.advanceSupertypeGeneration();
 
 
     const resolved: ResolvedRef[] = [];
     const resolved: ResolvedRef[] = [];
     const unresolved: UnresolvedRef[] = [];
     const unresolved: UnresolvedRef[] = [];
@@ -1356,6 +1439,7 @@ export class ReferenceResolver {
     byMethod: Record<string, number>;
     byMethod: Record<string, number>;
   } {
   } {
     this.warmCaches();
     this.warmCaches();
+    this.advanceSupertypeGeneration();
     const resolved: ResolvedRef[] = [];
     const resolved: ResolvedRef[] = [];
     const unresolved: UnresolvedRef[] = [];
     const unresolved: UnresolvedRef[] = [];
     const byMethod: Record<string, number> = {};
     const byMethod: Record<string, number> = {};

+ 14 - 10
src/resolution/name-matcher.ts

@@ -587,12 +587,16 @@ export function resolveMethodOnType(
     // populated in the conformance pass. Still VALIDATED (the method must exist on
     // populated in the conformance pass. Still VALIDATED (the method must exist on
     // a supertype), so a wrong inference produces no edge.
     // a supertype), so a wrong inference produces no edge.
     if (depth < 4 && context.getSupertypes) {
     if (depth < 4 && context.getSupertypes) {
-      for (const supertype of context.getSupertypes(typeName, ref.language)) {
-        const via = resolveMethodOnType(
-          supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1,
-        );
-        if (via) return via;
-      }
+      const viaSupers = nmTimedT('rmot-supers', ref, (): ResolvedRef | null => {
+        for (const supertype of context.getSupertypes!(typeName, ref.language)) {
+          const via = resolveMethodOnType(
+            supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1,
+          );
+          if (via) return via;
+        }
+        return null;
+      });
+      if (viaSupers) return viaSupers;
     }
     }
     return null;
     return null;
   }
   }
@@ -1685,7 +1689,7 @@ export function matchMethodCall(
               .getImportMappings(ref.filePath, ref.language)
               .getImportMappings(ref.filePath, ref.language)
               .find((i) => i.localName === inferredType)?.source
               .find((i) => i.localName === inferredType)?.source
           : undefined;
           : undefined;
-      const typedMatch = resolveMethodOnType(
+      const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
         inferredType,
         inferredType,
         methodName!,
         methodName!,
         ref,
         ref,
@@ -1693,7 +1697,7 @@ export function matchMethodCall(
         0.9,
         0.9,
         'instance-method',
         'instance-method',
         importedFqn,
         importedFqn,
-      );
+      ));
       if (typedMatch) {
       if (typedMatch) {
         return typedMatch;
         return typedMatch;
       }
       }
@@ -1728,7 +1732,7 @@ export function matchMethodCall(
       // imported FQN so resolveMethodOnType can disambiguate (#314).
       // imported FQN so resolveMethodOnType can disambiguate (#314).
       const imports = context.getImportMappings(ref.filePath, ref.language);
       const imports = context.getImportMappings(ref.filePath, ref.language);
       const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
       const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
-      const typedMatch = resolveMethodOnType(
+      const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType(
         inferredType,
         inferredType,
         methodName!,
         methodName!,
         ref,
         ref,
@@ -1736,7 +1740,7 @@ export function matchMethodCall(
         0.9,
         0.9,
         'instance-method',
         'instance-method',
         importedFqn,
         importedFqn,
-      );
+      ));
       if (typedMatch) {
       if (typedMatch) {
         return typedMatch;
         return typedMatch;
       }
       }