Skip to content

perf(build): build very large repos end to end; opt-in parallel parsing with graft build --workers - #548

Draft
csm-kb wants to merge 8 commits into
trailhq:mainfrom
csm-kb:perf/large-repo-build
Draft

csm-kb wants to merge 8 commits into
trailhq:mainfrom
csm-kb:perf/large-repo-build

Conversation

@csm-kb

@csm-kb csm-kb commented Oct 4, 2026 •

Copy link
Copy Markdown

this is the follow-up to #340 (branch is based on that one's commits): the last 4 commits here are this PR -> can rebase once #340 lands

with #340, the 65k-file C repo no longer aborts in the WASM heap -- but the build still can't finish correctly. this PR gets it through end-to-end, and adds an opt-in'able way to parse using multiple parallel workers


Problem

After #340, a cold build of the ~65k-file C repo hit two more limits:

  • JS heap: buildGraph held every file's source text for the whole build (about 1 GB of strings here) for a meaning pass that a Tier-1 build never runs. With Node's default heap the build died with JavaScript heap out of memory at file 38,988.
  • V8's string cap (512 MB): each artifact was one JSON.stringify and one JSON.parse. On this repo the extract cache is 1.87 GB, wiring.json 704 MB and the ask index 586 MB. writeExtractCache swallowed the Invalid string length (so every later build ran cold), writeGraph threw after the full parse, and a graph that did get written couldn't be loaded by ask/check.

Parsing is also single-threaded, at about 3.3 ms per C file.

Solution

  • b5469ed Stream the artifacts. The extract cache is now NDJSON (extract.<stamp>.ndjson, cache v2). wiring.json and the ask index are written one element per line; they are still plain JSON, so every JSON.parse reader works unchanged, and above the cap they're read back line by line. wiring.json is now compact rather than indented. Old .json caches age out through the existing sidecar pruner.
  • 516059f Read sources lazily. The meaning pass re-reads a file when it summarizes it, and only if the bytes still hash to what was parsed; otherwise the node stays pending for the next build. The per-file read/hash/extract step moves into extractOne, so the in-thread loop and a worker run the same code.
  • 69525d2 graft build --workers <n|auto> (or GRAFT_PARSE_WORKERS). Parses in child_process.fork children and folds results in file order, so wiring.json is byte-identical to an in-thread build. Off by default and read only by the build command: the pre-query refresh, the MCP server and the App never fork. auto = min(cores - 1, free memory / 300 MB, files / 200), and stays in-thread below 200 files or 2 workers.
  • b1d2ff8 Harden it.
    • Pool: guarded sends, warm-up and per-job timeouts. A job whose child died is retried once. A child whose WASM runtime aborted is stopped and replaced, so one abort costs one file. If children keep dying, the parent parses the rest in-thread. It skips a file that already killed a child, unless no child ever succeeded, which points at the environment rather than the file.
    • Limits: an explicit --workers is validated and capped at the core and file counts. The background sync passes --workers 0, so a GRAFT_PARSE_WORKERS in .env can't make it fork.
    • Readers: they read in chunks from a file descriptor, so they also work above Node's 2 GiB readFileSync limit.
    • Docs: the heap note in the README and .env.example, plus the changelog.

Measurements

Windows 10, Node v22.23.2, 65,159-file C repo (1,226,623 nodes, 1,261,404 edges), cold build (extract and fingerprint caches deleted), NODE_OPTIONS=--max-old-space-size=16384. Peak RSS is summed across the parent and its children.

wall peak RSS
#340 only, default heap OOM at file 38,988 (~4 min) ~4.3 GB
this PR, in-thread 496 s 9.4 GB
this PR, --workers auto (15 children) 228 s 9.2 GB
no-change rebuild 185–206 s 7.9–8.2 GB

Both cold builds parse all 65,159 files with no errors. wiring.json from the in-thread and pooled builds is byte-identical, and graft ask and graft check both load the graph.

