Skip to content

Compaction: eliminating disk scan and updating PQ retrain#691

Open
dian-lun-lin wants to merge 5 commits into
mainfrom
compaction-perf-fix
Open

Compaction: eliminating disk scan and updating PQ retrain#691
dian-lun-lin wants to merge 5 commits into
mainfrom
compaction-perf-fix

Conversation

@dian-lun-lin

@dian-lun-lin dian-lun-lin commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Speeds up graph compaction (merging pre-built partition indexes into one) — 5.6–11.5× faster
cold
, at roughly the same recall. Five changes:

  1. Eliminate the cold disk scancomputeLayerInfoFromSources called getNodes(0), which
    seeks + reads every node record on disk just to count live nodes. Replaced with an in-memory
    liveNodes.cardinality() popcount (no I/O). The big one on large datasets.
  2. Warm-start PQ retrain — retrain the codebook via Lloyd's refinement from the existing
    codebook instead of full k-means++ from scratch (far fewer passes, no recall loss).
  3. Windowed streaming prefetch — sources are mmapped MADV_RANDOM, so bulk phases faulted one
    page at a time. Now each phase (retrain sampling, code pre-encode, L0 batches) streams the exact
    records it is about to read into the page cache first.
  4. rerankK = searchTopK — the L0 cross-source search used a beam 2× the candidate budget; the
    extra candidates are pruned by diversity selection anyway. Halving it cuts ~19% off L0 for
    ~0.006 recall.
  5. Post-compaction refinement optional (default off) — it costs ~20–25% of compaction time and
    contributes ~0 recall (a query-latency/navigability pass), so it is now opt-in.

The two structural fixes (1. scan elimination, 3. prefetch) remove the disk-bound overhead that
dominated cold compaction; 4./ 5. trim the remaining compute. Recall stays within ~0.006 of main.

main vs PR (perf-fix + prefetch): compaction time & recall

220g heap, -wi 1 -i 1 single fork ("cold" = warmup iteration),
drop_caches before each arm so both start disk-cold. Graph degree 32, beam width 100.
Recall is search@10 on the compacted graph.

  • main = b86a94c8 (stock)
  • PR = compaction-perf-fix @ 8324dd6d = disk-scan fix + warm-start retrain + streaming
    prefetch + rerankK + refinement-off
Dataset Workload main cold PR cold Speedup main recall PR recall
cohere-1M 2-TIERED 2m58s 26s 6.8× 0.6094 0.6116
cohere-1M 2-UNIFORM 2m58s 19s 9.4× 0.6202 0.6145
cohere-1M 4-UNIFORM 3m09s 27s 7.0× 0.6239 0.6230
cohere-10M 2-TIERED 27m09s 2m44s 9.9× 0.5649 0.5631
cohere-10M 2-UNIFORM 27m00s 2m46s 9.8× 0.5706 0.5648
cohere-10M 4-UNIFORM 29m24s 4m34s 6.4× 0.5689 0.5668
cap-1M 2-TIERED 3m26s 27s 7.6× 0.6646 0.6633
cap-1M 2-UNIFORM 3m15s 26s 7.5× 0.6671 0.6630
cap-1M 4-UNIFORM 3m54s 39s 6.0× 0.6706 0.6659
cap-6M 2-TIERED 17m15s 2m11s 7.9× 0.6330 0.6277
cap-6M 2-UNIFORM 16m56s 2m14s 7.6× 0.6360 0.6310
cap-6M 4-UNIFORM 18m36s 3m20s 5.6× 0.6427 0.6356
dpr-gemma-1m 2-TIERED 2m48s 19s 8.8× 0.7844 0.7781
dpr-gemma-1m 2-UNIFORM 2m44s 17s 9.6× 0.7757 0.7769
dpr-gemma-1m 4-UNIFORM 2m55s 24s 7.3× 0.7830 0.7846
dpr-gemma-10m 2-TIERED 25m41s 2m14s 11.5× 0.6895 0.6868
dpr-gemma-10m 2-UNIFORM 26m01s 2m20s 11.2× 0.6872 0.6852
dpr-gemma-10m 4-UNIFORM 26m57s 3m42s 7.3× 0.7052 0.6961

Takeaways

  • 5.6–11.5× faster cold compaction than main across every dataset.
  • Recall within ~0.006 of main

…uteLayerInfoFromSources

computeLayerInfoFromSources called getNodes(0) on each source graph to count live nodes
at level 0. getNodes(0) sequentially seeks through every node record on disk to filter
out deleted entries. On a cold page cache this touches large amounts of source data before
compaction even begins, significantly delaying the start of actual graph merging.

