john-mlika opened a new issue, #16420:
URL: https://github.com/apache/lucene/issues/16420
### Description
`Lucene104ScalarQuantizedVectorsWriter.mergeAndRecalculateCentroids` has a
fast path that computes
the merged centroid as the vector-count-weighted mean of the source
segments' stored centroids,
skipping any data read. It is unreachable for HNSW-wrapped fields — that is,
for
`Lucene104HnswScalarQuantizedVectorsFormat`:
```java
// Lucene104ScalarQuantizedVectorsWriter (main:423-429)
static float[] getCentroid(KnnVectorsReader vectorsReader, String fieldName)
{
vectorsReader = vectorsReader.unwrapReaderForField(fieldName);
if (vectorsReader instanceof Lucene104ScalarQuantizedVectorsReader reader)
{
return reader.getCentroid(fieldName);
}
return null;
}
```
`unwrapReaderForField`'s default returns `this` and only
`PerFieldKnnVectorsFormat.FieldsReader`
overrides it (`KnnVectorsReader.java:58-60`), so for an HNSW-wrapped field
the unwrap stops at
`Lucene99HnswVectorsReader`, the `instanceof` fails, `getCentroid` returns
`null`, and
`mergeAndRecalculateCentroids` (main:431+) sets `recalculate = true` —
triggering
`calculateCentroid`: a **full pass over every float of every source
segment**, on **every** merge,
including the delete-free ones the fast path exists for.
That pass is one of **four** full reads the merge makes of each source
`.vec` — the `checkIntegrity`
checksum (`KnnVectorsWriter#merge:117`), the raw-vector copy
(`Lucene99FlatVectorsWriter#mergeOneFlatVectorField:279`), the centroid
recomputation
(`calculateCentroid:482`) and the re-quantization pass
(`Lucene104ScalarQuantizedVectorsWriter#mergeOneFlatVectorField:339`).
Measured on a delete-free `forceMerge(1)` of two 500-doc, 64-dim segments,
with byte counting at a
`FilterDirectory` and each `slice()` tagged by its calling frame,
eliminating the centroid pass cuts
source-segment raw-vector reads by **exactly one full pass** — 256,000
bytes, to the byte:
| metric | unpatched | with the fix below | delta |
|---|---|---|---|
| source-segment `.vec` reads | 1,024,516 B (4.002 passes) | 768,516 B
(3.002) | **−25.0%** |
| all reads from the source segments | 1,111,226 B | 855,226 B | −23.0% |
| total merge read volume (all files) | 7,814,144 B | 7,558,144 B | −3.3% |
The last row is the honest context: HNSW graph construction re-reading the
merged segment's `.veq`
is 6.5 MB of that total (83%), so this dominates only the *source-read*
side. What it removes is a
recomputation of metadata already sitting in the field entries, on every
delete-free merge.
### The obvious fix does not work
Overriding `unwrapReaderForField` on `Lucene99HnswVectorsReader` to return
its flat reader is not
viable. I checked the callers, and it breaks:
- `HnswUtil.graphIsRooted` (the unwrap no longer surfaces the
`HnswGraphProvider`),
- two CheckIndex paths,
- `SlowCodecReaderWrapper.getOffHeapByteSize`,
- `TestLucene104HnswScalarQuantizedVectorsFormat.testSimpleOffHeapSize`.
`unwrapReaderForField` is a general-purpose unwrap whose callers need the
HNSW layer; its semantics
shouldn't change for this.
### Proposed fix: route centroid access through `QuantizedVectorsReader`
`Lucene99HnswVectorsReader` **already implements** `QuantizedVectorsReader`,
delegating to its flat
reader when that reader is itself a `QuantizedVectorsReader` (main:74-75
declaration, :426/:434/:443
delegation guards). Riding that interface avoids touching the unwrap:
1. Add a centroid accessor to the `@lucene.experimental` interface — the
default keeps every
existing implementor source- and binary-compatible:
```java
// org.apache.lucene.util.quantization.QuantizedVectorsReader
/** The stored centroid for {@code fieldName}, or {@code null} if this
reader has none. */
default float[] getCentroid(String fieldName) {
return null;
}
```
2. `Lucene104ScalarQuantizedVectorsReader` marks its existing
`getCentroid(String)` as the interface
override — the method already exists; the writer calls it today after the
concrete-class cast.
3. `Lucene99HnswVectorsReader` forwards it in its existing delegation block:
```java
@Override
public float[] getCentroid(String fieldName) {
if (flatVectorsReader instanceof QuantizedVectorsReader qvr) {
return qvr.getCentroid(fieldName);
}
return null;
}
```
4. The writer's helper checks the interface after the unchanged,
PerField-only unwrap:
```java
static float[] getCentroid(KnnVectorsReader vectorsReader, String fieldName)
{
vectorsReader = vectorsReader.unwrapReaderForField(fieldName);
if (vectorsReader instanceof QuantizedVectorsReader reader) {
return reader.getCentroid(fieldName);
}
return null;
}
```
No change to `unwrapReaderForField` or its callers, and
`mergeAndRecalculateCentroids` is untouched —
its `centroid == null || liveDocs != null` recalc logic already does the
right thing once centroids
arrive. Non-quantized readers return `null` via the default and recalculate
exactly as today.
### Testing it
I verified the above with a same-package test
(`org.apache.lucene.codecs.lucene104`) that calls the
package-private `getCentroid` directly and measures merge I/O by counting
bytes at a
`FilterDirectory`, tagging each `slice()` with its calling frame so reads
attribute to individual
merge passes. Counting inside a wrapping `KnnVectorsReader` delegate turned
out to be the wrong
instrument — wrapping the reader changes what the `instanceof` /
`QuantizedVectorsReader` checks see
and perturbs the very path being measured.
On unpatched `main` it reports:
```
[centroid] top reader = PerFieldKnnVectorsFormat$FieldsReader
[centroid] unwrapReaderForField = Lucene99HnswVectorsReader
[centroid] Lucene104ScalarQuantizedVectorsWriter.getCentroid(...) = null
```
With the four-step sketch applied, the same line reads `= float[64]
0.520761`, and the merge's `.vec`
read volume drops by exactly one full pass over the source floats (1,280,774
→ 1,024,774 bytes for
1000 × 64-dim vectors). A run of `org.apache.lucene.codecs.lucene104.*` and
`org.apache.lucene.codecs.lucene99.*` with the sketch applied is clean (338
tests; the only failure
is that suite's own deliberately inverted `assertNull` on `getCentroid`).
Worth covering in a real PR beyond that: the delete case (`liveDocs !=
null`), where
`calculateCentroid` must still run, and a regression sweep over the
`unwrapReaderForField` callers
listed above, which this approach leaves untouched.
I'm happy to put up the PR, with the test, if the approach looks right.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]