Skip to content

perf(core): allocation-free query path with pooled SearchScratch - #46

Merged
matengo merged 1 commit into
masterfrom
perf-query-scratch
Sep 18, 2026
Merged

matengo merged 1 commit into
masterfrom
perf-query-scratch

Conversation

@matengo

@matengo matengo commented Sep 18, 2026

Copy link
Copy Markdown
Owner

Summary

PR 3 of the performance programme (docs/design-performance.md §4.1 + §4.3): makes the query path allocation-free apart from the result the caller receives.

Changes

  • InsertScratchSearchScratch, extended with reusable PreparedQuery, normalised-query buffer and int8 query-code buffer; pooled via ConcurrentBag, rented in both Search overloads, returned in finally.
  • SearchLayerNearest, SearchLayerFiltered, GreedyClosest iterate NeighborPointer in place (read lock held; slots are whole int32 writes) instead of copying into an ArrayPool buffer per hop.
  • LINQ tail replaced by a stable insertion sort (only in rescored mode — the heap already yields descending order) and a pre-sized result list.
  • SearchLayerFiltered keeps metadata strings only for admitted nodes.
  • Visiting order unchanged → recall and graph identical by construction.

Measured (reference machine, SearchBenchmarks, --job short)

dim mode mean before → after allocated before → after
128 float 92.4 → 50.9 µs 30.8 KB → 1.48 KB
128 int8 88.8 → 58.5 µs 30.8 KB → 1.48 KB
128 int8 rescored 95.3 → 38.4 µs 30.7 KB → 1.48 KB
768 float 425.7 → 386.4 µs 67.9 KB → 1.48 KB
768 int8 211.8 → 163.6 µs 68.8 KB → 1.48 KB
768 int8 rescored 275.5 → 243.5 µs 68.7 KB → 1.48 KB

Gen1 collections: gone. Gen0: 7.4–16.6 → 0–0.31 per 1k ops.

Validation

  • dotnet build Qvec.slnx -c Release: 0 warnings
  • Core tests incl. Slow: 415/415; full solution fast: 507/507

Search allocated 30-68 KB per query (HashSet, two PriorityQueues,
PreparedQuery, ArrayPool neighbour copies, LINQ tail) and triggered a
gen1 collection every 1-4k queries. Rename InsertScratch to
SearchScratch, extend it with reusable PreparedQuery / normalised query
/ int8 code buffers, and rent it from a ConcurrentBag pool in both
Search overloads. SearchLayerNearest, SearchLayerFiltered and
GreedyClosest now read neighbour lists in place via NeighborPointer
under the read lock, and the LINQ OrderByDescending/Take/Select/ToList
tail is replaced by a stable insertion sort (rescored mode only) and a
pre-sized result list. Visiting order is unchanged, so recall and the
built graph are identical.

Micro-benchmark (reference machine, 10k-node index, topK 10, ef 100):
allocated per query 30.8/68 KB -> 1.48 KB, gen1 0, 128-d float
92 -> 51 us, 768-d int8 212 -> 164 us. Recorded in
docs/design-performance.md section 2.1 and section 4.1/4.3.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@matengo
matengo merged commit 607dda6 into master Sep 18, 2026
2 checks passed
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