Since every live node is present at level 0 by the HNSW invariant, the count is simply
liveNodes.get(s).cardinality() — an in-memory popcount requiring no I/O.

Also switch PQ retraining from ProductQuantization.compute() (full k-means++ init)
to basePQ.refine() (Lloyd's iterations only, warm-started from the existing codebook).
The source codebooks are already trained on the same distribution, so warm-starting
converges in far fewer passes with no recall loss.
@dian-lun-lin
dian-lun-lin marked this pull request as draft July 2, 2026 22:19
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Before you submit for review:

  • Does your PR follow guidelines from CONTRIBUTIONS.md?
  • Did you summarize what this PR does clearly and concisely?
  • Did you include performance data for changes which may be performance impacting?
  • Did you include useful docs for any user-facing changes or features?
  • Did you include useful javadocs for developer oriented changes, explaining new concepts or key changes?
  • Did you rebase your branch onto the latest main for regression testing and PR submission?
  • Did you trigger regression testing via Run Bench Main and review results?
  • Did you adhere to the code formatting guidelines (TBD)
  • Did you group your changes for easy review, providing meaningful descriptions for each commit?
  • Did you ensure that all files contain the correct copyright header?
  • Did you add documentation for this feature to the release notes directory?

If you did not complete any of these, then please explain below.

@dian-lun-lin
dian-lun-lin marked this pull request as ready for review July 4, 2026 08:05
The timer was initialized after the extraction call, so the logged
duration was always ~0. Addresses PR review feedback.
Source graphs are mapped with MADV_RANDOM (correct for search-time access),
which disables kernel readahead and makes compaction's bulk phases fault one
page at a time on a cold cache (measured: retrain ~37s, pre-encode ~42s on a
disk-cold 10M-node compaction that takes ~3s/~8s warm).

Adds ReaderSupplier.prefetch(offset, length) — streams a byte range into the
page cache through a separate readahead-enabled descriptor — and
OnDiskGraphIndex.prefetchL0Records(minNode, maxNode) on top of it. Each bulk
phase warms exactly the records it is about to read, from the worker that
will read them:
- PQ retrain prefetches its (source, node)-sorted sample ranges before
  extraction (bounded by training-set size, not file size),
- code pre-encode prefetches each chunk's records at task start,
- L0 batch processing prefetches each batch's own records at task start
  (cross-source search reads are data-dependent and stay demand-faulted).

The per-thread prefetch buffer is 64KB: streamed bytes are discarded, so the
buffer is sized to stay L2-resident. Measured on 56 threads: disk-cold
throughput is device-bound (~3.3 GB/s) at every size from 32KB to 4MB, and
in the warm case (range already cached, pass is pure overhead) 64KB runs
within noise of larger buffers while 4KB is 2-3x slower in both regimes.

Transient cache demand is proportional to the in-flight windows, so there is
no up-front whole-file streaming pass and no memory-availability gate to
mistune: on a box that cannot hold the sources, pages are simply evicted and
reads degrade per-page to the old fault-on-demand behavior.
The L0 cross-source candidate search passed beamWidth (= 2x searchTopK) as
rerankK, doubling the approximate-phase beam over what the candidate budget
needs. A seeding-vs-beam decomposition study (7 paired disk-cold arms,
cohere-10M, median-of-3) showed the narrower beam is where the time goes:

  S=2: L0 165.2s -> 133.4s (-19%), recall 0.5718 -> 0.5660
  S=4: L0 260.3s -> 224.6s (-14%), recall 0.5733 -> 0.5659

Query latency on the merged index is unaffected (0.55ms avg both ways).
The wider beam's extra candidates were largely pruned by diversity
selection, which keeps at most degree edges per node.

The same study found warm-start seeding of these searches (from finished
neighbors' merged adjacency, reverse candidates, or upper-layer descent) is
net-negative: at matched beam width, seeded searches run 8-17% slower with
equal recall — the per-search seeding overhead exceeds the few cheap
descent hops it saves. Beam width is the whole lever.
Refinement ablations (three datasets, disk-cold, paired same-window runs)
show its recall contribution on the merged index is ~0: same-beam arms with
and without refinement land within 0.001. Skipping it saves 45-58s, ~20-25%
of total compaction time at 10M nodes. What it buys is navigability — query
latency on the merged index rises from ~0.55ms to ~0.95ms avg (p99 2.0ms to
3.9ms, cohere-10M) without it.

That is a workload tradeoff, not a correctness call: default to compaction
throughput, and let latency-sensitive pipelines opt back in with
setRefineAfterCompaction(true).
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.

2 participants