Known limits / follow-ups

  • A repo this size still needs a larger V8 heap than the default, because every node's body_text (up to 5,000 chars, needed by the ask index) stays resident until the index is written. Tokenizing it during the fold would remove that requirement.
  • A no-change rebuild still takes about 3 minutes here: build re-hashes every file and rewrites the artifacts even when nothing changed. A no-drift fast path would help.
  • graft viz still reads wiring.json as one string, so it's limited to graphs under 512 MB.
  • A file that takes over the 120 s per-file timeout in a worker is recorded as an error, where the in-thread build would have waited for it.
  • --workers isn't wired into workspace builds yet; a workspace build says it's ignoring it.

csm-kb added 8 commits October 4, 2026 11:15
…ed runtime

extractGeneric and parseWasm created a Parser per file and never deleted it
or the Tree. Both live in the WASM heap (2 GB cap, no finalizer), so a
65k-file C repo aborted after ~12k files and every later parse failed with
Aborted(). Reuse one parser per grammar and delete every tree. After a real
abort, fail every later parse with one message instead of one per file.
parseWasm now returns the tree for the container tier to free, and rethrows
instead of caching a symbol-less file as a success.
A cached error was replayed until the file's bytes changed, so one abort
pinned 52k files as failed. Skip the cache hit for an errored entry. The
fingerprint keeps its hash, so a refresh does not rebuild just to retry it.
A C++ header misparsed by the C grammar can nest one level per initializer element; the recursive walks overflowed the call stack.
Each artifact was one JSON.stringify and one JSON.parse; V8 caps a string
at 512 MB, and a 65k-file C repo produces a 1.9 GB extract cache, a 700 MB
wiring.json and a 590 MB ask index. The extract cache is now NDJSON read
one line at a time from a Buffer. wiring.json and the ask index are written
one element per line (still plain JSON for every reader) and read back line
by line when they are over the cap.
buildGraph kept every file's source text for the whole build, about 1 GB
of strings on a 65k-file repo, for a pass a Tier-1 build never runs. The
meaning pass now reads a file when it summarizes it, and only if its bytes
still hash to what was parsed. The per-file read/hash/extract step moves
into extractOne so the in-thread loop and a parser child run the same code.
Large cold builds can parse in child processes; results are folded in file
order so the graph is byte-identical to a single-process build. Off by
default and read only by the build command, so the pre-query refresh, the
MCP server and the App never fork. A dead child's job is retried once; a
job that kills two children is parked and becomes an error unless the pool
never succeeded at all, in which case the parent parses the rest itself.
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

🌱 graft blast radius

5 areas changed → 13 areas can be affected. 46 dependent symbols, depth 2.
Tests: no test reaches CLI Engine Integration; 4 areas updated their tests.
Tag: @anirudhkumar-nanonets — 16 of 18 areas · @shhdwi — 11 of 18 areas · @Frankie-Xu — Context Building, Brain Rules Refresh

