Skip to content

perf: parallelize file-change detection and speed up watcher startup#277

Open
Slicit wants to merge 4 commits into
yoanbernabeu:mainfrom
Slicit:perf/parallel-rescan-and-watch-startup
Open

perf: parallelize file-change detection and speed up watcher startup#277
Slicit wants to merge 4 commits into
yoanbernabeu:mainfrom
Slicit:perf/parallel-rescan-and-watch-startup

Conversation

@Slicit

@Slicit Slicit commented Jul 13, 2026

Copy link
Copy Markdown

Description

Targets indexing/watch-restart speed on large repos (100k+ files, 5M+ LOC).

The problem: two phases here are fully sequential today.

  1. Indexer.IndexAllWithBatchProgress decides which files need (re)indexing before any embedding happens: for every scanned file it does a store lookup (GetDocument), an mtime fast-path check, and -- for files that don't pass it -- a full file read + SHA-256 hash. This runs one file at a time. It's exactly the phase that runs on every grepai watch restart (runInitialScan -> IndexAllWithBatchProgress), and after a git checkout/branch switch or CI clone where most files' mtimes look "new," it becomes a full sequential hash of the entire repo before anything else can happen.
  2. watcher.addRecursive uses filepath.Walk, which does an extra Lstat syscall per file/directory on top of the directory read -- unnecessary work that roughly doubles the syscall count of the tree walk grepai watch does at startup, before it can start serving file-change events.

