Skip to content

perf(resolution): run the resolver workers the main thread can keep busy - #3183

Merged
colbymchenry merged 1 commit into
mainfrom
claude/spd2-resolver-pool
Nov 1, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/spd2-resolver-pool

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

The resolver pool started 6 workers on any large project, though the main thread, which saves every batch itself, keeps about 4 busy. The pool now takes at most 4, and fewer when a run projects little work. Batches queue behind each other past the prerequisites, and a file's chunks stay on one worker.

Stacked on the shared scans PR, which adds a line to createPool this one edits.

Root cause

rocksdb's CPU rose 31% from 1.6.2 with its wall up 8%. Per thread (process.threadCpuUsage in every thread, against process CPU): main +3 s, resolver workers +19 s, V8's background threads 12 to 25 s, mostly compiling. V8 compiles per isolate, so every worker pays for every hot function again, and there is more code than there was. Tier flags don't help: --no-maglev, --no-turbofan and --no-sparkplug gain nothing. Fewer isolates do.

The workers were also idle about 40% of the time, at the batch barrier: 10 chunks over the pool, and the next batch held until the slowest finished.

The fix

  • MAX_RESOLVE_WORKERS = 4, and resolveWorkersWorthStarting: a mid-size run that engages the pool from its own measured rate takes 2 to 4 by what it projects. CODEGRAPH_RESOLVE_WORKERS still overrides.
  • Past the prerequisites a batch makes no extends or implements edge, the only ones resolving reads, so the next batch queues behind it. Should one make such an edge, the batch queued ahead is dropped and starts again once the edges are in: results only take effect at settle, in order.
  • A file's chunks go to the worker that had its last chunk, unless that worker is further behind, for its cached reads.

Two boundaries needed the pool idle, and keep it: the connection recycle (no batch queues ahead of one), and the WAL valve's parked backfill, which needs the readers between statements (#1539). The valve now waits for a batch with nothing queued ahead, which the recycle guarantees every 8 batches, so the WAL can overshoot the hard cap by at most that much.

Validation

Windows 11. resolver-pool-sizing.test.ts pins the sizes. resolver-pool-pipeline.test.ts: a fake pool proves the loop queues ahead (2 batches running at once) and that both the valve and the recycle find it idle (red without the valve's check), and a real pool on a generated project makes the graph one thread makes.

Graphs. Full dumps of the whole stack against its base, byte-identical on 24 repos: rocksdb, redis, leveldb, fmt (C, C++), react, excalidraw, taxonomy, express (JS, TS), django, flask (Python), rails-src, sinatra (Ruby), framework, ci3 (PHP), vscode-be (TS), jellyfin, serilog (C#), jsoup, nowinandroid (Java, Kotlin), gin, caddy (Go), tokio, ripgrep (Rust), Alamofire (Swift).

Workers, 2 rounds, wall (best) / CPU / peak RSS. Machine noise is large, so read the best round and the CPU:

Repo 6 4 3
rocksdb 37.0 / 125.9 / 2780 35.8 / 107.3 / 2425 36.1 / 97.5 / 2229
react 29.0 / 113.4 / 2047 29.2 / 103.2 / 1798 30.3 / 91.5 / 1568
django 15.8 / 70.0 / 2268 15.2 / 60.0 / 1995 15.8 / 56.6 / 1707
es-nogit 32.5 / 105.4 / 3573 32.6 / 95.9 / 3074 34.0 / 98.9 / 2603
rails-src 18.0 / 67.4 / 1625 17.8 / 64.4 / 1388 18.1 / 60.4 / 1274
framework 13.2 / 44.2 / 1536 12.0 / 41.6 / 1326 14.4 / 39.3 / 1207
jellyfin 10.2 / 34.7 / 1258 9.1 / 32.3 / 1080 11.5 / 32.5 / 981
vscode-be 14.4 / 53.2 / 1718 12.2 / 47.3 / 1512 15.3 / 44.6 / 1353

4 costs no wall anywhere. 3 saves more CPU and starts to cost wall.

The whole stack, 1.6.2 / 4499d00 / this, medians of 3, interleaved. This side also carries the regression fixes that landed since 4499d00, which add work of their own: on rocksdb, against the main it's based on, 33.6 to 29.2 s, 131.1 to 107.8 s CPU, 2692 to 2485 MB in a quieter hour.

Repo Wall s CPU s Peak RSS MB
rocksdb 38.4 / 43.7 / 38.9 109.9 / 146.1 / 115.7 3040 / 2679 / 2396
redis 11.2 / 10.4 / 8.3 25.4 / 30.2 / 25.7 1333 / 1068 / 884
react 69.5 / 49.8 / 44.3 204.5 / 140.1 / 123.2 3012 / 2098 / 1749
excalidraw 12.3 / 8.6 / 8.2 30.3 / 28.8 / 23.5 1596 / 1129 / 830
tokio 10.8 / 7.5 / 7.2 24.4 / 23.0 / 18.4 1042 / 998 / 750
caddy 6.7 / 5.1 / 4.7 18.1 / 14.6 / 10.6 866 / 787 / 462
ripgrep 2.7 / 3.1 / 2.7 5.2 / 5.6 / 4.2 454 / 433 / 301

A sync after an edit:

Repo Wall s CPU s Peak RSS MB
react 11.6 / 12.6 / 10.8 11.3 / 12.8 / 10.6 471 / 403 / 310
rocksdb 10.3 / 11.1 / 9.0 10.4 / 9.5 / 9.6 539 / 528 / 394
redis 2.7 / 3.0 / 2.5 2.6 / 2.7 / 2.5 371 / 325 / 223

What's left on rocksdb: thread CPU is level with 1.6.2 and profile self time below it (93.5, 109.8, 88.1 s). V8's background threads are the difference, 13 to 23.5 s.

🤖 Generated with Claude Code

@colbymchenry
colbymchenry force-pushed the claude/spd2-resolver-pool branch 3 times, most recently from 498d133 to 4e5526f Compare November 1, 2026 09:37
The pool started 6 workers on any large project, though the main thread saves
every batch itself and keeps about 4 busy. A further worker mostly waited, having
cost a second or two of CPU to load and to compile the hot code in its own isolate,
a share of the others' cache hits, and a few hundred MB. That's a large part of why
rocksdb's CPU rose 31% for a wall rise of 8%: V8's background threads went from 12
to 25 s, mostly compiling, for more code in every isolate. The pool now takes at
most 4, and fewer when a run projects little work.

Workers 6 to 4, 2 rounds: rocksdb 125.9 to 107.3 s CPU, 2780 to 2425 MB, best wall
37.0 to 35.8 s; react 113.4 to 103.2; django 70.0 to 60.0; vscode-be 53.2 to 47.3,
wall 14.4 to 12.2. Dumps of the whole stack are byte-identical on 24 repos.

The rest of the waiting was the batch barrier: 10 chunks over a few workers, and
the next batch held until the slowest was done. Past the prerequisites no batch
makes an edge resolving reads, so the next one now queues behind it. A file's
chunks stay on one worker, which has its cached reads.

The WAL valve parks only at a pool-idle boundary, since a full backfill needs the
readers between statements, so it now waits for a batch with nothing queued ahead,
which the connection recycle guarantees every 8 batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant