Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
cba793c
pipnn: add HashPrune candidate merging
SeliMeli Jul 29, 2026
2caf2cd
build: configure HashPrune integration
SeliMeli Jul 29, 2026
2b087cb
refactor(pipnn): dispatch HashPrune through diskann-wide
SeliMeli Jul 29, 2026
fe64af4
perf(pipnn): move unique HashPrune rows into adjacency
SeliMeli Jul 29, 2026
fd40648
fix(pipnn): validate HashPrune capacity against graph degree
SeliMeli Jul 29, 2026
75f570e
refactor(pipnn): scope extraction scratch to Rayon jobs
SeliMeli Jul 29, 2026
fa1604e
fix(pipnn): validate HashPrune hash-space capacity
SeliMeli Jul 29, 2026
4454b8d
perf(pipnn): retain HashPrune sketch scratch capacity
SeliMeli Jul 30, 2026
2a92960
docs(pipnn): map HashPrune reservoir flow
SeliMeli Jul 31, 2026
d721ebe
test(pipnn): exercise sketch scratch reuse
SeliMeli Jul 31, 2026
9d8f8fc
test(disk): adapt PiPNN adapter mismatch case
SeliMeli Jul 31, 2026
2d8149e
fix(pipnn): make HashPrune history independent
SeliMeli Aug 3, 2026
59449aa
docs(pipnn): name point reservoirs
SeliMeli Aug 3, 2026
d4fa0cd
bench(pipnn): add IAI candidate-merge scenarios
SeliMeli Aug 5, 2026
161166c
fix(pipnn): keep HashPrune explicitly opt-in
SeliMeli Aug 6, 2026
341c2b2
ci(pipnn): gate unsafe pointer boundaries
SeliMeli Aug 6, 2026
35111de
test(pipnn): colocate HashPrune coverage
SeliMeli Aug 6, 2026
983385b
fix(pipnn): satisfy Windows FFI lint
SeliMeli Aug 6, 2026
d77bda5
docs(pipnn): make unsafe proofs local
SeliMeli Aug 7, 2026
2f84225
refactor(pipnn): remove unused wrapper layers
SeliMeli Aug 7, 2026
69e7be1
docs(pipnn): simplify HashPrune comments
SeliMeli Aug 7, 2026
0211872
fix(pipnn): reject invalid HashPrune leaf input
SeliMeli Aug 7, 2026
68aab1e
refactor(pipnn): return HashPrune ingestion errors
SeliMeli Aug 7, 2026
e8dbbca
docs(pipnn): state core HashPrune contracts
SeliMeli Aug 7, 2026
18c4ade
refactor(pipnn): use domain names in HashPrune flow
SeliMeli Aug 7, 2026
c52a822
docs(pipnn): describe HashPrune domain actions
SeliMeli Aug 7, 2026
a44e57d
refactor(pipnn): inline hash dispatch selection
SeliMeli Aug 10, 2026
6cd096c
refactor(pipnn): remove dead slab methods
SeliMeli Aug 10, 2026
2cb0e89
test(pipnn): use named leaf width
SeliMeli Aug 10, 2026
f07074b
fix(pipnn): gate trusted adjacency transfer
SeliMeli Aug 10, 2026
d6aa9fa
test(pipnn): make slab check portable
SeliMeli Aug 10, 2026
58b1d4a
refactor(pipnn): bind hash leaves to leaf metrics
SeliMeli Aug 10, 2026
1f4f84a
refactor(pipnn): use leaf metric policy in hash path
SeliMeli Aug 11, 2026
4730149
refactor(pipnn): trust HashPrune ingestion
SeliMeli Aug 14, 2026
cfd25f6
fix(pipnn): omit unrankable HashPrune edges
SeliMeli Aug 18, 2026
9ab1ed4
perf(pipnn): defer HashPrune edge bitmap
SeliMeli Aug 19, 2026
493fe7c
perf(pipnn): reuse HashPrune CSR buffers
SeliMeli Aug 19, 2026
19b121a
refactor(pipnn): apply SIMD schema to HashPrune
SeliMeli Aug 20, 2026
3cb91bd
test(pipnn): clarify HashPrune contracts
SeliMeli Aug 21, 2026
7d256de
test(pipnn): use semantic integration fixtures
SeliMeli Aug 24, 2026
02eac18
refactor(pipnn): use shared SIMD vector in hash
SeliMeli Aug 24, 2026
f62d01d
test(pipnn): name zero-boundary inputs
SeliMeli Aug 24, 2026
a6172eb
ci(pipnn): follow nightly Miri policy
SeliMeli Aug 25, 2026
2bdfdf9
refactor(pipnn): adopt metric-owned buffers
SeliMeli Sep 4, 2026
0842035
Merge batched core and adapt HashPrune schema
SeliMeli Sep 14, 2026
0b10d2a
perf(pipnn): streamline HashPrune candidate updates
SeliMeli Sep 14, 2026
143b8bd
Merge reviewed PiPNN kernel tests into HashPrune
SeliMeli Sep 17, 2026
9ea5d15
Merge TopK visitor into PR6
SeliMeli Sep 18, 2026
11b074a
Merge fast PiPNN norms into PR6
SeliMeli Sep 18, 2026
2957624
Merge shared GEMM test helper into PR6
SeliMeli Sep 18, 2026
048ec71
Merge focused GEMM cases into PR6
SeliMeli Sep 18, 2026
89b1f52
Keep matrix view hardening outside PR6
SeliMeli Sep 18, 2026
0531c50
Sync PR2 Clippy fix through PR6
SeliMeli Sep 18, 2026
2bb813b
Sync rewritten PiPNN core tests through PR6
SeliMeli Sep 21, 2026
2b83feb
Sync PiPNN partition coverage through PR6
SeliMeli Sep 21, 2026
1400968
Sync NEON tracking cleanup through PR6
SeliMeli Sep 21, 2026
f2ae505
merge(pipnn): carry review fixes into HashPrune
SeliMeli Sep 22, 2026
bb35f0b
merge(pipnn): carry top-k simplification into HashPrune
SeliMeli Sep 23, 2026
2584ebb
merge(pipnn): carry debug-only top-k checks into HashPrune
SeliMeli Sep 23, 2026
4f849b7
merge(pipnn): catch HashPrune up with main through benchmarks
SeliMeli Sep 24, 2026
7791e3f
merge(pipnn): catch HashPrune up with core review changes
SeliMeli Sep 28, 2026
18b234d
merge(pipnn): catch HashPrune up with the integration import fix
SeliMeli Sep 28, 2026
c0b294f
merge(pipnn): catch HashPrune up with the unsorted leaf candidates
SeliMeli Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,12 @@ jobs:
cargo +nightly miri test --locked -p diskann --features pipnn --lib -- \
graph::pipnn::topk::tests::selection_returns_distinct_candidates_when_distances_tie \
graph::pipnn::topk::tests::a_single_pair_updates_exactly_its_two_endpoints::case_1_finite

