Skip to content

perf: walker boundaries, batched worker dispatch, hot-path memos (+ deadcode fix) - #16

Merged
maxgfr merged 5 commits into
mainfrom
perf-walker-and-hot-paths
Sep 2, 2026
Merged

maxgfr merged 5 commits into
mainfrom
perf-walker-and-hot-paths

Conversation

@maxgfr

@maxgfr maxgfr commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Performance pass over the indexing pipeline plus two correctness fixes, with every artifact kept byte-identical (EXTRACTOR_VERSION untouched).

  1. Walker (fix(walk)): stop at nested repositories (a subdirectory with its own .git, i.e. linked worktrees, vendored clones, submodules), honor .git/info/exclude (following gitfiles and a worktree's commondir), and never walk .git even when --ignore-dir replaces the default list. On a real repo with four worktrees under .claude/worktrees/ the index went from 14 661 phantom-laden files to the 2 924 git actually tracks.
  2. Dead code (fix(deadcode)): a called export whose name is also exported by another file was reported dead, because the bare caller-index key was consulted before the qualified name@file key.
  3. Extraction (perf(extract)): one-pass blankComments (was split("")/join("") per byte), node-type class memo in the AST visitor, O(log n) lineAt for barrels.
  4. Pool (perf(pool)): batches dispatched on demand with two in flight per worker instead of one static shard each (robust to heterogeneous P/E cores and load); --workers / CODEINDEX_WORKERS now apply to read commands with a persisted index; betweenness scratch allocated once and reset per visited node only.
  5. Serial phase (perf(graph)): JS resolution memo per (dir, spec), Go/Java per-directory lists, once-per-file holder filter in literals, docs tokenized once and shared by graph and symbols.json, adjacency memo in impactOf/neighborsOf, Set idents plus one compiled regex in findReferences.

Measurements

codeindex index on code-du-travail-numerique (Apple M5, 4P+6E, Node 24, min of 3):

run files cold warm cold --workers 1 cold --workers 4 cache.json
main (default walker) 14 661 3.87 s 0.71 s 8.17 s 3.83 s 68 MB
main with .claude excluded by hand 2 922 0.83 s 0.16 s 1.62 s 0.86 s 13 MB
this branch (default) 2 924 0.74 s 0.16 s 1.44 s 0.78 s 13 MB

In-process phase timings on the same 2 924 files (8 workers): parallel extraction 388 → ~335 ms, buildArtifactsFromScan 197 → ~157 ms cold (145 → 122 ms steady state).

Verification

  • pnpm typecheck, pnpm test (1 280 passed, 50 skipped), pnpm run check:build clean.
  • graph.json / symbols.json byte-identical before/after steps 2–5 on tests/fixtures/mini-repo and on the real repo, and identical at --workers 1 vs 8.
  • blankComments old vs new: identical on 3 240 files plus edge cases. impactOf / neighborsOf / findReferences / findLiteralDuplications / findDeadCode old bundle vs new: 16 470 answers, 0 mismatches.
  • Walker output equals git ls-files minus the walker's own binary/font/lockfile filters (the 6 remaining differences are NFC/NFD spellings of the same paths).
  • New tests: tests/walk-nested.test.ts (nested repo boundary, info/exclude incl. negation and worktree commondir, .git under --ignore-dir, excluded count) and a homonym regression in tests/phase2.test.ts.

Not done from the plan, on measurement

  • Skipping the main thread's grammar load when the pool runs (7–10 ms here) and overlapping worker boot with the walk (boot is ~35 ms): not worth the extra tier-identity invariants.
  • relations.ts typeDefs/resolveRelations recompute (under 2 ms) and callers.ts enclosingAmong (not on the index path).
  • The remaining parallel cost is per-worker JIT warm-up (the first ~40 files of each worker run at ~2 ms/file against 0.5 ms steady state), which no dispatch strategy removes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8

The walker descended into subdirectories carrying their own `.git`
(linked worktrees under .claude/worktrees/, vendored clones, submodules),
so a repo with four worktrees indexed five copies of itself: 14 661
files instead of 2 924 on a real project, 4.0 s instead of ~0.9 s cold,
a 68 MB cache instead of 13 MB. It also ignored `.git/info/exclude`, and
`--ignore-dir` — which REPLACES the default list — pulled `.git/objects`
into the index whenever the caller forgot to list `.git`.

- A non-root directory whose listing contains `.git` (dir or gitfile) is
  skipped on the dirents already read, zero extra syscalls, and counted
  once in `excluded`. Structural: independent of `gitignore: false`.
- `.git/info/exclude` is parsed ahead of the .gitignore chain (git's own
  precedence), following a gitfile and a worktree's `commondir`.
- `.git` is ignored whatever `ignoreDirs` says, and a root gitfile is no
  longer indexed as a source file.

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
When several files export the same name, the caller index keeps the
first-sorted definition under the bare name and the others under
"name@file". findDeadCode consulted the bare key first, got the other
file's entry, failed its own file check and reported a symbol that IS
called as dead. Query "name@file" first, then fall back to the bare key.

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
…log n) lineAt