What changed:

  • indexer/indexer.go: the per-file "does this need (re)indexing" decision (decideFileScan) now runs concurrently across a bounded worker pool (golang.org/x/sync/errgroup, matching the pattern already used in embedder/openai.go), since each file's decision is independent of the others. Behavior, stats accounting, and the resulting file order are unchanged -- only the scheduling is concurrent. The same treatment is applied to prepareFileChunks (used by batch-capable embedders like OpenAI/LM Studio), which overlaps per-file store deletes with chunk generation instead of serializing them.
  • watcher/watcher.go: addRecursive switched from filepath.Walk to filepath.WalkDir, removing a redundant Lstat per entry during the watch tree walk.
  • indexer/indexer_test.go: mockStore is now mutex-guarded. go test -race caught a real data race here once the indexer started calling store methods concurrently -- this brings the test double in line with the concurrency guarantees the real backends already provide (GOBStore's RWMutex, Postgres's connection pool, Qdrant's client).
  • indexer/indexer_branchswitch_test.go: fixed BenchmarkIndexAllWithProgress_BranchSwitchScenario, which only passed when b.N == 1 -- the mock store silently accumulated real documents on the benchmark's first iteration, so any run with b.N > 1 (e.g. -benchtime=Nx) failed regardless of this PR. Also added BenchmarkIndexAllWithProgress_FullHashRescan, which exercises the full-hash-comparison path this PR targets.

Why this is safe:

  • GetDocument/DeleteByFile are already safe for concurrent use on every existing backend: GOBStore guards its maps with an RWMutex, PostgresStore uses a pgxpool.Pool, and QdrantStore uses the qdrant client -- all designed for concurrent access.
  • Chunker.ChunkWithContext operates on immutable config with no shared mutable state.
  • framework.ProcessorRegistry.TransformForEmbedding is read-only over its config/processor list; VueProcessor already guards its own internal state with a mutex, indicating concurrent use was already anticipated.
  • Verified with go test -race ./indexer/... -- no data races.
  • Full existing test suite passes (go test ./...), go vet ./... is clean, and touched files are gofmt-clean.

Benchmarks (2 vCPU sandbox -- gains scale with core count, and are largest for network-backed stores like Postgres/Qdrant where each GetDocument call is a round trip rather than an in-memory map lookup):

Scenario Before After Speedup
FullHashRescan -- 800 files, all need local read+hash 6.26 ms/op 5.22 ms/op ~1.2x (CPU-hashing bound, 2 cores)
NetworkLatencyRescan -- 2000 files, 2ms simulated store latency each 4.32 s/op 0.55 s/op ~7.9x

The second scenario is the more representative stand-in for Postgres/Qdrant-backed projects, where the previous fully-sequential loop paid N round trips back-to-back; this PR overlaps them (bounded worker pool, sized max(8, min(64, GOMAXPROCS*4))).

Related Issue

Not tied to an existing issue -- opened directly after profiling indexing/watch-restart behavior on large repos.

Type of Change

  • Performance improvement
  • Test update

How Has This Been Tested?

  • Unit tests
  • Manual testing (benchmarks in indexer/indexer_branchswitch_test.go, before/after comparison)
go build ./...
go vet ./...
go test ./...
go test -race ./indexer/...
gofmt -l indexer/indexer.go indexer/indexer_test.go indexer/indexer_branchswitch_test.go watcher/watcher.go

All pass / clean.

Test Configuration:

  • OS: Linux (sandbox)
  • Go version: 1.24.4
  • Embedding provider: N/A (indexer/watcher logic, backend-agnostic; reasoning above covers GOBStore/Postgres/Qdrant)

Slicit added 4 commits July 13, 2026 16:34
addRecursive walked the tree with filepath.Walk, which does an extra Lstat syscall per entry on top of the directory read. Switching to filepath.WalkDir avoids that, roughly halving the syscall count of the watch tree walk on large repos (100k+ files) -- this walk blocks watch startup/restart before any fsnotify events are served.
BenchmarkIndexAllWithProgress_BranchSwitchScenario only passed when b.N == 1: the mock store had no seeded documents, so the first iteration actually indexed all 800 files (populating it as a side effect), and only later iterations hit the mtime fast path. Any run with b.N > 1 (e.g. -benchtime=Nx) failed the benchmark's own assertion regardless of the indexer change in this PR. Pre-seeding documents fixes that.

Also adds BenchmarkIndexAllWithProgress_FullHashRescan, which exercises the path the parallelization in indexer.go targets: every file's mtime looks new, so every file needs a real read+hash to discover it's actually unchanged.
IndexAllWithBatchProgress decided which files need (re)indexing one file at a time: a store lookup (GetDocument), an mtime fast-path gate, and -- for files that don't pass it -- a full read + SHA-256 hash. This is the exact phase that runs on every `grepai watch` restart, and after a git checkout/branch switch or CI clone where most files' mtimes look new, it becomes a full sequential hash of the repo before anything else can happen.

decideFileScan now runs this per-file decision concurrently across a bounded worker pool (golang.org/x/sync/errgroup, matching the pattern already used in embedder/openai.go), sized via scanWorkerLimit (max(8, min(64, GOMAXPROCS*4))). Behavior, stats accounting, and the resulting file order are unchanged -- only the scheduling is concurrent. GetDocument is already safe for concurrent use on every backend: GOBStore guards its maps with an RWMutex, PostgresStore uses a pgxpool.Pool, and QdrantStore uses the qdrant client.

prepareFileChunks (used by batch-capable embedders such as OpenAI/LM Studio) gets the same treatment: the per-file store delete + chunk generation loop is now concurrent, overlapping store round trips with chunking CPU work instead of paying both costs strictly one file at a time.

Verified with go test -race ./indexer/... (no races) and the full test suite. Benchmarks in indexer_branchswitch_test.go: ~1.2x on a 2 vCPU sandbox for local hashing, ~7.9x for a simulated network-backed store (2000 files, 2ms/lookup) -- representative of Postgres/Qdrant, where the previous sequential loop paid N round trips back-to-back.
go test -race caught a real data race here: the indexer now calls GetDocument/DeleteByFile concurrently across files (see indexer.go), but mockStore mutated its maps with no locking at all. Real backends already handle this -- GOBStore has an RWMutex, Postgres uses a connection pool, Qdrant's client is safe for concurrent use -- so the test double needed to catch up to the same contract. Guarded every method with a mutex; no behavior change otherwise.
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