Support u64 vertex ids in the bftree provider - #1216
Conversation
There was a problem hiding this comment.
Pull request overview
This PR generalizes the diskann-bftree provider to support u64 vertex IDs (while keeping u32 as the default), enabling bf-tree indexes to exceed the u32::MAX vertex ceiling. It threads a new BfTreeId abstraction through the provider, neighbor storage, vector starting-point logic, and save/load metadata, and adds tests that exercise the wide-id path end-to-end.
Changes:
- Introduces
BfTreeId(u32/u64) and adds capacity validation to prevent silent truncation when minting IDs from indices. - Makes
BfTreeProviderand related accessors/strategies generic over vertex-id typeI, including neighbor record sizing viasize_of::<I>(). - Persists
id_widthinSavedParamsand validates width/capacity on load; adds tests foru64high-bit IDs andu64save/load round-trips.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| diskann-bftree/src/id.rs | Adds BfTreeId trait + capacity validation helper for index-derived IDs. |
| diskann-bftree/src/lib.rs | Exposes the new id module and re-exports BfTreeId. |
| diskann-bftree/src/vectors.rs | Makes starting-point ID generation generic over BfTreeId. |
| diskann-bftree/src/neighbors.rs | Sizes bf-tree neighbor records based on I width; adds u64 ID collision/round-trip test. |
| diskann-bftree/src/provider.rs | Makes BfTreeProvider generic over I; adds save/load id_width validation and u64 path tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1216 +/- ##
==========================================
+ Coverage 90.48% 90.50% +0.02%
==========================================
Files 497 498 +1
Lines 95489 96065 +576
==========================================
+ Hits 86399 86945 +546
- Misses 9090 9120 +30
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
|
Does u64 bloat adjacency list, and if so, is there a chance we can bring in a delta compression ? |
@harsha-simhadri yes, in u64 mode it doubles the adjacency-list storage (neighbor ids are stored inline), but that's explicit opt-in and there are no u64 consumers yet. I can tackle delta compression as a follow-up. Neighbor order doesn't affect search correctness, so we can sort ids and delta+varint them, which recovers essentially all of the u64 overhead. |
thanks, could you create an issue for future follow up. thanks |
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
The follow up issue is already linked above your comment. |
|
@harsha-simhadri |
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
3583acb to
5d4d540
Compare
The provider hardcoded `u32` vertex ids, capping any bf-tree-backed index at ~4.29B vectors. That ceiling is at odds with the crate's purpose of supporting larger-than-memory, billion-scale-and-beyond datasets. Introduce a `BfTreeId` trait (`VectorId + IntoUsize` plus index-construction) implemented for `u32` and `u64`, and thread a new id type parameter `I` through `BfTreeProvider<T, Q, I = u32>` and all of its accessors, strategies, and save/load impls. The default `I = u32` keeps every existing caller (benchmark harness, tests) compiling and behaving identically, while `u64` is now available opt-in for indexes that exceed the 32-bit range. `iter()`/`IntoIterator` now map `0..total` through `I::from_index` instead of relying on `Range<u32>` (a generic `Range<I>` would require the unstable `Step` trait). `VectorProvider::starting_points` is likewise generic over the id type. The on-disk neighbor format follows the id width and is self- consistent on load (the width is not persisted, mirroring the metric). A new `test_quantized_index_search_u64_ids` builds and searches a `BfTreeProvider<_, _, u64>` index end-to-end to prove the u64 path is functional, not merely compilable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reinforce the newly-reachable u64 id path surfaced by review: - neighbors: size record key/value buffers by size_of::<I>() instead of hardcoded size_of::<u32>(), so u64 providers reserve correct record sizes. - id: add validate_id_capacity to reject vertex counts that overflow the id type, and guard new_empty with it. - provider: persist id_width in SavedParams (back-compat default of 4) and validate it on load, so a u64 index can't be silently loaded as u32. - tests: add u64 high-bit id coverage (ids/neighbors > u32::MAX, low-bit collision check) plus a u64 save/load round-trip, id-width mismatch rejection, and capacity-guard rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add two tests exercising bf-tree vertex ids beyond u32::MAX: - test_quantized_index_search_u64_high_ids: end-to-end quantized insert + search with ids above u32::MAX, using a sparse max_points so no billion-scale allocation is needed. - test_u64_id_recall_parity_on_transposed_dataset: builds a random dataset twice (dense u32 identity vs order-preserving u64 offset above u32::MAX) and asserts identical neighbor sets per query plus a recall floor against brute-force ground truth. Existing u64 tests only used the u64 type with values in 0..N, which still fit in u32 and would pass even under silent 32-bit truncation. Also add a BfTreeId::INDEX_CONVERSION_LOSSLESS associated const, overridden for u64 to assert a 64-bit usize and referenced from the monomorphized validate_id_capacity. This turns the as_index truncation into a compile error only for u64 providers on 32-bit targets, leaving the default u32 path (even on 32-bit) unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d3795cf4-fd2c-46db-ac06-fc4a360392ba
Add an opt-in, validation-only path to the bf-tree full-precision
benchmark that transposes a real dataset's dense 0..N u32 row ids into
the u64-only range (base > u32::MAX), exercising the provider's u64
vertex-id code paths without touching production code.
- diskann-benchmark-core: `Offset` ToId<u64> mapper (base + i, checked)
with tests, documented as validation-only.
- diskann-benchmark: `single_or_multi_insert_offset` insert helper, a
`U64IdValidation { offset }` JSON config on BfTreeFullPrecisionBuild
(serde default, non-breaking), a widened `bftree_parameters_offset`,
and a dedicated `run_u64_id_validation` orchestration that builds a
BfTreeProvider<T, NoStore, u64>, inserts with the offset, and widens
ground-truth ids via Matrix::map to keep recall aligned.
- Example config + end-to-end integration test asserting the transposed
u64 run reproduces the identity-u32 recall baseline.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d3795cf4-fd2c-46db-ac06-fc4a360392ba
5d4d540 to
b07f493
Compare
Address hyper-critical review + Copilot PR feedback: - Add `test_u64_aliased_ids_distinct_vectors` covering the full-precision vector store's `as_index` key path. Existing u64 tests only use id values in `0..N` (collision-free low 32 bits), so a truncating key would pass them; this test inserts distinct vectors at `low` and `low | (1<<32)` and asserts each reads back its own vector, catching low-32-bit key aliasing. - Soften the over-claiming doc comment on `test_quantized_index_search_u64_high_ids` to reflect what it actually covers, pointing at the dedicated aliasing tests for key integrity. - DRY `single_or_multi_insert`/`single_or_multi_insert_offset` (Copilot #3): extract private `single_or_multi_insert_with_ids<..., M: ToId>`; both wrappers delegate with `Identity`/`Offset` mappers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d3795cf4-fd2c-46db-ac06-fc4a360392ba
| input: &BfTreeFullPrecisionBuild, | ||
| validation: U64IdValidation, | ||
| checkpoint: Checkpoint<'_>, | ||
| mut output: &mut dyn Output, |
What
Make the bf-tree provider generic over its vertex-id type so it can index past
the
u32::MAX(~4.3B) ceiling, while keepingu32as the default (no memory orstorage cost for existing datasets). Also add defensive hardening, wide-id test
coverage, and a benchmark-layer path to validate the
u64id space againstreal-world datasets.
id.rs—BfTreeIdtrait withu32/u64impls,from_index/try_from_index, andvalidate_id_capacity.provider.rs—BfTreeProvider<T, Q = QuantVectorProvider, I = u32>.The vertex id type threads through construction, save, and load. The persisted
SavedParamsrecordsid_width; load validates it against the requested typeso a
u32index can't be silently reopened asu64(or vice versa).neighbors.rs/vectors.rs— neighbor cells and starting-point seedingare generic over
I, sized viasize_of::<I>().Why
Larger-than-memory, billion-scale datasets need vertex ids wider than
u32.Defaulting
I = u32keeps the common case unchanged; opting intou64is asingle type parameter at the call site.
Hardening
Defensive coverage for the wide-id path:
id_widthmismatch instead of misreading data.> u32::MAX) ids and their low-bit collisions, plus au64save/load round-trip.Wide-id tests + 32-bit compile guard
The earlier
u64-typed tests only used values in0..N, which still fit inu32and would pass even under silent 32-bit truncation. Added tests thatactually exceed
u32::MAX:test_quantized_index_search_u64_high_ids— end-to-end quantized insert +search with ids above
u32::MAX, using a sparsemax_pointsso nobillion-scale allocation is needed.
test_u64_id_recall_parity_on_transposed_dataset— builds a random datasettwice (dense
u32identity vs an order-preservingu64offset aboveu32::MAX) and asserts identical per-query neighbor sets plus a recall flooragainst brute-force ground truth.
Added
BfTreeId::INDEX_CONVERSION_LOSSLESS, overridden foru64to assert a64-bit
usizeand referenced from the monomorphizedvalidate_id_capacity.This turns
as_indextruncation into a compile error only foru64providerson 32-bit targets, leaving the default
u32path (even on 32-bit) unaffected.Real-world
u64validation (benchmark layer)Real datasets ship as a dense
0..Nblock whose ids always fit inu32, sothey never exercise the
u64paths, and reaching an id> u32::MAXpositionally would need billions of rows. Added an opt-in, validation-only
benchmark path that transposes a dataset's dense
u32row ids into theu64-only range (base > u32::MAX) without touching production code:diskann-benchmark-core: anOffsetToId<u64>mapper (base + i, checkedadd), documented as validation-only.
diskann-benchmark: asingle_or_multi_insert_offsethelper, aU64IdValidation { offset }JSON config onBfTreeFullPrecisionBuild(
#[serde(default)], non-breaking), a widenedbftree_parameters_offset, anda dedicated
run_u64_id_validationorchestration that builds aBfTreeProvider<T, NoStore, u64>, inserts with the offset, and widensground-truth ids via
Matrix::mapto keep recall aligned. This reuses thealready-generic provider/search/recall stacks rather than relaxing the shared
u32build pipeline.u64run reproduces the identity-u32recall baseline.Validated on the 100K Wikipedia/Cohere dataset (offset
2^32, so every vertexid lives above
u32::MAX): recall@100 0.9536 (u32) vs 0.9537 (u64) — parityconfirmed (the sub-
1e-4delta is nondeterministic 8-thread multi-insert batchordering, not an id-path difference).
Deferred: the streaming benchmark is not yet transposable — its shared
executor is
u32-pinned (theTagSlotManagerslot allocator, theManagedStreamtrait, theManagedtranslation layer, andBfTreeStreamallhardcode
u32), so a transposed stream run needs a broader genericization ofthat shared infrastructure. Tracked as follow-up.
Notes
Iis an internal id. Theinmem2direction uses
InternalId = u32(hardcoded) + generic external id. Worthaligning on whether bftree needs wide internal ids or should follow the same
internal-u32 / external-generic split before merge.