Procházet zdrojové kódy

perf(resolution): darwin-honest memory budget — vm_stat-based availability unstrangles the resolver pool on macOS (#1388)

Post-R7b store-arc round 1, found by the dubbo warm-wall decomposition
(the cbm bar): resolution's loop-stage profile showed settle=3.0s — the
main thread idling on TWO resolver workers on an 11-core Mac. Pool sizing
logged `size=2 (budget=1068MB)`: memoryBudgetBytes() falls back to
os.freemem() when uncontained, and macOS keeps RAM deliberately full of
reclaimable cache, so freemem reads ~1GB on a mostly-idle 64GB machine.
The memory term then capped the pool at 2 where the CPU term allowed 6 —
the macOS sibling of §7a.1's os.cpus() cpuset-blindness (that round fixed
the CPU term; this fixes the memory term).

Fix: darwinMemoryAvailable() reads /usr/bin/vm_stat once per sizing call
and reports free + inactive + speculative + purgeable pages — what
Activity Monitor calls available, the same reclaimable-inclusive
convention the Linux branch already uses by crediting inactive_file back.
Parse failure → null → freemem fallback; Linux/cgroup and Windows paths
untouched.

Measured (dubbo 4,402 files, warm, caffeinated, n=3 each): pool now
self-sizes to 6 (budget 5.7-6.3GB) — wall 8.62-8.83s vs 9.67-10.87s
baseline, resolution phase 6.9→5.3s, loop settle 3.0→1.9s. Matches the
CODEGRAPH_RESOLVE_WORKERS=6 probe exactly (probe-before-build). Dumps
byte-identical pool-6 vs sequential (441,270 lines). Second consumer
unblocked: the cFnPtr LRU cache cap no longer spuriously degrades to 128
on Macs (its full-cache tier is worth ~60s at kernel scale).

Suite: resolver-pool-sizing gains a darwin-gated reclaimable-pages test +
an off-darwin null pin; full suite 2,689 green ×2 with
CODEGRAPH_KERNEL_EXPECT=1.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry před 1 měsícem
rodič
revize
27c3c55436

+ 1 - 0
CHANGELOG.md

@@ -22,6 +22,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - Every release is now cryptographically verifiable: npm packages publish with npm provenance (the "Provenance" badge on npmjs.com, proving each version was built by this repository's release workflow from a specific commit), and the GitHub Release bundles carry signed build attestations you can check with `gh attestation verify <file> -R colbymchenry/codegraph`.
 - Indexing inside CPU- or memory-limited containers (Docker, CI runners) now sizes its worker pools from the container's actual allowance instead of the host machine's, and giant codebases no longer balloon temporary database files during indexing (previously tens of GB of transient disk on Linux-kernel-scale projects). Together these prevent out-of-memory and out-of-disk failures on constrained machines; set `CODEGRAPH_RESOLVE_WORKERS` to override the resolution worker count explicitly.
 - 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.
 - Indexing large C and C++ codebases spends much less time in the function-pointer dispatch analysis (the pass that connects handler tables like a command table or an ops struct to their call sites): each source file is now read and prepared once instead of four times, files that can't contribute any dispatch wiring are skipped outright in the later linking steps, and on platforms with the native engine the per-file scanning itself now runs natively too. On a Linux-kernel-scale tree the pass runs about a third faster end-to-end, with graphs byte-for-byte identical; platforms without a native binary keep the same results on the previous path.
 

+ 29 - 1
__tests__/resolver-pool-sizing.test.ts

@@ -7,8 +7,13 @@
  * 7GB container at true 8-core concurrency).
  */
 import { describe, it, expect } from 'vitest';
+import * as os from 'os';
 import { ResolverPool } from '../src/resolution/resolver-pool';
-import { cgroupMemoryAvailable, memoryBudgetBytes } from '../src/resolution/memory-budget';
+import {
+  cgroupMemoryAvailable,
+  darwinMemoryAvailable,
+  memoryBudgetBytes,
+} from '../src/resolution/memory-budget';
 
 const GB = 1024 * 1024 * 1024;
 const MB = 1024 * 1024;
@@ -77,4 +82,27 @@ describe('memory budget helpers', () => {
       expect(v === null || (v >= 0 && Number.isFinite(v))).toBe(true);
     }
   });
