Conversation
2dc4210 to
c41a499
Compare
c41a499 to
17f1cf1
Compare
a1b77ea to
204a1a1
Compare
Add ParallelVectorReadable, an optional IndexInput capability for fetching many fixed-size float vectors at scattered offsets, and VectorBatch, which gathers those reads across the inputs of a whole index. A KNN rerank shortlist is spread over every segment, so each segment's .vec input individually sees only a fraction of the query's reads; batching across segments is what lets a store reach a queue depth deep enough to keep a modern SSD busy. RescoreTopNQuery queues every segment's shortlist and issues a single batch per query, falling back to per-document scoring when the store cannot batch. FlatVectorsReader gains newRawVectorBatch/addRawVectors so a codec can contribute its raw float32 vectors to such a batch while keeping its file layout private. New SelectiveDirectIODirectory (misc) opens only .vec with O_DIRECT, serving a batch through a caller-owned executor, and FLOAT32 vector data is 4KB page-aligned so each vector is a single block with no read amplification.
204a1a1 to
5fdfec9
Compare
|
Thanks for this.
I’d also revisit the two reasons for routing
This is why mmap +
Here’s how we do it in Elasticsearch using APIs Lucene already has. The ring sits outside the leaf loop, so a candidate in segment N+1 can be prefetched while segment N is still being scored. The window isn’t tied to one input, so there’s no per-file ceiling: PrefetchRing ring = new PrefetchRing(WINDOW); // WINDOW = 100 for us; doc ids, never vectors
for (LeafReaderContext leaf : reader.leaves()) {
FloatVectorValues values = leaf.reader().getFloatVectorValues(field);
Scorer inner = weight.scorer(leaf);
if (values == null || inner == null) continue;
VectorScorer rescorer = values.rescorer(queryVector); // full precision
KnnVectorValues.DocIndexIterator vectorIter = values.iterator();
DocIdSetIterator conj =
ConjunctionUtils.intersectIterators(List.of(vectorIter, inner.iterator()));
for (int doc = conj.nextDoc(); doc != NO_MORE_DOCS; doc = conj.nextDoc()) {
values.prefetch(vectorIter.index()); // fire and forget
if (ring.isFull()) {
// oldest entry was prefetched WINDOW candidates ago; scores via VectorScorer.Bulk
ring.advance(scoreOldest(ring, buffer, results));
}
ring.append(doc, leaf.docBase, rescorer);
}
}
while (ring.size() > 0) ring.advance(scoreOldest(ring, buffer, results));Heap stays flat because the ring holds The current limitation is that We hit the same issue in Elasticsearch and fixed it in our wrapper. The upstream change is small: add This doesn’t rule out O_DIRECT. It should also be able to rescore efficiently, but it doesn’t need the new SPI either. The input can submit asynchronously during The query loop stays the same and the store chooses the mechanism. Would you be up for measuring the prefetch path before adding the API, on the same box and at the same operating point? I still expect mmap to be better, but if O_DIRECT clearly wins, that’s a real result. The next step would be your Directory behind |
Title:
(Implemented with AI, with Human in the loop)
Parallel
O_DIRECTfull-precision rerank reads for larger-than-RAM KNN searchDescription (Reviewed and Edited by Human)
For a quantized-plus-rerank KNN search over an index larger than RAM, cost is dominated by fetching
the full-precision vectors of the candidate shortlist.
RescoreTopNQueryscores one document at atime, so a shortlist of M candidates becomes M dependent blocking reads at a device queue depth of
~1.
Batching per input is not enough either. A shortlist is spread across segments, so M candidates over
N segments leaves only ~M/N reads per
.vecinput: a per-input batch tops out at M/N in flight andthe query still pays N sequential rounds. The depth that matters is M, which requires gathering reads
across the inputs of the whole index.
This PR adds that, plus an example
Directoryinmiscthat serves it withO_DIRECT.VectorBatchCapable(o.a.l.store) — optional capability of anIndexInput, in the spirit ofRandomAccessInput:VectorBatch(o.a.l.store) — the cross-input accumulator; nothing is read untilexecute():addreturns false for an input the batch does not recognise, so the caller falls back for that inputalone.
FlatVectorsReadergainsnewRawVectorBatch(field)andaddRawVectors(...), defaulting to "notsupported", so a codec can contribute raw float32 vectors without exposing its layout.
Lucene99FlatVectorsReaderimplements them only when its vector data input offers the capability.RescoreTopNQuery.rewritequeues every segment's shortlist, issues oneexecute()per query,then scores. No core
Directoryimplements the capability, so the default path is unchanged.SelectiveDirectIODirectory(misc) routes only.vecthroughO_DIRECT— the HNSW graph,quantized codes and metadata stay on the mmap delegate and stay page-cached — and serves a batch
through a caller-owned
Executor, sized independently of the searcher's executor.Lucene99FlatVectorsWriterpage-aligns FLOAT32 vector data to 4 KB, so a vector is one blockrather than straddling two.
O_DIRECTis opt-in and confined to.vecfor two measured reasons: read-ahead is waste on a randomshortlist (27.6 KB fetched per 4 KB vector, below), and one-shot rerank pages evict the graph and
codes that are worth caching.
How the numbers were obtained
AWS G6 instance, x86_64, AL2023, kernel 6.1.182, local NVMe. JDK 25 (Corretto), Panama Vector API.
25M Cohere v3 embeddings, 1024-dim fp32,
DOT_PRODUCT; HNSWmaxConn64 /beamWidth250, 1-bit BBQplus full-precision rerank via
RescoreTopNQuery; defaultmaxMergedSegmentMB; ~104 GB on disk,~101 GB of it vector data.
Every run is confined to a systemd scope with
MemoryMax=10Gagainst that ~101 GB of vectors, pagecache dropped before every run. Driver is
lucene-util'sKnnGraphTester, 10,000 queries,topK100, against precomputed exact-NN ground truth so recall is comparable across runs. Every row below usesoverSample5 /fanout100, so only the read path varies.Single-stream is one query at a time. Concurrent is open-loop: Poisson arrivals, bounded 32 server
threads with a 32-deep queue, load shedding when full; SLA-QPS is the highest offered rate holding
p99 ≤ 50 ms with ≤ 0.1% shed.
I/O figures are
iostat -xaverages over the measured phase (r/s,rkB/s,rareq-sz,aqu-sz,r_await), excluding idle samples.%utilis not used — it pins near 100% on NVMe well before thedevice is busy.
Bottleneck attribution before optimizing: PSI
io.pressure full≈ 19% — 19% of wall time with everyrunnable task stalled on I/O — against ~50% idle CPU, corroborated by eBPF
offcputimeinuninterruptible sleep.
Results
Single-stream, 10,000 queries:
O_DIRECT, unaligned vectorsO_DIRECT, 4 KB-aligned vectors25× slower at p99 — it moves 27.6 KB to deliver 4 KB.
same ~4 KB per read. Its higher aqu-sz (11.2 vs 7.2) is extra outstanding work, not useful depth.
r_awaitis 0.14–0.15 ms, so at a 13 ms p99 the device accounts for under 2% of latency; the restwas queueing and serialization.
The aligned row was measured twice from a dropped cache: p99 13.02 and 12.91 ms, recall 0.967 both,
IOPS within 0.2%, aqu-sz within 2%. The mmap baseline varies more across cold 40-minute passes (an
earlier run of the same stock code gave 235 ms).
Concurrent, aligned
O_DIRECT, 32 read threads — SLA-QPS ~110:Every point, including the failures, achieved its offered rate with zero shedding — the ceiling is
latency from queueing, not admission control.
Recall is tunable against throughput independently of this change: at
overSample3.5 /fanout25the same code sustains ~190 SLA-QPS at recall 0.937.
Scope and known gaps
Directoryimplements it, so nothing changes bydefault.
misc(
TestSelectiveDirectIODirectory: serial and parallel batch reads, and a query where the firstsegment cannot supply a batch while later ones can). Happy to add a test-only batch-capable
FilterDirectoryunder core's test tree if that is the preferred shape.because of the read path.
Companion PR
#16666 adds an io_uring-backed
Directoryinsandboxon top of this API, where a single submissionspans every segment's
.vec— reaching aqu-sz 16.3 against 7.2 here, for 9.53 ms single-stream p99and ~180 SLA-QPS at the same operating point and recall. Kept separate: this PR is pure JDK
(
ExtendedOpenOption.DIRECT) with no native dependency.