Repository navigation
PiPNN 3/6: add core graph construction - #1290
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces the “core” PiPNN build pipeline in the diskann-pipnn crate, wiring together deterministic partitioning, leaf-local candidate construction, and final pruning into a public build_graph API with a validated build context.
Changes:
- Adds
PiPNNConfigvalidation and aPiPNNBuildContextthat binds PiPNN policy to DiskANN graph pruning policy and a caller-owned Rayon thread pool. - Implements the three main stages: partitioning (
partitioning.rs), leaf candidate construction (leaf_build.rs), and final pruning via shared Vamana robust prune (finalization.rs). - Adds comprehensive unit/integration tests and a Criterion benchmark for core scenarios; updates dependencies, lockfile, and mutation-test exclusions.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| diskann-pipnn/src/lib.rs | Adds public PiPNN API (PiPNNConfig, PiPNNBuildContext, build_graph) and stage orchestration. |
| diskann-pipnn/src/partitioning.rs | Implements deterministic overlapping partition construction and leader assignment/scatter. |
| diskann-pipnn/src/partitioning/tests.rs | Adds unit tests covering partition determinism, invariants, error cases, and helpers. |
| diskann-pipnn/src/leaf_build.rs | Builds leaf-local symmetric k-NN candidates and accumulates global candidates safely in parallel. |
| diskann-pipnn/src/leaf_build/tests.rs | Adds unit tests for candidate correctness, invariants, type support, and error handling. |
| diskann-pipnn/src/finalization.rs | Orders/prunes candidate rows using shared robust_prune and validates candidate IDs/shape. |
| diskann-pipnn/src/finalization/tests.rs | Adds unit tests for pruning behavior and candidate validation failures. |
| diskann-pipnn/src/tests.rs | Tests effective_metric behavior for integer cosine-normalized handling. |
| diskann-pipnn/tests/config.rs | Integration tests for config validation and graph-policy compatibility checks. |
| diskann-pipnn/tests/build_graph.rs | Integration tests for end-to-end graph building, invariants, determinism, and type/metric support. |
| diskann-pipnn/benches/core.rs | Adds a Criterion benchmark for stage-focused core build scenarios. |
| diskann-pipnn/Cargo.toml | Updates crate dependencies/dev-dependencies and registers the new core benchmark target. |
| Cargo.lock | Records dependency graph changes for the updated diskann-pipnn crate dependencies. |
| .cargo/mutants.toml | Adds mutation-test exclusions for key PiPNN public boundary checks and partitioning invariants. |
Comments suppressed due to low confidence (1)
diskann-pipnn/src/partitioning.rs:604
size_of::<f32>()is used without being in scope (nouse std::mem::size_of;and not qualified), so this function won’t compile as written.
fn assignment_stripe_rows(leaders: usize) -> usize {
(ASSIGNMENT_CACHE_TARGET_BYTES / (leaders.max(1) * size_of::<f32>()))
.clamp(MIN_ASSIGNMENT_STRIPE_ROWS, MAX_ASSIGNMENT_STRIPE_ROWS)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
5be0c9c to
50047c6
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
diskann-pipnn/src/partitioning.rs:637
size_ofis used without being in scope (std::mem::size_of), which will not compile. Qualify the call or import it.
fn assignment_stripe_rows(leaders: usize) -> usize {
(ASSIGNMENT_CACHE_TARGET_BYTES / (leaders.max(1) * size_of::<f32>()))
.clamp(MIN_ASSIGNMENT_STRIPE_ROWS, MAX_ASSIGNMENT_STRIPE_ROWS)
}
diskann-pipnn/src/partitioning.rs:19
Normis imported but never used in this module, which will tripunused_importswarnings (and can become CI failures under-D warnings). Remove it from the import list.
use diskann::{utils::VectorRepr, ANNError, ANNResult};
use diskann_linalg::Transpose;
use diskann_utils::views::MatrixView;
use diskann_vector::{distance::Metric, norm::FastL2NormSquared, Norm};
use rand::{prelude::IndexedRandom, SeedableRng};
use rayon::prelude::*;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
diskann-pipnn/src/partitioning.rs:390
gather_rowsusesTypeId::of::<T>(), which implicitly requiresT: 'static. Making that bound explicit here avoids surprising/indirect trait-bound errors later and matches the publicbuild_graphboundary (which already requires'static).
fn gather_rows<T>(data: MatrixView<'_, T>, indices: &[u32], output: &mut [f32]) -> ANNResult<()>
where
T: VectorRepr,
1324668 to
857e200
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
diskann-pipnn/src/partitioning.rs:641
size_of::<f32>()is used without being imported or qualified, which will fail to compile. Qualify it withstd::mem::size_of(or add an explicit import).
let rows = ASSIGNMENT_CACHE_TARGET_BYTES / (leaders.max(1) * size_of::<f32>());
857e200 to
f642204
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (4)
diskann-pipnn/src/leaf_build.rs:222
build_leafis executed from a Rayon parallel context (viabuild_leaf_candidates), so it should also explicitly requireT: Send + Syncto reflect the actual thread-safety requirement.
where
T: VectorRepr + 'static,
{
diskann-pipnn/src/leaf_build/tests.rs:154
assert_source_typeforwardsTinto the parallel leaf build path, so it should also includeSend + Syncbounds to match the production requirements.
fn assert_source_type<T>(data: &[T])
where
T: diskann::utils::VectorRepr + 'static,
{
diskann-pipnn/src/leaf_build.rs:193
build_leaf_candidatesuses Rayon parallel iteration overdata, soTmust beSend + Sync. Making this explicit in the signature avoids confusing trait-bound errors at call sites and documents the thread-safety requirement.
This issue also appears on line 220 of the same file.
where
T: VectorRepr + 'static,
{
diskann-pipnn/src/leaf_build/tests.rs:35
- This test helper calls
build_leaf_candidates, which (via Rayon) requiresT: Send + Sync. Add the bounds here so the test continues to compile once the production signature is tightened.
This issue also appears on line 151 of the same file.
where
T: diskann::utils::VectorRepr + 'static,
{
f642204 to
b1181d6
Compare
…eaves 300 seeded 16-dimensional points in 40 overlapping leaves of 1 to 64 points, k = 2, one thread. An f64 brute-force oracle that does not use the leaf kernel gives the expected edges; the fixture keeps a single-point leaf, a two-point leaf and a pair found in two leaves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No caller relies on the order inside a pruned list or an assignment row, so the tests compare sets. The within-degree prune test now uses a list that a prune would shorten, so it shows that the prune was skipped. The stripe reuse test uses a second stripe whose leader sets differ from the stale prefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unit tests use 8-point assignment stripes, so 20 points on a line cover two full stripes, a tail and a second level. The test asserts the leaves as literals and drops the generated snapshot file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At the same point-repeat budget, a fanout schedule matches the recall of extra replicas and builds faster, so one partition per build is enough. Parallel grouping saved about 1% of a BigANN-10M build; group serially. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
From a high level architectural perspective, the design here forces monomorphization of "glue level" code based on the architecture type, the metric type, and the data type.
Almost all of these can be moved behind an enum and/or trait objects I think without really sacrificing anything (see my comment in leaf_build.rs). Granted, doing this in the leaf_build stage is a little easier than in the partitioning stage due to the associated type on PartitionMetric. But even in that context, there are coarsed grained operations on concrete types that live behind a generic - meaning it's a good candidate for type erasure.
My other large architectural concern for this review pass is the state of configuration. Having configuration object dedicated solely to the algorithm (and not pulling in an arbitrary subset of graph::Config) will make this code far easier to maintain and use independently from the graph code.
# Conflicts: # diskann/src/graph/pipnn/mod.rs
Read in this order: mod.rs (config, build_graph), kernels.rs, conversion.rs, then partitioning.rs, leaf_build.rs, finalization.rs, then the kernel files, then rowmajor.rs and Cargo.toml. Port to the rowmajor views API. main moved diskann-utils views to views::rowmajor: MatrixView is rowmajor::Ref, MutMatrixView is rowmajor::Mut, the owned Matrix is rowmajor::Owned, and the row accessors live on the Matrix and MatrixMut traits. Configuration. PiPNNConfig gets private fields and a Builder that checks every invariant once, so the build steps rely on the configuration instead of a validate call higher in the stack. The builder owns the four prune parameters that PiPNN reads from graph::Config (degree, alpha, max_occlusion_size as NonZeroU16, prune_kind); Builder::from_graph copies them. The metric moves into the configuration, and the f32 row length comes from VectorRepr::full_dimension of the first row. Thread pool. PiPNNBuildContext and its &rayon::ThreadPool go away. The build runs as Rayon jobs in the pool of the caller: an enclosing ThreadPool::install, or else the global pool. Type erasure. build_graph takes the dataset as a Dataset trait object: rows gathered as f32 for the partition and leaf steps, and the distance between two rows for final pruning. Rows<T> implements it for a matrix of one Element type; Element selects each type's row conversion, and f16 overrides it with the run-time FP16 dispatch that the TypeId branch used to select. kernels::select runs the diskann-wide dispatch once per build and boxes the leaf and partition kernels of the metric behind the Kernels and LeaderSet objects; a call covers one stripe or leaf. No build step carries the architecture, metric or element type as a generic. Scratch. rowmajor::Buffer is a Vec-backed Matrix/MatrixMut whose shape changes in place and whose allocation only grows. The kernel workspaces and the leaf and stripe job buffers use it instead of a Vec plus a hand-cut prefix view, and the distance_scratch helper goes away. Logging. The three info_span! scopes become one timed tracked_debug! event per build step, and the max_depth warning uses tracked_warn! without a structured field, which the no-tracing fallback cannot format. The pipnn feature no longer forces tracing.
Group the build parameters by the step that reads them. `Config` now holds a private `PartitionConfig`, the leaf `k`, and a private `PruneConfig`; `partition` and `prune_overfull` take their own group. The public `Builder` and the `Config` getters stay flat. The sampling seed is a plain `u64` with a fixed default. A caller that wants fresh leaves on each build passes a random value. `partition` owns the level loop: it reads the fanout of each level, splits the pending clusters, and cuts the clusters that are still pending after `max_depth` levels. `Cluster` no longer carries a level and `ClusterSplit` no longer counts cut points. `split_cluster` only splits. Tests: the medoid helper uses `ComputeMedoid`; one helper builds, indexes and asserts a SIFT case; the vector-type test drops the f32 arm that the metric test already runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`Builder` keeps the seed as `Option<u64>` and `build` resolves it once: the given seed, or a random one. `Config::seed` returns the resolved value, so a caller can log it to repeat a build. A default build no longer samples the same leaves every time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The library build no longer uses `VectorRepr` in `pipnn/mod.rs`, so the crate-level import failed `-D warnings` in the clippy-features and docs jobs. Only the construction tests call `f32::distance`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The SDE jobs run the `diskann` tests with all features, which now read the SIFT fixtures in `test_data/sift` through LFS, as the other test jobs do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`add_leaf_candidates` computes the effective `k` and returns early for a leaf without pairs; `LeafBuffers::prepare` only shapes storage, and now also clears the edge lists. The leaf loop consumes the leaves instead of borrowing them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This reverts commit 535a213.
The two end-to-end PiPNN tests read the SIFT fixtures from LFS, which the SDE jobs do not check out. The PiPNN kernel tests still run under SDE and cover the SIMD paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
Thanks! Love to see the consolidation into well defined trait boundaries. I think others have reviewed the mechanics of the algorithm enough, I'm limiting my reviews to the higher level components and user facing side.
My last requests are in two buckets:
- Please make
rowmajor::Buffera little more general purpose so it can be used more readily in other places (and rename torowmajor::Resizeable). - I have some suggestions for how to decouple the implementation from rigid use of
diskann_vector::distance::Metricwithout necessarily requiring a whole slew of user facing traits. I think it would be a good idea to do this preemptively so we can be more easily experiment with different kernel types in the future.
- `rowmajor::Buffer` becomes `rowmajor::Resizable`, a general resizable matrix: `resize` (needs only `T: Default`), `resize_with` for any `T`, `with_capacity`, `capacity`, `clear`, `shrink_to_fit`, conversions to and from `Owned`, and `PartialEq`. The values after a resize are unspecified, so the implementation may reuse storage freely. - `Element` no longer extends `VectorRepr`. It asks only for what PiPNN reads: the converted row length, a row conversion, and a distance. `f32`, `u8` and `i8` share one macro and never fail; a compressed type such as MinMax returns its decode errors from `convert`. `f16` keeps its batched runtime-dispatched gather. - The build steps read rows through a private `inner::Dataset` that an opaque `Backend` holds, following `into_backend` in diskann-inmem. The inner trait hands out the kernels and the prune kind instead of a `Metric`, so `Metric` stays at the public boundary. `Dataset` gains `Debug` and a provided `backend()`. `build_graph` takes any `IntoBackend`: a `&Dataset` or a `Backend`. - Public docs describe the contract, including the documented panics for out-of-range IDs and wrong output shapes; implementation notes moved to comments. The lane-count check in `topk` is now a compile-time assert. - The SDE jobs check out the LFS fixtures instead of skipping the SIFT tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Add PiPNN graph construction under
diskann::graph::pipnn, behind the opt-indiskann/pipnnfeature.build_graphborrows one contiguousMatrixViewoff32,f16,u8ori8vectors and returns one adjacency list for each point, in three steps. The build context carries thef32row length, which differs from the stored row length for compressed types such as MinMax.c_maxpoints with the partition kernel from PiPNN 2/6: add numerical kernels #1287.leaf_knearest neighbors of each point inside each leaf with the leaf kernel from PiPNN 2/6: add numerical kernels #1287, and merge the pairs into one candidate list per point.The core does not load providers, select start points or serialize indexes. This is part 3 of the PiPNN stack; #1291 integrates it into the disk index build. Review in pipeline order:
mod.rs,partitioning.rs,leaf_build.rs,finalization.rs.Design
Take the graph policy from the DiskANN
Config. Degree, alpha,max_occlusion_sizeand the prune kind come fromConfig, as in Vamana.PiPNNConfigholds the partition and leaf parameters, withNonZeroUsizecounts and aPiPNNConfigErrorfromvalidate.Dispatch once per build.
build_graphselects the SIMD architecture and the metric, so every step compiles for one of each.Split clusters one level at a time. A split samples
ceil(p_samp * n)leaders, clamped to [2,max_leaders] (default 1000, as in the paper), assigns each point to itsfanoutnearest leaders, and groups the points by leader. Children of at mostc_maxpoints become leaves. Each split seeds its children from its own random stream, and children keep the input order of their points, so the leaves do not depend on the thread count.End partitioning at
max_depth. A level whose fanout is at least the leader count would copy the cluster into every child, so the cluster moves to the next level unsplit. A cluster that is still larger thanc_maxatmax_depth(default 30), for example copies of one vector, is cut into equal leaves in point order, and the build logs one warning. Small leaves are kept as they are: on BigANN-10M, merging leaves below 64 points changed recall@10 by at most 0.0006, the run-to-run spread, and cost 3.7% to 10.9% of build time.Assign points in stripes of 256. A stripe gathers its points as
f32and ranks them against all leaders in one kernel call. Sweeps on four CPU families kept this height within 1.4% of the best on average. A cache-size rule did no better, because a VM reports a last-level cache that other tenants share.Convert
f16with the instructions of the running CPU. Crates that depend ondiskanndo not inherit itsx86-64-v3target flag, so the genericVectorRepr::as_f32_intoconvertsf16in software for them, which made gathers 4.6 to 22 times slower on an EPYC 7763.Lock each candidate list once per leaf. A leaf job collects its pairs in both directions locally, then appends them to each point's shared list under one lock. The lists stay unsorted, because final pruning ranks candidates by distance; sorting them cost 10 to 12 s of serial time per BigANN-10M build with no recall change. List order follows job completion and only breaks distance ties, so leaves are reproducible and the graph is not.
Prune only overfull lists. A longer list is ranked by full-precision distance to its source, cut to the
max_occlusion_sizenearest candidates as in Vamana, and pruned with the shared RobustPrune.How the build works, and the branches that tests cover
build_graphbuilds the graph in three steps:Kernels, top-k and RobustPrune are lower layers with their own tests.
build_graphhas an end-to-end test on the 256-point SIFT sample.build_graph(mod.rs)Partition (
partitioning.rs)A split samples some points as leaders and puts each point into the groups of its
fanoutnearest leaders. A point is in several groups, so groups overlap. A group of at mostc_maxpoints is a leaf; a larger group is split again at the next level.fanout[level]sets the fanout of each level, andmax_depthlimits the number of levels.partition: splits the dataset level by level until every group is a leaf.c_max: one leaf with every point, no split.split_cluster: one split of one group.fanout: fanout 1, so a point joins only one group.ceil(points * p_samp), at least 2 and at mostmax_leaders.max_depth: the group is cut into equal leaves in point order, andpartitionlogs one warning. A group can stay larger thanc_maxwhen its points are copies of one vector, or under inner product when one leader wins every point.assign_to_leaders: finds thefanoutnearest leaders of each point, in batches (stripes) of 256 points. Unit tests use stripes of 8.group_by_leader: turns each point's leader list into one point group per leader.Leaf build (
leaf_build.rs)In each leaf, each point picks its
leaf_knearest points of that leaf, and each picked pair becomes an edge in both directions. A point is in several leaves, so its edges from all leaves merge into one candidate list.add_leaf_candidates: one leaf.leaf_kat or above the leaf size: each point picks every other point.Finalization (
finalization.rs)Merged lists can hold more candidates than the graph degree. Vamana RobustPrune cuts such a list: it keeps the nearest candidate first, and drops a candidate when a kept neighbor is more than
alphatimes nearer to it than the source is.PruneWorkspace::prune: one list.max_occlusion_sizenearest candidates, then runs RobustPrune.prune_overfull: every list. Each result stays with its source point.Conversion (
conversion.rs)The kernels compute in
f32.gather_as_f32copies the rows of one stripe or leaf into onef32matrix. Thef32row length comes from the build context: compressed types such as MinMax store more elements per row.f16: uses the CPU's FP16 instructions. Other types useVectorRepr::as_f32_into.