+
+  it.runIf(process.platform === 'darwin')(
+    'darwin: available memory counts reclaimable pages, not just free_count',
+    () => {
+      const v = darwinMemoryAvailable();
+      // vm_stat exists on every macOS; a null here means the parse broke.
+      expect(v).not.toBeNull();
+      expect(Number.isFinite(v!)).toBe(true);
+      // The sum includes the free pages freemem() counts, so it can only be
+      // larger (modulo TOCTOU drift between the two reads — allow slack).
+      expect(v!).toBeGreaterThanOrEqual(os.freemem() * 0.5);
+      // And the budget must ride it (the 2-worker strangulation regression:
+      // a mostly-idle Mac read ~1GB free and halved the resolver pool).
+      expect(memoryBudgetBytes()).toBeGreaterThanOrEqual(v! * 0.5);
+    }
+  );
+
+  it.runIf(process.platform !== 'darwin')(
+    'darwinMemoryAvailable is null off-macOS and never throws',
+    () => {
+      expect(darwinMemoryAvailable()).toBeNull();
+    }
+  );
 });

+ 49 - 4
src/resolution/memory-budget.ts

@@ -1,5 +1,6 @@
 /**
- * Memory headroom for worker-pool sizing — cgroup-honest on Linux.
+ * Memory headroom for worker-pool sizing — cgroup-honest on Linux,
+ * reclaim-honest on macOS.
  *
  * `os.freemem()` reads /proc/meminfo, which inside a container reports the
  * HOST's (or VM's) memory, not the cgroup's — the same blindness os.cpus()
@@ -7,8 +8,19 @@
  * kernel-scale index in a 7GB-capped container (migration plan §7a.1:
  * oom_kill=5, six ~1GB workers at true 8-core concurrency), so pool sizing
  * combines a CPU term with the memory headroom this module reports.
+ *
+ * On macOS `os.freemem()` has the OPPOSITE failure: it counts only
+ * `free_count` pages, and macOS deliberately keeps RAM full of reclaimable
+ * cache — a mostly-idle 64GB machine reads ~1GB "free", so the memory term
+ * capped the resolver pool at 2 workers where the CPU term allowed 6
+ * (measured on the dubbo warm-wall bench: resolution settle 3.0s at 2
+ * workers vs 1.9s at 6, ~1.5–2s of init wall). `darwinMemoryAvailable`
+ * reports what Activity Monitor calls available — free + inactive +
+ * speculative + purgeable pages — the same reclaimable-inclusive convention
+ * the Linux branch uses by crediting `inactive_file` back.
  */
 
+import { execFileSync } from 'child_process';
 import * as fs from 'fs';
 import * as os from 'os';
 
@@ -66,13 +78,46 @@ export function cgroupMemoryAvailable(): number | null {
   return null;
 }
 
+/**
+ * Reclaimable-inclusive available memory on macOS, or null elsewhere / on
+ * any parse failure (→ callers fall back to `os.freemem()`). Reads
+ * `/usr/bin/vm_stat` — the stable public interface over host_statistics64 —
+ * once per call (pool sizing runs it once per init; a few ms). Never throws.
+ */
+export function darwinMemoryAvailable(): number | null {
+  if (process.platform !== 'darwin') return null;
+  try {
+    const out = execFileSync('/usr/bin/vm_stat', { encoding: 'utf8', timeout: 2000 });
+    const pageMatch = /page size of (\d+) bytes/.exec(out);
+    const pageSize = pageMatch ? Number.parseInt(pageMatch[1]!, 10) : 16384;
+    const count = (label: string): number => {
+      const m = new RegExp(`^${label}:\\s+(\\d+)`, 'm').exec(out);
+      return m ? Number.parseInt(m[1]!, 10) : 0;
+    };
+    const pages =
+      count('Pages free') +
+      count('Pages inactive') +
+      count('Pages speculative') +
+      count('Pages purgeable');
+    const bytes = pages * pageSize;
+    return bytes > 0 && Number.isFinite(bytes) ? bytes : null;
+  } catch {
+    return null;
+  }
+}
+
 /**
  * The budget pool sizing divides: the smaller of system free memory and the
- * cgroup headroom (when contained). Conservative by construction — both
- * numbers shrink as the process itself grows.
+ * cgroup headroom (when contained), with the macOS reclaimable-inclusive
+ * reading replacing the too-small darwin `freemem`. Conservative by
+ * construction — every number shrinks as the process itself grows.
  */
 export function memoryBudgetBytes(): number {
   const free = os.freemem();
   const cgroup = cgroupMemoryAvailable();
-  return cgroup === null ? free : Math.min(free, cgroup);
+  if (cgroup !== null) return Math.min(free, cgroup);
+  // darwinAvailable ⊇ free by construction (the sum includes free pages);
+  // max() guards a hypothetical undercounting parse.
+  const darwin = darwinMemoryAvailable();
+  return darwin === null ? free : Math.max(free, darwin);
 }