- name: PiPNN HashPrune pointer boundaries
env:
MIRIFLAGS: -Zmiri-disable-isolation -Zmiri-strict-provenance
run: |
cargo +nightly miri test --locked -p diskann --features pipnn --lib \
graph::pipnn::lsh::tests
cargo +nightly miri test --locked -p diskann --features pipnn --lib \
graph::pipnn::hash_prune::tests
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion diskann-benchmark/src/index/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,15 @@ where

let started = std::time::Instant::now();
let adjacency = {
let context = diskann::graph::pipnn::PiPNNBuildContext::new(
let mut context = diskann::graph::pipnn::PiPNNBuildContext::new(
parameters.into(),
&graph,
metric,
&pool,
)?;
if let Some(hash_prune) = &parameters.hash_prune {
context = context.with_hash_prune(hash_prune.into())?;
}
diskann::graph::pipnn::build_graph(data.as_view(), &context)?
};
let start_points = input
Expand Down
12 changes: 8 additions & 4 deletions diskann-disk/src/build/builder/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ where
index_writer: DiskIndexWriter,
) -> ANNResult<Self> {
#[cfg(feature = "pipnn")]
if let Some(config) = disk_build_param.pipnn_config() {
config.validate()?;
if let Some(parameters) = disk_build_param.pipnn_parameters() {
diskann::graph::pipnn::PiPNNConfig::from(parameters).validate()?;
if let Some(hash_prune) = &parameters.hash_prune {
diskann::graph::pipnn::HashPruneConfig::from(hash_prune)
.validate(index_configuration.config.pruned_degree().get())?;
}
}

let pq_storage = PQStorage::new(
Expand Down Expand Up @@ -184,8 +188,8 @@ where

async fn build_graph(&mut self, pool: RayonThreadPoolRef<'_>) -> ANNResult<()> {
#[cfg(feature = "pipnn")]
if let Some(config) = self.disk_build_param.pipnn_config() {
return pipnn::build_graph(self, pool, config);
if let Some(parameters) = self.disk_build_param.pipnn_parameters().cloned() {
return pipnn::build_graph(self, pool, &parameters);
}

match determine_build_strategy::<Data>(
Expand Down
48 changes: 41 additions & 7 deletions diskann-disk/src/build/builder/build/pipnn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//!
//! PiPNN and Vamana use the same disk graph format.

use diskann::graph::pipnn::{PiPNNBuildContext, PiPNNConfig};
use diskann::graph::pipnn::PiPNNBuildContext;
use diskann::{utils::VectorRepr, ANNError, ANNResult};
use diskann_providers::{
storage::{save_adjacency_graph, StorageReadProvider, StorageWriteProvider},
Expand All @@ -20,13 +20,13 @@ use diskann_providers::{
use diskann_utils::io::{read_bin, Metadata};

use super::{u32_try_from, DiskIndexBuilder};
use crate::data_model::GraphDataType;
use crate::{data_model::GraphDataType, PiPNNParameters};

/// Build PiPNN adjacency and persist it through the canonical disk graph writer.
pub(super) fn build_graph<Data, StorageProvider>(
builder: &DiskIndexBuilder<'_, Data, StorageProvider>,
pool: RayonThreadPoolRef<'_>,
config: PiPNNConfig,
parameters: &PiPNNParameters,
) -> ANNResult<()>
where
Data: GraphDataType<VectorIdType = u32>,
Expand Down Expand Up @@ -55,12 +55,15 @@ where
// supplied Rayon pool.
let data =
read_bin::<Data::VectorDataType>(&mut builder.storage_provider.open_reader(&data_path)?)?;
let context = PiPNNBuildContext::new(
config,
let mut context = PiPNNBuildContext::new(
parameters.into(),
&builder.index_configuration.config,
builder.index_configuration.dist_metric,
pool.as_rayon(),
)?;
if let Some(hash_prune) = &parameters.hash_prune {
context = context.with_hash_prune(hash_prune.into())?;
}
let adjacency = diskann::graph::pipnn::build_graph(data.as_view(), &context)?;

// The disk header requires a start point. Use the same sampled medoid policy
Expand Down Expand Up @@ -116,6 +119,7 @@ mod tests {
fanout: vec![nz(10), nz(3)],
k: nz(2),
replicas: nz(1),
hash_prune: Some(crate::HashPruneParameters::default()),
}
}

Expand Down Expand Up @@ -199,7 +203,7 @@ mod tests {
let builder = builder(&storage, 3, 8, 1.0, 1.2, parameters.clone());
let pool = create_thread_pool(1).unwrap();

let error = super::build_graph(&builder, pool.as_ref(), (&parameters).into()).unwrap_err();
let error = super::build_graph(&builder, pool.as_ref(), &parameters).unwrap_err();
assert!(format!("{error:?}").contains("configured point count 3"));
assert!(!storage.exists(&builder.index_writer.get_mem_index_file()));
}
Expand All @@ -213,7 +217,7 @@ mod tests {
let builder = builder(&storage, points, dimensions, 1.0, 1.2, parameters.clone());
let pool = create_thread_pool(1).unwrap();

super::build_graph(&builder, pool.as_ref(), (&parameters).into()).unwrap();
super::build_graph(&builder, pool.as_ref(), &parameters).unwrap();

let mut header = [0_u8; 24];
std::io::Read::read_exact(
Expand Down Expand Up @@ -274,4 +278,34 @@ mod tests {

assert!(format!("{error:?}").contains("p_samp (0) must be in (0, 1]"));
}

#[test]
fn builder_rejects_hash_prune_capacity_before_quantizer_artifacts() {
let storage = VirtualStorageProvider::new_memory();
let parameters = PiPNNParameters {
hash_prune: Some(crate::HashPruneParameters {
num_hash_planes: 12,
l_max: 16,
final_prune: true,
}),
..PiPNNParameters::default()
};
let params = DiskIndexBuildParameters::new_pipnn(
MemoryBudget::try_from_gb(1.0).unwrap(),
NumPQChunks::new_with(1, 1).unwrap(),
parameters,
);
let config = IndexConfiguration::new(Metric::L2, 1, 1, ONE, 1, graph_config(32, 1.2));
let writer =
DiskIndexWriter::new("/data.fbin".into(), "/index".into(), None, 4096).unwrap();

let error = match DiskIndexBuilder::<AdHoc<f32>, _>::new(&storage, params, config, writer) {
Ok(_) => panic!("HashPrune capacity below graph degree must be rejected"),
Err(error) => error,
};

assert!(format!("{error:?}").contains("must be at least the graph degree (32)"));
assert!(!storage.exists("/index_pq_pivots.bin"));
assert!(!storage.exists("/index_pq_compressed.bin"));
}
}
47 changes: 47 additions & 0 deletions diskann-disk/src/build/configuration/build_algorithm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,43 @@ pub struct PiPNNParameters {
pub k: NonZeroUsize,
/// Number of independent partition passes.
pub replicas: NonZeroUsize,
/// HashPrune policy. `None` keeps all unique direct candidates.
pub hash_prune: Option<HashPruneParameters>,
}

/// HashPrune parameters in the JSON build configuration.
#[cfg(feature = "pipnn")]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HashPruneParameters {
/// Number of random-hyperplane sketch dimensions.
pub num_hash_planes: usize,
/// Maximum number of candidates retained per point.
pub l_max: usize,
/// Apply Vamana RobustPrune after reservoir extraction.
pub final_prune: bool,
}

#[cfg(feature = "pipnn")]
impl Default for HashPruneParameters {
fn default() -> Self {
Self {
num_hash_planes: 12,
l_max: 64,
final_prune: true,
}
}
}

#[cfg(feature = "pipnn")]
impl From<&HashPruneParameters> for diskann::graph::pipnn::HashPruneConfig {
fn from(config: &HashPruneParameters) -> Self {
Self {
num_hash_planes: config.num_hash_planes,
l_max: config.l_max,
final_prune: config.final_prune,
}
}
}

#[cfg(feature = "pipnn")]
Expand All @@ -44,6 +81,7 @@ impl Default for PiPNNParameters {
fanout: FANOUT.to_vec(),
k: K,
replicas: NonZeroUsize::MIN,
hash_prune: None,
}
}
}
Expand Down Expand Up @@ -119,6 +157,15 @@ mod tests {
);
assert_eq!(config.k.get(), 3);
assert_eq!(config.replicas.get(), 1);
assert_eq!(config.hash_prune, None);

let explicit: BuildAlgorithm =
serde_json::from_str(r#"{"algorithm":"PiPNN","hash_prune":{}}"#).unwrap();
let BuildAlgorithm::PiPNN(explicit) = explicit else {
panic!("expected PiPNN");
};
assert_eq!(explicit.hash_prune, Some(HashPruneParameters::default()));

assert!(
serde_json::from_str::<BuildAlgorithm>(r#"{"algorithm":"PiPNN","l_max":72}"#).is_err()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ impl DiskIndexBuildParameters {
}

#[cfg(feature = "pipnn")]
pub(crate) fn pipnn_config(&self) -> Option<diskann::graph::pipnn::PiPNNConfig> {
pub(crate) fn pipnn_parameters(&self) -> Option<&PiPNNParameters> {
match &self.build_algorithm {
BuildAlgorithm::PiPNN(config) => Some(config.into()),
BuildAlgorithm::PiPNN(config) => Some(config),
BuildAlgorithm::Vamana => None,
}
}
Expand Down
2 changes: 1 addition & 1 deletion diskann-disk/src/build/configuration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
pub mod build_algorithm;
pub use build_algorithm::BuildAlgorithm;
#[cfg(feature = "pipnn")]
pub use build_algorithm::PiPNNParameters;
pub use build_algorithm::{HashPruneParameters, PiPNNParameters};

pub mod disk_index_build_parameter;
pub use disk_index_build_parameter::{DiskIndexBuildParameters, MemoryBudget, NumPQChunks};
Expand Down
4 changes: 2 additions & 2 deletions diskann-disk/src/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ pub mod builder;
pub mod configuration;

// Re-export key types for convenience
#[cfg(feature = "pipnn")]
pub use configuration::PiPNNParameters;
pub use configuration::{
disk_index_build_parameter, filter_parameter, BuildAlgorithm, DiskIndexBuildParameters,
QuantizationType, SphericalBits,
};
#[cfg(feature = "pipnn")]
pub use configuration::{HashPruneParameters, PiPNNParameters};
4 changes: 2 additions & 2 deletions diskann-disk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ pub(crate) mod test_utils;
pub mod error;

pub mod build;
#[cfg(feature = "pipnn")]
pub use build::PiPNNParameters;
pub use build::{
disk_index_build_parameter, filter_parameter, BuildAlgorithm, DiskIndexBuildParameters,
QuantizationType, SphericalBits,
};
#[cfg(feature = "pipnn")]
pub use build::{HashPruneParameters, PiPNNParameters};

pub mod data_model;
pub mod search;
Expand Down
54 changes: 37 additions & 17 deletions diskann-vector/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,17 @@ pub mod distance;
pub mod norm;

cfg_if::cfg_if! {
if #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))] {
// x86-64 guarantees SSE2; `_mm_prefetch` needs only SSE.
if #[cfg(target_arch = "x86_64")] {
const CACHE_LINE_SIZE: usize = 64;

#[inline(always)]
unsafe fn prefetch_exactly<const N: usize>(ptr: *const i8) {
use std::arch::x86_64::*;
for i in 0..N {
_mm_prefetch(ptr.add(i * CACHE_LINE_SIZE), _MM_HINT_T0);
// SAFETY: the caller guarantees that all `N` computed addresses are
// inside the allocation.
unsafe { _mm_prefetch(ptr.add(i * CACHE_LINE_SIZE), _MM_HINT_T0) };
}
}

Expand All @@ -56,7 +59,8 @@ cfg_if::cfg_if! {
if CACHE_LINE_SIZE * i >= bytes {
break;
}
_mm_prefetch(ptr.add(i * CACHE_LINE_SIZE), _MM_HINT_T0);
// SAFETY: the loop uses only offsets below `bytes`.
unsafe { _mm_prefetch(ptr.add(i * CACHE_LINE_SIZE), _MM_HINT_T0) };
}
}

Expand All @@ -66,32 +70,48 @@ cfg_if::cfg_if! {
pub fn prefetch_hint_max<const MAX_CACHE_LINES: usize, T>(vec: &[T]) {
let vecsize = std::mem::size_of_val(vec);
if vecsize >= MAX_CACHE_LINES * 64 {
// SAFETY: Pointer is in-bounds and use of the intrinsic is cfg gated.
// SAFETY: the slice contains every address passed to prefetch.
unsafe { prefetch_exactly::<MAX_CACHE_LINES>(vec.as_ptr().cast()) }
} else {
// SAFETY: Pointer is in-bounds and use of the intrinsic is cfg gated.
// SAFETY: the slice covers `vecsize` bytes.
unsafe { prefetch_at_most::<MAX_CACHE_LINES>(vec.as_ptr().cast(), vecsize) }
}
}

/// Prefetch a raw byte range without creating a slice.
///
/// # Safety
///
/// `ptr` must identify an allocation of at least `bytes` bytes. The allocation
/// must remain live for this call. The function creates no Rust reference.
/// The caller controls concurrent mutation of the range.
#[inline]
pub unsafe fn prefetch_hint_all_raw(ptr: *const u8, bytes: usize) {
use std::arch::x86_64::*;

for offset in (0..bytes).step_by(CACHE_LINE_SIZE) {
// SAFETY: the caller guarantees the byte range, and `offset < bytes`.
unsafe { _mm_prefetch(ptr.add(offset).cast(), _MM_HINT_T0) };
}
}

/// Prefetch the given vector in chunks of 64 bytes, which is a cache line size.
/// The entire vector will be prefetched.
#[inline]
pub fn prefetch_hint_all<T>(vec: &[T]) {
use std::arch::x86_64::*;

let vecsize = std::mem::size_of_val(vec);
let num_prefetch_blocks = vecsize.div_ceil(64);
let vec_ptr = vec.as_ptr() as *const i8;
for d in 0..num_prefetch_blocks {
// SAFETY: Pointer is in-bounds and use of the intrinsic is gated by the
// `cfg`-guard on this function.
unsafe {
std::arch::x86_64::_mm_prefetch(vec_ptr.add(d * CACHE_LINE_SIZE), _MM_HINT_T0);
}
} }
// SAFETY: the slice remains live and covers exactly `size_of_val(vec)` bytes.
unsafe { prefetch_hint_all_raw(vec.as_ptr().cast(), std::mem::size_of_val(vec)) }
}
} else {
pub fn prefetch_hint_max<const MAX_CACHE_LINES: usize, T>(_vec: &[T]) {}

/// Accept a raw prefetch range and do nothing.
///
/// # Safety
///
/// The pointer contract is the same as the x86-64 implementation.
pub unsafe fn prefetch_hint_all_raw(_ptr: *const u8, _bytes: usize) {}

pub fn prefetch_hint_all<T>(_vec: &[T]) {}
}
}
Expand Down
Loading
Loading