flowchart TB
  A0(("Build And Review Pipeline<br/>13 symbols"))
  A1(("Graph Loading And Traversal<br/>10 symbols"))
  A2(("Build Progress UI<br/>7 symbols"))
  A3(("Build Scripts<br/>2 symbols"))
  A4(("MCP Tool Invocation<br/>2 symbols"))
  AX(("8 smaller areas<br/>12 symbols"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3,A4 reached;
  classDef tail fill:#EEF2F3,stroke:#9AA4A9,stroke-width:1px,color:#3A4247;
  class AX tail;
Loading
Can be affected Symbols Nearest hop Reached from
Build And Review Pipeline 13 src/app/brain-build-process.ts:L105-L110 settle — calls, depth 1 Ask Index Persistence, Dependency Graph Building +1
Graph Loading And Traversal 10 src/graph/check.ts:L58-L164 checkGraph — calls, depth 1 Ask Index Persistence, CLI Engine Integration +1
Build Progress UI 7 src/claude/hooks.ts:L188-L194 handlePostEdit — calls, depth 1 readWiring, Dependency Graph Building
Build Scripts 2 scripts/build-viewer.mjs:L1-L45 build-viewer.mjs — calls, depth 2 Dependency Graph Building
MCP Tool Invocation 2 src/mcp/tools.ts:L216-L244 callTool — calls, depth 2 Dependency Graph Building
Ask Corpus Loading 2 src/ask/ask.ts:L162-L187 loadCorpus — calls, depth 2 Ask Index Persistence, Dependency Graph Building
7 smaller areas 10 Context Building, Brain Rules Refresh, Background Push And Auth, … see below
Who knows this code — 4 people across 18 areas
Area Who knows it
Dependency Graph Building · changed @anirudhkumar-nanonets — 23 commits, last 2mo ago · @shhdwi — 11 commits, last 2mo ago
Ask Index Persistence · changed @anirudhkumar-nanonets — 3 commits, last 2mo ago · @shhdwi — 2 commits, last 3mo ago
CLI Engine Integration · changed @anirudhkumar-nanonets — 54 commits, last 5d ago · @shhdwi — 24 commits, last 2mo ago
readWiring · changed @shhdwi — 4 commits, last 3mo ago · @anirudhkumar-nanonets — 2 commits, last 2mo ago
Build And Review Pipeline · affected @anirudhkumar-nanonets — 15 commits, last 6d ago
Graph Loading And Traversal · affected @anirudhkumar-nanonets — 17 commits, last 1mo ago · @shhdwi — 14 commits, last 2mo ago
Build Progress UI · affected @shhdwi — 13 commits, last 2mo ago · @anirudhkumar-nanonets — 11 commits, last 5d ago
Build Scripts · affected @shhdwi — 3 commits, last 2mo ago · @anirudhkumar-nanonets — 1 commit, last 1mo ago
…10 further areas

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 46 dependent symbols, grouped by area

Build And Review Pipeline — 13 symbols in 8 files

  • src/app/brain-build-process.ts:L105-L110 — settle (calls, depth 1)
    105: const settle = (fn: () => void): void => {
  • src/app/brain-build.ts:L251-L358 — readRepository (calls, depth 1)
    254: const tag = `${job.owner}/${job.repo}`;
  • src/app/history.ts:L164-L170 — worker (calls, depth 1)
    166: const i = next++;
  • src/app/review-process.ts:L206-L211 — settle (calls, depth 1)
    206: const settle = (fn: () => void): void => {
  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 1)
    51: const checkout = checkoutPullRequest({ owner: job.owner, repo: job.repo, number: job.number, baseRef: job.baseRef, token, log });
  • src/app/brain-build-process.ts:L88-L148 — runInChild (calls, depth 2)
    96: const tag = `${job.owner}/${job.repo}`;
  • src/app/brain-build-worker.ts:L1-L83 — brain-build-worker.ts (calls, depth 2)
    9: * Everything expensive happens here — the clone, the tree-sitter parse, the
  • src/app/brain-build-worker.ts:L29-L32 — DoneMessage (references, depth 2)
  • src/app/brain-build.ts:L237-L239 — buildRepoIntoBrain (calls, depth 2)
  • src/app/review-process.ts:L179-L183 — childReviewer (references, depth 2)
  • src/app/review-process.ts:L185-L265 — runInChild (calls, depth 2)
    206: const settle = (fn: () => void): void => {
  • src/app/review-worker.ts:L67-L87 — run (calls, depth 2)
    77: send({ t: "publish", seq: id, html });
  • src/app/server.ts:L34-L46 — AppSeams (references, depth 2)

Graph Loading And Traversal — 10 symbols in 6 files

  • src/graph/check.ts:L58-L164 — checkGraph (calls, depth 1)
    77: const committed = readGraph(wiringPath(outDir));
  • src/graph/load.ts:L95-L100 — loadAskIndexCached (calls, depth 1)
    97: return loadCached(askIndexCache, path, () => readAskIndex(outDir), () => {
  • src/graph/load.ts:L86-L91 — loadGraphCached (calls, depth 1)
    88: return loadCached(graphCache, path, () => readGraph(path), () => {
  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 1)
    157: // One case has a graph to work with even though this checkout has none: a git
  • src/graph/workspace-cli.ts:L49-L70 — buildChild (calls, depth 1)
    50: // Persisted BEFORE the child build itself runs, same as the single-repo
  • src/graph/refresh.ts:L235-L261 — ensureFreshChildren (calls, depth 2)
    249: // clobbering the rest.) A child's graph always lives in its own `<child>/graft`,
  • src/graph/traverse-cli.ts:L166-L242 — runCallersCommand (calls, depth 2)
    169: const graph = loadGraphCached(contextDir);
  • src/graph/workspace.ts:L709-L722 — splitWorkspace (calls, depth 2)
    719: clearParentGraft(root, override); // drop the mega-graph/.cache/cards…
  • src/graph/workspace.ts:L630-L655 — federateCheck (calls, depth 2)
    635: const lines = [`workspace check — ${wg.loaded.length + wg.missing.length} repo(s)`, ""];
  • src/graph/workspace.ts:L145-L156 — loadWorkspaceGraphs (calls, depth 2)
    151: const graph = loadGraphCached(contextDirFor(join(root, child)));

Build Progress UI — 7 symbols in 3 files

  • src/claude/hooks.ts:L188-L194 — handlePostEdit (calls, depth 1)
    192: const w = readWiring(dir);
  • src/claude/hooks.ts:L211-L236 — lastFileScopeHint (calls, depth 1)
    214: const w = readWiring(dir);
  • src/claude/statusline.ts:L16-L22 — resolveStats (calls, depth 1)
    19: const wiring = readWiring(dir);
  • src/claude/build-progress.ts:L45-L83 — buildGraphWithProgress (calls, depth 2)
    48: ): Promise<{ built: boolean; graph?: BuiltGraph; failed?: boolean; messages: string[] }> {
  • src/claude/hooks.ts:L461-L535 — main (calls, depth 2)
    468: // a repo with a trail — see trailAtSessionStart.
  • src/claude/hooks.ts:L380-L396 — handleStop (calls, depth 2)
    383: // sync-run.js ships next to this module inside the package, so it resolves in
  • src/claude/statusline.ts:L24-L35 — main (calls, depth 2)
    26: try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { /* no/invalid stdin */ }

Build Scripts — 2 symbols in 2 files

  • scripts/build-viewer.mjs:L1-L45 — build-viewer.mjs (calls, depth 2)
    7: import { mkdirSync, copyFileSync, readdirSync } from "node:fs";
  • scripts/postinstall.mjs:L22-L38 — recordInstall (calls, depth 2)
    26: const cli = join(root, 'dist', 'cli.js');

MCP Tool Invocation — 2 symbols in 1 file

  • src/mcp/tools.ts:L216-L244 — callTool (calls, depth 2)
  • src/mcp/tools.ts:L247-L329 — callSingleTool (calls, depth 2)
    281: // file at depth>1 it walks the file node AND every symbol defined in

Ask Corpus Loading — 2 symbols in 1 file

  • src/ask/ask.ts:L162-L187 — loadCorpus (calls, depth 2)
    177: slug: String(fm.slug ?? entry.replace(/\.md$/, "")),
  • src/ask/ask.ts:L1438-L1468 — skeleton (calls, depth 2)
    1440: const graph = loadGraphCached(outDir);

Context Building — 2 symbols in 1 file

  • src/context/build.ts:L452-L467 — mapWithConcurrency (calls, depth 1)
    455: fn: (item: T, index: number) => Promise<R>,
  • src/context/build.ts:L146-L374 — buildContext (calls, depth 2)
    150: // Read the same persisted walk choices as the Tier-1 wiring graph, so the

Brain Rules Refresh — 2 symbols in 1 file

  • src/upkeep.ts:L164-L180 — maybeRefreshBrainRules (calls, depth 2)
    164: export function maybeRefreshBrainRules(repo: string, now = Date.now()): boolean {
  • src/upkeep.ts:L130-L145 — maybeRefreshInBackground (calls, depth 2)

Background Push And Auth — 2 symbols in 2 files

  • src/brain/autopush.ts:L206-L232 — startBackgroundPush (calls, depth 2)
    206: export function startBackgroundPush(repo: string): boolean {
  • src/brain/signup.ts:L321-L329 — openBrowser (calls, depth 2)

LSP Client Connection — 1 symbol in 1 file

  • src/graph/lsp/client.ts:L38-L75 — constructor (calls, depth 2)
    54: // Swallow write-after-destroy (EPIPE / ERR_STREAM_DESTROYED) when the server

Telemetry Flushing — 1 symbol in 1 file

  • src/telemetry/flush.ts:L37-L53 — maybeFlushInBackground (calls, depth 2)

Blast Command Execution — 1 symbol in 1 file

  • src/blast/blast-cli.ts:L63-L124 — runBlastCommand (calls, depth 2)
    69: const graph = loadGraphCached(contextDir);

Grep Search CLI — 1 symbol in 1 file

  • src/search/grep-cli.ts:L87-L119 — runGrepCommand (calls, depth 2)
    90: const graph = loadGraphCached(contextDir);
Test signal per changed area — 4 ✓ · 1 ✗

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ Dependency Graph Building — 20 of 47 reached · 11 test files changed here: test/claude-stats.test.ts, test/container-extract.test.ts, test/generic-extract.test.ts, test/graph-artifacts-stream.test.ts, test/graph-build-workers-cli.test.ts, test/graph-enrich-failures.test.ts, test/graph-incremental.test.ts, test/graph-load.test.ts, test/graph-parse-pool.test.ts, test/graph-refresh.test.ts, test/graph-seed.test.ts
    • not reached: applyResult, labelOf, cachedHashOf, pruneSidecars, notePoison, poisonedError, mkDef, forEachNamedNode, …19 more
  • ✓ Ask Index Persistence — 3 of 5 reached · 3 test files changed here: test/graph-artifacts-stream.test.ts, test/graph-incremental.test.ts, test/graph-load.test.ts
    • not reached: validateAskIndex, readAskIndexFromLines
  • ✗ CLI Engine Integration — 0 of 1 reached · no test file reaches it
    • not reached: graph
  • ✓ readWiring — 1 of 2 reached · 1 test file changed here: test/claude-stats.test.ts
    • not reached: realBuild
  • ✓ JSON Stream Writing — 1 of 7 reached · 1 test file changed here: test/json-stream.test.ts
    • not reached: decodeLine, emit, abort, cleanup, commit, write
41 test suites also reference this code

60 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/claude-statusline.test.ts
  • test/cli-deep-failure.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-node24-probe.ts
  • test/graph-enrich-checkpoint.test.ts
  • test/graph-enrich-pending.test.ts
  • test/graph-enrich-quality.test.ts
  • test/graph-follow-nested-repos.test.ts
  • test/graph-follow-submodules.test.ts
  • test/graph-go.test.ts
  • test/graph-include-dir.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-languages.test.ts
  • test/graph-only-dir.test.ts
  • test/graph-php.test.ts
  • …21 more

⚠️ 3 changed files not in the graph (.env.example, CHANGELOG.md, README.md) — no parser claims the extension, or the index predates the file.

graft blast · origin/main...HEAD · depth 2 · 33 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

This branch has not been deployed

No deployments
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