Single-threaded profile on a 2 900-file repo: blankComments was 5 % of
extraction — `split("")`/`join("")` allocated one string per byte of
every JS/TS file. Rewritten as one charCodeAt pass that copies kept
stretches as slices and comments as space runs (newlines inside block
comments kept, so offsets and lines survive). Verified byte-identical to
the previous implementation on 3 240 files plus edge cases.

collectAll's visitor ran five end-anchored regexes on every node's type;
a grammar has a few hundred types, so the verdicts are now memoized per
type as a bit set, and `startPosition` (a wasm marshal) is read only in
the branch that needs it.

extractReexports' lineAt re-split the whole prefix per re-exported
name; line starts are now computed once per file and binary-searched.

graph.json/symbols.json are byte-identical before/after on the fixture
repo and on a real 2 900-file repo; EXTRACTOR_VERSION untouched.

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
…ommands

Workers used to receive one static shard each. On a heterogeneous CPU
(Apple's 4 performance + 6 efficiency cores) or a loaded machine the
slowest shard sets the wall-clock: measured on a 2 358-file repo, eight
equal shards ran at ~1 ms/file where one worker alone managed 0.45, and
eight workers barely beat four. The main thread now hands out batches on
demand from the path-sorted queue — sized remaining/(workers×4), never
below 4 — with two batches in flight per worker so it never idles on the
main thread's turn to deserialize and answer. Faster cores simply take
more of the queue; the tail is one small batch. Records stay keyed by
path and scanRepo orders them, so which worker built one never shows.
Extraction wall-clock: ~355 → ~340 ms on an idle machine, larger under
load; the byte-identity gate (--workers 0 vs 4) is unchanged.

A worker's readiness message (an empty first batch) carries the grammar
set it got ready; a mismatch, an error payload or a per-batch timeout
still discards the whole run — and now terminates the other workers —
before the sequential fallback.

preloadSessionLazy scans through scanRepoParallel: with a persisted
index whose code files drifted, a read command re-extracted every
changed file on the main thread, so --workers / CODEINDEX_WORKERS meant
nothing as soon as .codeindex/ existed. An unchanged index still loads
no wasm and spawns nothing.

betweennessOf allocates its per-source scratch once (typed arrays) and
resets only the nodes the BFS visited, so a source costs its component
rather than the whole graph — an import graph is mostly small ones.
Same accumulation order, bit-identical scores.

Not done from the plan, on measurement: the main thread's own grammar
load costs 7–10 ms and worker boot ~35 ms on this machine, so skipping
the former and overlapping the latter with the walk were not worth the
extra tier-identity invariants.

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
Serial post-extraction work (buildArtifactsFromScan) on a 2 900-file
repo: ~145 → ~122 ms steady state, all outputs byte-identical (graph.json,
symbols.json, and 16 470 impactOf/neighborsOf/findReferences/literals/
deadcode answers compared against the previous bundle).

- resolve.ts: JS/TS resolutions memoized per (importing dir, spec) in the
  ResolveContext — resolveJs only ever looks at the importer's directory,
  and a package's files import the same specifiers over and over; each
  hit skips ~20 normalize+probe rounds. Go/Java "first file in the
  directory" lists are built once per (ext, dir) instead of filter+sort
  per import. Callers get a copy of the cached record.
- literals.ts: the holder filters that depend only on the symbol (kind,
  function-valued signature, span) run once per file rather than once
  per (literal × symbol); same order, same innermost-wins tie-break.
- derived.ts: docs are tokenized once per scan (docMentionsFor) and
  shared by buildGraph's mention pass and computeSymbolRefs; the memo
  records whether the text was retained docText or re-read from disk so
  each consumer keeps its previous eligibility rule. identSetsFor gives
  findReferences a Set per file instead of Array.includes per query, and
  its doc RegExp is compiled once per query, not once per doc.
- traverse.ts: dependents/adjacency maps (with pre-sorted neighbour
  lists) are built once per edge array and kept in a WeakMap, instead of
  rebuilt and re-sorted on every impactOf/neighborsOf — delta.ts calls
  impactOf once per changed file of every module.

Left as-is on measurement: relations.ts's typeDefs/resolveRelations
recompute (under 2 ms) and callers.ts's enclosingAmong (not on the index
path).

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
@maxgfr
maxgfr merged commit 39aa752 into main Sep 2, 2026
2 checks passed
@maxgfr
maxgfr deleted the perf-walker-and-hot-paths branch September 2, 2026 06:35
github-actions Bot pushed a commit that referenced this pull request Sep 2, 2026
## [2.28.2](v2.28.1...v2.28.2) (2026-09-02)

