perf(kernel): indexing a Kotlin project uses less memory

Merged
BO
2d ago
+71-18

Indexing a Kotlin project peaks about 20% lower in memory: JetBrains Exposed from a median 2,409 MiB to 1,939 MiB and ktor from 2,492 MiB to 1,992 MiB (full index on Node 24, 3 alternating runs per build, identical graphs), and Exposed also indexes 5.9% faster.

Indexing a Kotlin project: peak memory about 20% lower. Full index, Node 24, median of 3 alternating runs per build, identical graphs. Exposed peak memory 2,409 MiB to 1,939 MiB, 20% lower; ktor peak memory 2,492 MiB to 1,992 MiB, 20% lower; Alamofire peak memory 1,034 MiB to 956 MiB, 7.6% lower; Exposed index time 9.63 s to 9.06 s, 5.9% lower. No clear change means within 5.5% of the baseline.

The chart's "no clear change" line is 5.5% of the baseline, the largest spread between two identical builds of this change on any row shown; each row is also judged against its own corpus's spread in the table below.

What changed

The resolver compiles a regex per name for checks that need one, such as the Kotlin \b(?:val|var)\s+NAME\s*:\s*([A-Z][\w.]*) or the call-shape pattern (?:^|[^\w])(NAME)\s*\(. cached_regex kept those in a thread-local cache of up to 512 compiled patterns (#383), so each of the six resolver worker threads compiled and held its own copy of the same hot patterns. A pattern with a capture group or a Unicode \b also gets a one-pass DFA from the regex engine, so one compiled name pattern holds 5 to 800 KB before any search. Instrumented runs put the compiled patterns at 78-140 MiB per thread on Exposed and 79-206 MiB on ktor, at about 13,800 and 23,000 compiles per run.

Now (codegraph-kernel/src/resolve/mod.rs):

  • All resolver threads share one cache of compiled patterns (the existing Generations structure with 512 entries per generation, so the 512 to 1,024 most recently used), so a pattern is compiled once for all of them.
  • Each thread keeps a small cache (16 per generation, so 16 to 32 entries) of its own clones of the shared regexes. A Regex clone shares the compiled program but has its own search-cache pool, so threads still search without contending on one pool, as the thread_regex pattern intends.
  • Compilation happens outside the lock. Threads that miss the same pattern at once may each compile it; the last insert stays.

Compiles fall to about 7,000 on Exposed and 14,000 on ktor, which is where the CPU saving comes from. Smaller per-thread caches without sharing (128 or 64 entries) saved less memory and cost more compiles and CPU, so they were dropped.

Results

Full index (codegraph init), before = fork/consolidated d161ef74, after = this change. Medians of 3 runs, alternating builds within each round, with a third arm that is an identical build of this change. A change counts when the medians differ by more than that corpus's floor (largest minus smallest of the six runs of the two identical builds) and the ranges do not overlap; otherwise "no clear change". All runs: Node 24.22.0, systemd-run --user --scope -p MemoryMax=16G, admitted only on a quiet host, rejected when other processes used more than 2 cores.

CorpusMeasureBefore (range)After (range)BenefitFloor
Exposed (Kotlin)Peak memory2,409 MiB (2,400-2,439)1,939 MiB (1,908-1,943)19.5% lower67 MiB
ExposedWall time9.63 s (9.48-9.69)9.06 s (8.98-9.10)5.9% faster0.53 s
ExposedCPU time35.72 s (35.34-36.07)33.37 s (33.09-34.20)6.6% less1.57 s
ktor (Kotlin)Peak memory2,492 MiB (2,467-2,546)1,992 MiB (1,906-1,999)20.1% lower93 MiB
ktorWall time13.39 s (13.17-13.53)13.06 s (12.86-13.08)no clear change (-2.5%)0.35 s
ktorCPU time51.91 s (50.97-51.95)48.03 s (47.91-48.32)7.5% less0.55 s
Alamofire (Swift)Peak memory1,034 MiB (1,019-1,036)956 MiB (950-965)7.6% lower19 MiB
AlamofireWall / CPU1.43 s / 4.91 s1.41 s / 4.90 sno clear change (-1.4% / -0.2%)0.08 s / 0.16 s
pretix (Python + JS)Peak memory3,394 MiB (3,224-3,408)3,200 MiB (3,193-3,247)no clear change (-5.7%)60 MiB
pretixWall / CPU9.58 s / 33.01 s9.65 s / 33.38 sno clear change (+0.7% / +1.1%)0.66 s / 1.65 s
CPython (C + Python)Peak memory4,278 MiB (4,199-4,349)4,214 MiB (4,156-4,247)no clear change (-1.5%)99 MiB
CPythonWall / CPU34.65 s / 110.04 s34.29 s / 109.44 sno clear change (-1.0% / -0.5%)1.46 s / 3.29 s

CPython is there because a large Python and C repository meets the most distinct patterns per worker, where a 16-entry per-thread cache could have cost time; it does not.

Graphs are identical: on every accepted run, the after build and its identical copy produce the same graph hash as the before build on the same corpus (Exposed b332758c901b, ktor 57e9c5136b5e, pretix bc3469fcdcfc, Alamofire 4206c9f55617, CPython 30138fa1cb5d). 69 runs accepted, 2 rejected and re-run (both before-build runs, where other processes used 2.2 cores).

Against upstream (e8ec7fd8, same runs): peak memory while indexing Exposed goes from 14.0% above upstream's to 8.2% below it, and ktor from 5.2% below to 24.2% below. Alamofire stays above upstream's (27.3% above before, 17.6% after). Index time on both Kotlin corpora stays well above upstream's (Exposed +55%, ktor +68%); #515 addresses that.

Once #515 lands (its head 7ef01159, which speeds up Kotlin resolution, alone and with this change applied on top, same three-round protocol, judged against the floors above). These numbers replace the ones above for Kotlin when #515 merges first:

CorpusMeasure#515 alone#515 + this changeBenefit
ExposedPeak memory2,599 MiB2,005 MiB22.9% lower
ExposedWall / CPU7.63 s / 28.60 s7.63 s / 28.30 sno clear change
ktorPeak memory2,717 MiB2,019 MiB25.7% lower
ktorWall / CPU10.91 s / 39.96 s10.51 s / 38.26 s3.7% / 4.3% faster

#515 on its own raises peak memory on these corpora (+7.9% on Exposed, +9.0% on ktor against the before build); with this change on top, peak memory is back to this change's level, and the kernel change cherry-picks onto #515 without conflict (the two CHANGELOG entries sit near each other and may need a hand merge).

Checks

Run against the code of 75acfc35 (the same mod.rs before the rebase onto #516, which changes only .coderabbit.yaml):

  • npm run build:kernel (clippy included): exit 0.
  • cargo test --release --lib -- resolve::tests: exit 0, 8 passed, including the new cached_regex_compiles_once_across_threads: three threads in turn look up one pattern and it is compiled once. With the shared lookup removed it fails (left: Some(3), right: Some(1)).
  • UPDATE_GOLDEN=1 npx vitest run __tests__/kernel-golden-dumps.test.ts: exit 0, 8 passed, .dump diff empty.
  • npm run build, npx tsc --noEmit, npm run check:agent-docs: exit 0.
  • npx vitest run: exit 0, 746 files, 9,262 passed, 40 skipped.
  • npx vitest run __tests__/readme-sync.test.ts on the final head 57330eec: exit 0, 6 passed.
  • Precision gate not run: no resolution change (golden dumps and graph hashes identical).

README rows checked

  • "About this fork" chart and summary paragraph, and the Full index table: a dated fork-vs-upstream run with no Kotlin corpus. Its Alamofire and pretix rows came from that run's own builds; pretix shows no clear change here and Alamofire's 7.6% would move only its memory figure. Not edited: updating them needs the full fork-vs-upstream benchmark re-run, left for the next scheduled one.
  • "What it costs": pretix's +14% peak memory against upstream (no clear change here) and Alamofire's one-file sync peak (sync not measured here). Unchanged.
  • Mid-size comparison paragraph (ktor 13.90 s vs 7.69 s): ktor wall time shows no clear change. Unchanged.
  • CHANGELOG: one entry under Unreleased → Fixes.

Not covered

  • Disabling the one-pass DFA, which would save more memory again: the regex crate has no option for it, and wrapping every name-pattern call site (about 90) to avoid capture groups and Unicode \b is a larger change.
  • No statistics for the shared cache. When a generation fills, up to 512 compiled patterns are dropped while the lock is held. Not measured separately; the timing results above include it.
  • node_table's process-wide cache of C++ declarator patterns uses a similar mechanism and was left as is.
  • Peak memory during a one-file sync, and Bun, were not measured.

Summary by cubic

Shares compiled name regexes across resolver worker threads, so indexing Kotlin projects peaks about 20% lower in memory with identical graph output. Each worker used to compile and hold its own copy of up to 512 name patterns (78–206 MiB per thread on Exposed and ktor); the threads now share one process-wide cache of compiled patterns, and each keeps only 16–32 of its own clones, which share the compiled program but keep a separate search state.

Results (full index on Node 24, median of 3 alternating runs per build)

  • Exposed: peak memory 2,409 → 1,939 MiB (−19.5%), wall time 9.63 → 9.06 s (−5.9%).
  • ktor: peak memory 2,492 → 1,992 MiB (−20.1%), CPU time −7.5%, wall time no clear change.
  • Alamofire: peak memory −7.6%; pretix and CPython: no clear change.
  • Pattern compiles drop from about 13,800 to 7,000 on Exposed and 23,000 to 14,000 on ktor.
  • Graph hashes are identical on every corpus.