perf(kernel): indexing a Kotlin project uses less memory
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.
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
Generationsstructure 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
Regexclone shares the compiled program but has its own search-cache pool, so threads still search without contending on one pool, as thethread_regexpattern 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.
| Corpus | Measure | Before (range) | After (range) | Benefit | Floor |
|---|---|---|---|---|---|
| Exposed (Kotlin) | Peak memory | 2,409 MiB (2,400-2,439) | 1,939 MiB (1,908-1,943) | 19.5% lower | 67 MiB |
| Exposed | Wall time | 9.63 s (9.48-9.69) | 9.06 s (8.98-9.10) | 5.9% faster | 0.53 s |
| Exposed | CPU time | 35.72 s (35.34-36.07) | 33.37 s (33.09-34.20) | 6.6% less | 1.57 s |
| ktor (Kotlin) | Peak memory | 2,492 MiB (2,467-2,546) | 1,992 MiB (1,906-1,999) | 20.1% lower | 93 MiB |
| ktor | Wall time | 13.39 s (13.17-13.53) | 13.06 s (12.86-13.08) | no clear change (-2.5%) | 0.35 s |
| ktor | CPU time | 51.91 s (50.97-51.95) | 48.03 s (47.91-48.32) | 7.5% less | 0.55 s |
| Alamofire (Swift) | Peak memory | 1,034 MiB (1,019-1,036) | 956 MiB (950-965) | 7.6% lower | 19 MiB |
| Alamofire | Wall / CPU | 1.43 s / 4.91 s | 1.41 s / 4.90 s | no clear change (-1.4% / -0.2%) | 0.08 s / 0.16 s |
| pretix (Python + JS) | Peak memory | 3,394 MiB (3,224-3,408) | 3,200 MiB (3,193-3,247) | no clear change (-5.7%) | 60 MiB |
| pretix | Wall / CPU | 9.58 s / 33.01 s | 9.65 s / 33.38 s | no clear change (+0.7% / +1.1%) | 0.66 s / 1.65 s |
| CPython (C + Python) | Peak memory | 4,278 MiB (4,199-4,349) | 4,214 MiB (4,156-4,247) | no clear change (-1.5%) | 99 MiB |
| CPython | Wall / CPU | 34.65 s / 110.04 s | 34.29 s / 109.44 s | no 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:
| Corpus | Measure | #515 alone | #515 + this change | Benefit |
|---|---|---|---|---|
| Exposed | Peak memory | 2,599 MiB | 2,005 MiB | 22.9% lower |
| Exposed | Wall / CPU | 7.63 s / 28.60 s | 7.63 s / 28.30 s | no clear change |
| ktor | Peak memory | 2,717 MiB | 2,019 MiB | 25.7% lower |
| ktor | Wall / CPU | 10.91 s / 39.96 s | 10.51 s / 38.26 s | 3.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 newcached_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,.dumpdiff 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.tson the final head57330eec: 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
\bis 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.