### Performance Improvements

* walker boundaries, batched worker dispatch, hot-path memos ([#16](#16)) ([39aa752](39aa752))
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.28.2 🎉

The release is available on:

Your semantic-release bot 📦🚀

maxgfr added a commit that referenced this pull request Sep 3, 2026
#17)

Two regressions introduced by #16, found by an independent audit and
reproduced against the published 2.28.1 bundle before being fixed.

- fix(walk): the repository boundary triggered on the NAME `.git`, so a
  file named `.git` holding anything else — a truncated write, an
  unrelated file, a dangling symlink — silently dropped its entire
  subtree from the index. A `.git` DIRECTORY is now a git dir, and a
  `.git` FILE is a marker only when it opens with exactly `gitdir: `,
  which is git's own rule (read_gitfile_gently compares the first 8
  bytes) — verified against real git for the no-space, leading-space and
  second-line spellings. Symlinks resolve through their target; the read
  is capped at 4 KiB. Not validating the TARGET stays a deliberate
  deviation, now documented as one: a stale gitfile from a pruned
  worktree still sits on a full checkout.

- fix(traverse): the adjacency cache keyed on the edge array's identity
  and length, so retargeting an edge IN PLACE kept answering with the
  pre-edit graph. A snapshot of the fields a traversal reads is now
  checked on every hit. Measuring that check changed the design:
  reverseClosure is back to its pre-PR uncached shape (200 impactOf
  calls: 69 ms pre-PR, 70 ms now), while bfs keeps the checked cache
  where it still pays (161 ms → 41 ms).

graph.json/symbols.json unchanged. 19 075 traversal answers match the
pre-PR bundle on a real repo, and another 19 075 match after mutating
every 7th edge in place — the case that used to go stale.

Claude-Session: https://claude.ai/code/session_01YDMVcJNo7WZh9xFAGWs2F8
github-actions Bot pushed a commit that referenced this pull request Sep 3, 2026
## [2.28.3](v2.28.2...v2.28.3) (2026-09-03)

### Bug Fixes

* validate the .git marker, and stop serving stale traversal caches ([#17](#17)) ([9ff08f6](9ff08f6)), closes [#16](#16)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant