Repository navigation
IVF per cluster storage - #140
Merged
Merged
Conversation
…istorical-compat test to load it
…ge so add() only touches clusters that actually changed
…h touched clusters, not index size
…allocating every call
…commit their generator
TamimEhsan
marked this pull request as ready for review
October 18, 2026 15:48
TamimEhsan
marked this pull request as draft
October 18, 2026 16:55
TamimEhsan
marked this pull request as ready for review
October 19, 2026 05:11
Include the fixture directory in temporary filenames to prevent collisions during parallel runs. Keep only the new quantized and raw L2 fixtures, preserving all existing v0.5.2 compatibility cases.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Give each IVF cluster its own storage, so
IVF::add()only touches the clusters that receive new points instead of copying the whole index on every call.Before this change, all clusters shared four combined buffers (batch data, extra-bit codes, raw vectors, IDs). Any
add()rebuilt all four, so its cost and peak memory grew with the whole index, not with the points added. Now:IVFholds one privateClusterStorageper cluster.Clusterstays a non-owning view and the search path is unchanged.add()touches only the clusters it adds to. Untouched clusters are neither reallocated nor copied; their storage pointers are unchanged afteradd(). A touched cluster is rebuilt at exactly its new size next to its old storage and swapped in, unless it has reserved room (below).IVF::reserve(cluster_id, num_points)and PythonIvfIndex.reserve(cluster_id, num_points). These preallocate room so that lateradd()calls grow the cluster in place, without copying it. Reserved room is not saved, so a loaded index has none. In Python,reservevalidates its arguments and takes the same exclusive-access guard asadd. The change is additive; no existing signature changes.Saved-index format
Unchanged.
save()still writes all batch data, then all extra-bit or raw data, then all IDs, in cluster order, which is byte-for-byte the old shared-buffer layout.load()reads that layout into each cluster's own storage.To pin this,
tests/fixtures/persistence/pre-ivf-per-cluster-storage/adds frozen IVF files written bymainat64fc943. They cover 4-bit and raw (nbits=32) storage under L2 and IP, with four uneven clusters of 0, 1, 32 and 97 points. Unlike the single-clusterv0.5.2fixtures, these catch a change in how multi-cluster data is ordered on disk. The test requires that re-saving each file reproduces it byte-for-byte. The generator, its CMake project and the SHA-256 manifest are committed alongside the fixtures.How other IVF libraries do it
Checked against current source of FAISS, cuVS, Knowhere (Milvus), ScaNN, pgvector, Lance and Elasticsearch (DiskBBQ). SPFresh is described from its paper.
Storing clusters in memory. Every index that can be updated in memory gives each cluster its own storage:
ArrayInvertedLists(code and ID vectors per list),BlockInvertedLists(32-vector FastScan blocks per list, includingIndexIVFRaBitQFastScan), and GPU IVF (a device vector per list).IVF_FLAT_CC: a chain of fixed-size segments per cluster. Knowhere'sIVF_RABITQwraps FAISS.ivfflat: a linked chain of 8 KB pages per list.The only shared-buffer design found is cuVS IVF-RaBitQ: flat arrays with a start offset per cluster, like
main. It has noextendAPI; you can only build it.Adding points and growing a cluster.
std::vector)ArrayInvertedLists, ScaNNBlockInvertedListsandOnDiskInvertedLists; cuVS up to 1024 rows, then multiples of 1024conservative_memory_allocation; this branch by defaultreserve)IVF_FLAT_CC(48 vectors by default), pgvector (8 KB pages)When adding to a partial FastScan block, FAISS
BlockInvertedListsrepacks the new vectors into the tail block one at a time, in place, as this branch does when a cluster has room. FAISS doesn't promise that a failed add leaves the index unchanged; this branch does, by repacking the tail in scratch first.Searching while adding. Only Knowhere's
IVF_FLAT_CCis designed for this: its segments never move, and each list keeps an atomic size. The others, including this branch, may move a cluster's memory while it grows, so writes need exclusive access.Deleting points. FAISS
remove_idsswaps the last entry into each removed slot and then shrinks the list. pgvector marks entries dead and reclaims them during VACUUM. Lance and Elasticsearch use deletion markers and drop the rows at the next rebuild or merge.Updating centroids as data changes. This branch, like
main, keeps centroids fixed after construction. Others adapt:adaptive_centersupdates centroids onextend.Saving to disk. Disk-oriented formats use one file with clusters stored back-to-back and an offset per cluster: Lance (one file per index segment) and Elasticsearch (
.clivfposting lists plus.mivfoffsets, one set per Lucene segment). FAISSOnDiskInvertedListsalso uses a single memory-mapped file, with a power-of-two slot per list and a free-slot list. This branch keeps the existing format, a single file with clusters in order.Spilling points to a second cluster. Elasticsearch DiskBBQ also stores each vector in a second, nearby cluster (SOAR) to improve recall, and ScaNN has a spilling option. That changes cluster sizes and the file layout, and is out of scope here.
Possible follow-ups, not in this PR:
reserve.add.Known tradeoff: search speed
Search is up to about 3–4% slower than
mainon GIST1M, with identical results. Most per-cluster buffers are under 2 MiB, so they no longer get the huge-page advice (MADV_HUGEPAGE) the single shared buffer received. Whether to address this before merging is still open.Setup: GIST1M (1M × 960, 1000 queries), 9-bit IVF, L2, recall@10 against the official ground truth.
mainis64fc943. Intel Xeon Gold 6418H, g++ 14.3.0, Release,RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFFwith runtime dispatch, transparent huge pages set tomadvise. Single-thread search pinned to one core.mainand this branch alternate for five rounds, and each value is the median QPS.mainQPSperf statshows 2–3× more dTLB load misses on the branch (1.2–1.7M against 0.4–0.7M) and 57k page faults against 3.6k.mainfrom the same centroid files.A possible fix: after
constructandload, keep all clusters in one huge-page buffer asmaindoes, and give a cluster its own buffer only once it grows. That should recovermain's search speed for indexes that are never added to, at the cost of more complex storage code.Docs
IVF::addheader comment and the Pythonadddocstring describe the per-cluster cost andreserve.docs/docs/index/ivf.md: the "Cost ofadd" section is rewritten, andreserveis documented.docs/docs/index/usage.md:reserveis listed as requiring exclusive access.Benchmark
benchmarks/hotpaths/ivf_add.cppadds a hot-path benchmark foradd(), insingle(one target cluster) androuted(nearest-centroid) modes, registered as the CTest smoke testsIvfAddHotpathSingleandIvfAddHotpathRouted.Why this matters for LlamaIndex and Haystack integrations
Vector stores in frameworks such as LlamaIndex and Haystack receive data incrementally. Documents are chunked and embedded in small batches, often a few dozen vectors at a time, and written to the store batch by batch, and users keep inserting, updating and deleting documents in a live store. An adapter would turn each of these writes into an
IvfIndex.add()call.On
main, eachadd()copies the whole index. Ingesting N vectors in batches of size b therefore costs about N²/b, and each call briefly needs about twice the index's memory. At about 98 ms per one-point add, document-by-document ingestion is impractical. With this change:mainreserveaddadd(), with no client-side buffering or periodic full rebuilds.reserve()can pre-size clusters, for example in proportion to their current sizes, so later batches grow in place without copying.An integration still has to provide:
PIDs belowkSearchBufferMaxPointCount.addthat overlaps a search instead of waiting.save()still rewrites the whole file, and reserved room is not saved.Testing
Built with
RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF, Release, g++ 14.3.0, Linux x86-64.IvfAddHotpath*smoke tests. One test is skipped (ThreadCount.BoundParallelForUsesRuntimePlacementAndPropagatesErrors), as onmain.IvfIndex.reserve, thenpython -m pytest tests/python: 703 passed, 2 skipped (the wheel tests)../scripts/check-format.sh(clang-format 15.0.7),./scripts/check-python.sh,python -m mkdocs build --strict --config-file docs/mkdocs.ymlandgit diff --checkall pass.64fc943separately. It loads all four new fixtures (cluster sizes 0, 1, 32 and 97), re-saves them byte-for-byte, and its search matches the.expectedfiles. All eight manifest hashes match.New and updated tests:
EveryWayOfAddingTheSamePointsGivesByteIdenticalIndexesgains areservedcase: half the clusters grow in place and half are rebuilt, for bits {1, 4, 9, 32} under L2 and IP, and the result must be byte-identical to a singleadd.AddingToOneClusterLeavesOtherClustersUntouched: untouched clusters keep their storage pointers.UnreservedClustersGrowToExactlyTheirNewSize: a rebuilt cluster has no spare capacity.IvfReserveTest: growing in place until the reserved room runs out, exact error types and messages, and confirmation that spare capacity changes neither the saved file nor a loaded index.FailedAddLeavesClustersGrowingInPlaceUnchanged: an injected failure in one cluster, after another has grown in place, leaves the saved bytes unchanged, and a retry matches a clean index. With the old in-place tail write restored, only this test fails.test_reserved_adds_match_a_single_add(bits 1, 4 and 32) andtest_reserve_rejects_invalid_arguments.Not run: a full
./scripts/check-tidy.sh build-tidy(required before merge), and a search benchmark with recorded numbers (see the TODO above).