blob: 4b2cb95f1f843bfc6a81c3c40b0ca75f57808191 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::distance::{preprocess_vectors, MetricType};
use crate::kmeans::KMeansConfig;
use crate::pq::ProductQuantizer;
use crate::vamana::{
estimate_sharded_vamana_memory_bytes, estimate_vamana_memory_bytes, VamanaGraph,
};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::borrow::Cow;
use std::collections::VecDeque;
use std::io;
use std::time::{Duration, Instant};
pub(crate) const DISKANN_ADJACENCY_LOCATOR_NODE_BYTES: usize = 4;
pub(crate) const DISKANN_ADJACENCY_LOCATOR_BLOCK_NODES: usize = 16;
/// Match the proven DiskANN training bound: more samples materially increase
/// memory and training time without consistently improving the codebook.
pub const DISKANN_MAX_PQ_TRAINING_VECTORS: usize = 50_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiskAnnStorageLayout {
/// Keep compressed adjacency pages and dense raw-vector records in separate sections.
Compact,
/// Store each raw vector immediately before its compressed adjacency list.
Interleaved,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
pub enum DiskAnnRawVectorEncoding {
/// Preserve indexed vectors and final distances as little-endian `f32`.
F32 = 1,
/// Store little-endian IEEE 754 binary16 values for approximate final reranking.
F16 = 2,
}
impl DiskAnnRawVectorEncoding {
pub(crate) const fn element_size(self) -> usize {
match self {
Self::F32 => size_of::<f32>(),
Self::F16 => size_of::<u16>(),
}
}
pub(crate) const fn from_code(code: u32) -> Option<Self> {
match code {
1 => Some(Self::F32),
2 => Some(Self::F16),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiskAnnBuildDistance {
/// Use full-precision distances for graph traversal and robust pruning.
FullPrecision,
/// Use PQ distances for graph traversal and full precision for robust pruning.
ProductQuantized,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DiskAnnBuildParams {
pub max_degree: usize,
pub build_search_list_size: usize,
pub alpha: f32,
pub seed: u64,
pub memory_budget_bytes: usize,
pub storage_layout: DiskAnnStorageLayout,
pub raw_vector_encoding: DiskAnnRawVectorEncoding,
pub build_distance: DiskAnnBuildDistance,
}
impl Default for DiskAnnBuildParams {
fn default() -> Self {
Self {
max_degree: 64,
build_search_list_size: 100,
alpha: 1.2,
seed: 42,
memory_budget_bytes: 8 * 1024 * 1024 * 1024,
storage_layout: DiskAnnStorageLayout::Compact,
raw_vector_encoding: DiskAnnRawVectorEncoding::F16,
build_distance: DiskAnnBuildDistance::ProductQuantized,
}
}
}
pub(crate) fn validate_diskann_format_configuration(
dimension: usize,
pq_m: usize,
pq_bits: usize,
build: DiskAnnBuildParams,
) -> io::Result<()> {
if dimension == 0 {
return Err(invalid_input("DiskANN dimension must be greater than 0"));
}
if dimension > 1024 {
return Err(invalid_input("DiskANN v1 dimension must be at most 1024"));
}
if pq_m == 0 {
return Err(invalid_input("DiskANN pq.m must be greater than 0"));
}
if pq_m > dimension {
return Err(invalid_input(format!(
"DiskANN pq.m {} must not exceed dimension {}",
pq_m, dimension
)));
}
if !matches!(pq_bits, 4 | 8) {
return Err(invalid_input("DiskANN pq.bits must be 4 or 8"));
}
if build.max_degree == 0 {
return Err(invalid_input(
"DiskANN maximum degree must be greater than 0",
));
}
if build.max_degree > 1023 {
return Err(invalid_input(format!(
"DiskANN adjacency list size {} exceeds the v1 1023-neighbor page limit",
build.max_degree.saturating_mul(size_of::<u32>())
)));
}
if build.build_search_list_size < build.max_degree {
return Err(invalid_input(format!(
"DiskANN build search-list size {} must be at least maximum degree {}",
build.build_search_list_size, build.max_degree
)));
}
if u32::try_from(build.build_search_list_size).is_err() {
return Err(invalid_input("DiskANN build search-list size exceeds u32"));
}
if !build.alpha.is_finite() || build.alpha < 1.0 {
return Err(invalid_input("DiskANN alpha must be at least 1 and finite"));
}
let interleaved_record_bytes = dimension
.checked_mul(build.raw_vector_encoding.element_size())
.and_then(|vector_bytes| {
build
.max_degree
.checked_mul(size_of::<u32>())
.and_then(|adjacency_bytes| vector_bytes.checked_add(adjacency_bytes))
});
if build.storage_layout == DiskAnnStorageLayout::Interleaved
&& interleaved_record_bytes.is_none_or(|record_bytes| record_bytes > 4096)
{
return Err(invalid_input(
"DiskANN interleaved raw vector and maximum adjacency list must fit in one page",
));
}
Ok(())
}
pub(crate) fn validate_diskann_training_budget(
dimension: usize,
metric: MetricType,
pq_m: usize,
pq_bits: usize,
memory_budget_bytes: usize,
) -> io::Result<()> {
let minimum_training_vectors = 1usize << pq_bits;
pq_training_plan_with_sample_buffers(
dimension,
pq_m,
minimum_training_vectors,
minimum_training_vectors,
memory_budget_bytes,
usize::from(metric == MetricType::Cosine) + 1,
)
.map(|_| ())
.map_err(|error| {
invalid_input(format!(
"DiskANN memory budget cannot fit minimum PQ training: {error}"
))
})
}
pub(crate) fn diskann_training_sample_limit(
dimension: usize,
metric: MetricType,
pq_m: usize,
pq_bits: usize,
memory_budget_bytes: usize,
) -> io::Result<usize> {
pq_training_plan_with_sample_buffers(
dimension,
pq_m,
1usize << pq_bits,
DISKANN_MAX_PQ_TRAINING_VECTORS,
memory_budget_bytes,
usize::from(metric == MetricType::Cosine) + 1,
)
.map(|plan| plan.sample_count)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DiskAnnBuildStats {
/// One for the normal parallel build; greater than one when the memory
/// budget selected overlapping shard construction.
pub graph_shards: usize,
pub total: Duration,
pub pq_encoding: Duration,
pub vamana_initialization: Duration,
pub vamana_pass_one: Duration,
pub vamana_pass_two: Duration,
pub connectivity_repair: Duration,
pub locality_remap: Duration,
pub resident_serialization: Duration,
pub adjacency_serialization: Duration,
pub vector_serialization: Duration,
}
impl DiskAnnBuildStats {
pub fn accounted_duration(self) -> Duration {
[
self.pq_encoding,
self.vamana_initialization,
self.vamana_pass_one,
self.vamana_pass_two,
self.connectivity_repair,
self.locality_remap,
self.resident_serialization,
self.adjacency_serialization,
self.vector_serialization,
]
.into_iter()
.sum()
}
}
pub struct DiskAnnIndex {
pub d: usize,
pub metric: MetricType,
pub pq: ProductQuantizer,
pub build_params: DiskAnnBuildParams,
pub ids: Vec<i64>,
pub vectors: Vec<f32>,
}
impl DiskAnnIndex {
pub fn new(
d: usize,
metric: MetricType,
pq_m: usize,
build_params: DiskAnnBuildParams,
) -> Self {
Self::with_pq_bits(d, metric, pq_m, 8, build_params)
}
pub fn with_pq_bits(
d: usize,
metric: MetricType,
pq_m: usize,
pq_bits: usize,
build_params: DiskAnnBuildParams,
) -> Self {
Self {
d,
metric,
pq: ProductQuantizer::with_nbits_balanced(d, pq_m, pq_bits),
build_params,
ids: Vec::new(),
vectors: Vec::new(),
}
}
pub fn train(&mut self, data: &[f32], n: usize) -> io::Result<()> {
if n == 0 {
return Err(invalid_input(
"DiskANN training vector count must be greater than zero",
));
}
let expected_len = n
.checked_mul(self.d)
.ok_or_else(|| invalid_input("DiskANN training data length overflows usize"))?;
if data.len() != expected_len {
return Err(invalid_input(format!(
"DiskANN training data length {} does not match n * dimension {}",
data.len(),
expected_len
)));
}
let plan = self.training_plan(n)?;
let sample =
bounded_pq_training_sample(data, n, self.d, plan.sample_count, self.build_params.seed);
let sample = sample
.as_deref()
.unwrap_or(&data[..plan.sample_count.saturating_mul(self.d)]);
let processed = self.preprocess_vectors(sample, plan.sample_count);
self.pq.train_hot_start_with_parallelism(
&processed,
plan.sample_count,
&KMeansConfig::default(),
false,
plan.parallelism,
);
Ok(())
}
fn training_plan(&self, n: usize) -> io::Result<PqTrainingPlan> {
pq_training_plan_with_sample_buffers(
self.d,
self.pq.m,
self.pq.ksub,
n,
self.build_params.memory_budget_bytes,
usize::from(self.metric == MetricType::Cosine) + 1,
)
}
pub fn add(&mut self, data: &[f32], ids: &[i64]) {
self.ids.extend_from_slice(ids);
self.vectors
.extend_from_slice(self.preprocess_vectors(data, ids.len()).as_ref());
}
pub fn estimate_build_memory_bytes(&self) -> io::Result<usize> {
let n = self.ids.len();
let workers = rayon::current_num_threads().max(1);
let raw_vectors = checked_bytes(self.vectors.len(), size_of::<f32>(), "raw vectors")?;
let row_ids = checked_bytes(n, size_of::<i64>(), "row IDs")?;
let row_id_encoding_scratch = row_id_encoding_scratch_bytes(n)?;
let pq_codes = checked_bytes(n, self.pq.code_size(), "PQ codes")?;
let pq_codebook = checked_bytes(self.pq.centroids.len(), size_of::<f32>(), "PQ codebook")?;
let pq_build_distances = if self.build_params.build_distance
== DiskAnnBuildDistance::ProductQuantized
{
self.pq
.m
.checked_mul(self.pq.ksub)
.and_then(|value| value.checked_mul(self.pq.ksub))
.and_then(|value| value.checked_mul(size_of::<f32>()))
.ok_or_else(|| invalid_input("DiskANN PQ build-distance table size overflows"))?
} else {
0
};
let row_id_order = checked_bytes(n, size_of::<u32>(), "row-ID order")?;
let adjacency_index = checked_bytes(
n,
DISKANN_ADJACENCY_LOCATOR_NODE_BYTES,
"adjacency locators",
)?
.checked_add(checked_bytes(
n.div_ceil(DISKANN_ADJACENCY_LOCATOR_BLOCK_NODES),
size_of::<u64>(),
"adjacency locator block offsets",
)?)
.ok_or_else(|| invalid_input("DiskANN adjacency index size overflows usize"))?;
let vamana = estimate_vamana_memory_bytes(
n,
self.build_params.max_degree,
self.build_params.build_search_list_size,
workers,
)
.ok_or_else(|| invalid_input("DiskANN Vamana memory estimate overflows usize"))?;
let graph_stage_peak = vamana.build_peak_bytes.max(vamana.remap_peak_bytes);
[
raw_vectors,
row_ids,
row_id_encoding_scratch,
pq_codes,
pq_codebook,
pq_build_distances,
row_id_order,
adjacency_index,
graph_stage_peak,
]
.into_iter()
.try_fold(0usize, |total, value| {
total
.checked_add(value)
.ok_or_else(|| invalid_input("DiskANN memory estimate overflows usize"))
})
}
fn graph_build_shard_count(&self) -> io::Result<usize> {
let estimated = self.estimate_build_memory_bytes()?;
if estimated <= self.build_params.memory_budget_bytes {
return Ok(1);
}
let n = self.ids.len();
let workers = rayon::current_num_threads().max(1);
let vamana = estimate_vamana_memory_bytes(
n,
self.build_params.max_degree,
self.build_params.build_search_list_size,
workers,
)
.ok_or_else(|| invalid_input("DiskANN Vamana memory estimate overflows usize"))?;
let graph_peak = vamana.build_peak_bytes.max(vamana.remap_peak_bytes);
let fixed_bytes = estimated
.checked_sub(graph_peak)
.ok_or_else(|| invalid_input("DiskANN fixed memory estimate underflows"))?;
let max_shards = 64.min(n / 2);
for shard_count in 2..=max_shards {
let Some(sharded_graph) = estimate_sharded_vamana_memory_bytes(
n,
self.d,
self.build_params.max_degree.min(n.saturating_sub(1)),
shard_count,
if self.build_params.build_distance == DiskAnnBuildDistance::ProductQuantized {
self.pq.code_size()
} else {
0
},
) else {
continue;
};
if fixed_bytes
.checked_add(sharded_graph)
.is_some_and(|peak| peak <= self.build_params.memory_budget_bytes)
{
return Ok(shard_count);
}
}
Err(invalid_input(format!(
"DiskANN estimated build memory {} exceeds memory budget {}; overlapping sharded build also cannot fit",
estimated, self.build_params.memory_budget_bytes
)))
}
pub(crate) fn validate_for_write(&self) -> io::Result<()> {
validate_diskann_format_configuration(self.d, self.pq.m, self.pq.nbits, self.build_params)?;
validate_diskann_training_budget(
self.d,
self.metric,
self.pq.m,
self.pq.nbits,
self.build_params.memory_budget_bytes,
)?;
if self.build_params.memory_budget_bytes == 0 {
return Err(invalid_input(
"DiskANN memory budget must be greater than zero",
));
}
if self.ids.is_empty() || u32::try_from(self.ids.len()).is_err() {
return Err(invalid_input(
"DiskANN vector count must be between 1 and u32::MAX",
));
}
let expected_vectors = self
.ids
.len()
.checked_mul(self.d)
.ok_or_else(|| invalid_input("DiskANN vector shape overflows usize"))?;
if self.vectors.len() != expected_vectors {
return Err(invalid_input(format!(
"DiskANN vector length {} does not match {} row IDs * dimension {}",
self.vectors.len(),
self.ids.len(),
self.d
)));
}
if let Some(offset) = self.vectors.iter().position(|value| !value.is_finite()) {
return Err(invalid_input(format!(
"DiskANN vector data contains a non-finite value at offset {}",
offset
)));
}
if self.build_params.raw_vector_encoding == DiskAnnRawVectorEncoding::F16 {
if let Some(offset) = self
.vectors
.iter()
.position(|&value| !half::f16::from_f32(value).is_finite())
{
return Err(invalid_input(format!(
"DiskANN vector data at offset {} is outside the finite f16 range",
offset
)));
}
}
let expected_ksub = 1usize
.checked_shl(self.pq.nbits as u32)
.ok_or_else(|| invalid_input("DiskANN PQ centroid count overflows usize"))?;
let expected_centroids = self
.d
.checked_mul(expected_ksub)
.ok_or_else(|| invalid_input("DiskANN PQ codebook shape overflows usize"))?;
if self.pq.d != self.d
|| self.pq.ksub != expected_ksub
|| self.pq.centroids.len() != expected_centroids
|| !self.pq.has_valid_layout()
{
return Err(invalid_input("DiskANN PQ codebook shape is invalid"));
}
if let Some(offset) = self
.pq
.centroids
.iter()
.position(|value| !value.is_finite())
{
return Err(invalid_input(format!(
"DiskANN PQ codebook contains a non-finite value at offset {}",
offset
)));
}
Ok(())
}
pub(crate) fn prepare_build(&self) -> io::Result<PreparedDiskAnn> {
self.validate_for_write()?;
let graph_shards = self.graph_build_shard_count()?;
let pq_started = Instant::now();
let mut pq_codes = vec![0u8; self.ids.len() * self.pq.code_size()];
self.pq
.encode_batch(&self.vectors, self.ids.len(), &mut pq_codes);
let pq_encoding = pq_started.elapsed();
let (mut graph, vamana_stats) = if graph_shards > 1 {
match self.build_params.build_distance {
DiskAnnBuildDistance::FullPrecision => VamanaGraph::build_sharded_with_stats(
&self.vectors,
self.ids.len(),
self.d,
self.graph_metric(),
self.build_params,
graph_shards,
)?,
DiskAnnBuildDistance::ProductQuantized => VamanaGraph::build_sharded_with_pq_stats(
&self.vectors,
&self.pq,
&pq_codes,
self.ids.len(),
self.d,
self.graph_metric(),
self.build_params,
graph_shards,
)?,
}
} else {
match self.build_params.build_distance {
DiskAnnBuildDistance::FullPrecision => VamanaGraph::build_with_stats(
&self.vectors,
self.ids.len(),
self.d,
self.graph_metric(),
self.build_params,
)?,
DiskAnnBuildDistance::ProductQuantized => VamanaGraph::build_with_pq_stats(
&self.vectors,
&self.pq,
&pq_codes,
self.ids.len(),
self.d,
self.graph_metric(),
self.build_params,
)?,
}
};
let locality_started = Instant::now();
let permutation = bfs_locality_permutation(&graph);
remap_graph_in_place(&mut graph, &permutation);
let locality_remap = locality_started.elapsed();
Ok(PreparedDiskAnn {
graph,
permutation,
pq_codes,
stats: DiskAnnBuildStats {
graph_shards,
pq_encoding,
vamana_initialization: vamana_stats.initialization,
vamana_pass_one: vamana_stats.pass_one,
vamana_pass_two: vamana_stats.pass_two,
connectivity_repair: vamana_stats.connectivity_repair,
locality_remap,
..DiskAnnBuildStats::default()
},
})
}
fn graph_metric(&self) -> MetricType {
match self.metric {
// Cosine vectors are normalized during training and ingestion, so
// squared L2 has identical ordering and a well-behaved prune ratio.
MetricType::Cosine => MetricType::L2,
metric => metric,
}
}
fn preprocess_vectors<'a>(&self, data: &'a [f32], count: usize) -> Cow<'a, [f32]> {
if self.metric == MetricType::Cosine {
Cow::Owned(preprocess_vectors(data, count, self.d, self.metric))
} else {
Cow::Borrowed(&data[..count * self.d])
}
}
}
fn bounded_pq_training_sample(
data: &[f32],
count: usize,
dimension: usize,
sample_limit: usize,
seed: u64,
) -> Option<Vec<f32>> {
if count <= sample_limit {
return None;
}
let mut sample = Vec::with_capacity(sample_limit.saturating_mul(dimension));
sample.extend_from_slice(&data[..sample_limit * dimension]);
let mut rng = StdRng::seed_from_u64(seed);
for (stream_index, vector) in data
.chunks_exact(dimension)
.enumerate()
.skip(sample_limit)
.take(count - sample_limit)
{
let replacement = rng.gen_range(0..=stream_index);
if replacement < sample_limit {
let start = replacement * dimension;
sample[start..start + dimension].copy_from_slice(vector);
}
}
Some(sample)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PqTrainingPlan {
sample_count: usize,
parallelism: usize,
}
#[cfg(test)]
fn pq_training_plan(
dimension: usize,
pq_m: usize,
ksub: usize,
vector_count: usize,
memory_budget_bytes: usize,
) -> io::Result<PqTrainingPlan> {
pq_training_plan_with_sample_buffers(
dimension,
pq_m,
ksub,
vector_count,
memory_budget_bytes,
1,
)
}
fn pq_training_plan_with_sample_buffers(
dimension: usize,
pq_m: usize,
ksub: usize,
vector_count: usize,
memory_budget_bytes: usize,
sample_buffer_count: usize,
) -> io::Result<PqTrainingPlan> {
if vector_count == 0 {
return Ok(PqTrainingPlan {
sample_count: 0,
parallelism: 1,
});
}
let max_sample = vector_count.min(DISKANN_MAX_PQ_TRAINING_VECTORS);
let min_sample = vector_count.min(ksub.max(1));
let max_chunk_dimension = dimension.div_ceil(pq_m.max(1));
let current_parallelism = rayon::current_num_threads().max(1).min(pq_m.max(1));
let peak_for = |sample_count: usize, parallelism: usize| -> Option<usize> {
let sample_bytes = sample_count
.checked_mul(dimension)?
.checked_mul(size_of::<f32>())?
.checked_mul(sample_buffer_count)?;
let codebook_bytes = dimension.checked_mul(ksub)?.checked_mul(size_of::<f32>())?;
let subvector_copy_bytes = sample_count
.checked_mul(max_chunk_dimension)?
.checked_mul(2 * size_of::<f32>())?;
let assignment_bytes = sample_count.checked_mul(size_of::<usize>() + size_of::<f32>())?;
let score_matrix_bytes = sample_count
.checked_mul(ksub)?
.min(4 * 1024 * 1024)
.checked_mul(size_of::<f32>())?;
let centroid_scratch_bytes = max_chunk_dimension
.checked_mul(ksub)?
.checked_mul(3 * size_of::<f32>())?;
let per_task = [
subvector_copy_bytes,
assignment_bytes,
score_matrix_bytes,
centroid_scratch_bytes,
]
.into_iter()
.try_fold(0usize, |total, bytes| total.checked_add(bytes))?;
sample_bytes
.checked_add(codebook_bytes)?
.checked_add(per_task.checked_mul(parallelism)?)
};
if peak_for(min_sample, 1).is_none_or(|peak| peak > memory_budget_bytes) {
return Err(invalid_input(format!(
"{} bytes is below the estimated one-worker PQ training peak",
memory_budget_bytes
)));
}
let mut low = min_sample;
let mut high = max_sample;
while low < high {
let middle = low + (high - low).div_ceil(2);
if peak_for(middle, 1).is_some_and(|peak| peak <= memory_budget_bytes) {
low = middle;
} else {
high = middle - 1;
}
}
let sample_count = low;
let parallelism = (1..=current_parallelism)
.rev()
.find(|&parallelism| {
peak_for(sample_count, parallelism).is_some_and(|peak| peak <= memory_budget_bytes)
})
.unwrap_or(1);
Ok(PqTrainingPlan {
sample_count,
parallelism,
})
}
fn checked_bytes(count: usize, item_size: usize, name: &str) -> io::Result<usize> {
count
.checked_mul(item_size)
.ok_or_else(|| invalid_input(format!("DiskANN {} byte size overflows usize", name)))
}
fn row_id_encoding_scratch_bytes(count: usize) -> io::Result<usize> {
checked_bytes(count, size_of::<i64>(), "row-ID encoding scratch")
}
fn invalid_input(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message.into())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LocalityPermutation {
pub(crate) new_to_old: Vec<u32>,
pub(crate) old_to_new: Vec<u32>,
}
pub(crate) fn bfs_locality_permutation(graph: &VamanaGraph) -> LocalityPermutation {
let count = graph.adjacency.len();
let mut visited = vec![false; count];
let mut new_to_old = Vec::with_capacity(count);
let entry = graph.entry_node as usize;
if entry < count {
visited[entry] = true;
let mut queue = VecDeque::from([entry]);
while let Some(node) = queue.pop_front() {
new_to_old.push(node as u32);
for &neighbor in &graph.adjacency[node] {
let neighbor = neighbor as usize;
if neighbor < count && !visited[neighbor] {
visited[neighbor] = true;
queue.push_back(neighbor);
}
}
}
}
for (node, was_visited) in visited.into_iter().enumerate() {
if !was_visited {
new_to_old.push(node as u32);
}
}
let mut old_to_new = vec![0u32; count];
for (new_id, &old_id) in new_to_old.iter().enumerate() {
old_to_new[old_id as usize] = new_id as u32;
}
LocalityPermutation {
new_to_old,
old_to_new,
}
}
pub(crate) fn remap_graph_in_place(graph: &mut VamanaGraph, permutation: &LocalityPermutation) {
graph
.adjacency
.permute_and_map_neighbors(&permutation.old_to_new);
graph.entry_node = permutation.old_to_new[graph.entry_node as usize];
}
pub(crate) struct PreparedDiskAnn {
pub(crate) graph: VamanaGraph,
pub(crate) permutation: LocalityPermutation,
pub(crate) pq_codes: Vec<u8>,
pub(crate) stats: DiskAnnBuildStats,
}
impl PreparedDiskAnn {
pub(crate) fn row_id(&self, index: &DiskAnnIndex, new_id: usize) -> i64 {
index.ids[self.permutation.new_to_old[new_id] as usize]
}
pub(crate) fn vector<'a>(&self, index: &'a DiskAnnIndex, new_id: usize) -> &'a [f32] {
let old_id = self.permutation.new_to_old[new_id] as usize;
&index.vectors[old_id * index.d..(old_id + 1) * index.d]
}
pub(crate) fn pq_code<'a>(&'a self, index: &DiskAnnIndex, new_id: usize) -> &'a [u8] {
let old_id = self.permutation.new_to_old[new_id] as usize;
let code_size = index.pq.code_size();
&self.pq_codes[old_id * code_size..(old_id + 1) * code_size]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn low_level_diskann_training_uses_the_same_bounded_deterministic_reservoir() {
let dimension = 2;
let count = DISKANN_MAX_PQ_TRAINING_VECTORS + 257;
let data = (0..count * dimension)
.map(|offset| offset as f32)
.collect::<Vec<_>>();
let first = bounded_pq_training_sample(
&data,
count,
dimension,
DISKANN_MAX_PQ_TRAINING_VECTORS,
73,
)
.unwrap();
let second = bounded_pq_training_sample(
&data,
count,
dimension,
DISKANN_MAX_PQ_TRAINING_VECTORS,
73,
)
.unwrap();
let different_seed = bounded_pq_training_sample(
&data,
count,
dimension,
DISKANN_MAX_PQ_TRAINING_VECTORS,
74,
)
.unwrap();
assert_eq!(first.len(), DISKANN_MAX_PQ_TRAINING_VECTORS * dimension);
assert_eq!(first, second);
assert_ne!(first, different_seed);
assert_ne!(first, data[..DISKANN_MAX_PQ_TRAINING_VECTORS * dimension]);
assert!(bounded_pq_training_sample(
&data[..DISKANN_MAX_PQ_TRAINING_VECTORS * dimension],
DISKANN_MAX_PQ_TRAINING_VECTORS,
dimension,
DISKANN_MAX_PQ_TRAINING_VECTORS,
73
)
.is_none());
}
#[test]
fn diskann_pq_training_plan_throttles_parallelism_and_samples_to_budget() {
let unrestricted =
pq_training_plan(1024, 256, 256, 50_000, 8 * 1024 * 1024 * 1024).unwrap();
assert_eq!(unrestricted.sample_count, 50_000);
assert_eq!(
unrestricted.parallelism,
rayon::current_num_threads().min(256)
);
let bounded = pq_training_plan(1024, 256, 256, 50_000, 64 * 1024 * 1024).unwrap();
assert!(bounded.sample_count < unrestricted.sample_count);
assert!(bounded.parallelism <= unrestricted.parallelism);
assert!(pq_training_plan(1024, 1, 256, 256, 1).is_err());
}
#[test]
fn diskann_pq_training_plan_accounts_for_retained_and_normalized_samples() {
let budget = 128 * 1024 * 1024;
let retained =
pq_training_plan_with_sample_buffers(1024, 256, 256, 50_000, budget, 1).unwrap();
let cosine =
pq_training_plan_with_sample_buffers(1024, 256, 256, 50_000, budget, 2).unwrap();
assert!(retained.sample_count < 50_000);
assert!(cosine.sample_count < retained.sample_count);
}
#[test]
fn low_level_diskann_training_uses_metric_aware_budget_and_propagates_failure() {
let budget = 128 * 1024 * 1024;
let build_params = DiskAnnBuildParams {
memory_budget_bytes: budget,
..DiskAnnBuildParams::default()
};
let l2 = DiskAnnIndex::new(1024, MetricType::L2, 256, build_params);
let cosine = DiskAnnIndex::new(1024, MetricType::Cosine, 256, build_params);
let l2_plan = l2.training_plan(50_000).unwrap();
let cosine_plan = cosine.training_plan(50_000).unwrap();
assert_eq!(
l2_plan,
pq_training_plan_with_sample_buffers(1024, 256, 256, 50_000, budget, 1).unwrap()
);
assert_eq!(
cosine_plan,
pq_training_plan_with_sample_buffers(1024, 256, 256, 50_000, budget, 2).unwrap()
);
assert!(cosine_plan.sample_count < l2_plan.sample_count);
let mut infeasible = DiskAnnIndex::new(
8,
MetricType::Cosine,
2,
DiskAnnBuildParams {
memory_budget_bytes: 1,
..DiskAnnBuildParams::default()
},
);
let error = infeasible
.train(&[0.0; 8], 1)
.expect_err("an infeasible low-level training budget must be returned");
assert!(error.to_string().contains("PQ training peak"));
}
#[test]
fn diskann_index_rejects_build_exceeding_memory_budget() {
let build_params = DiskAnnBuildParams {
memory_budget_bytes: 1,
..DiskAnnBuildParams::default()
};
let mut index = DiskAnnIndex::new(8, MetricType::L2, 2, build_params);
index.add(&[0.0; 8], &[7]);
let error = index
.graph_build_shard_count()
.expect_err("build should fail before graph allocation");
assert!(error.to_string().contains("memory budget"));
}
#[test]
fn diskann_memory_estimate_accounts_for_one_compact_graph_during_remap() {
let count = 1024;
let max_degree = 1024;
let estimate = estimate_vamana_memory_bytes(count, max_degree, 1024, 1)
.unwrap()
.remap_peak_bytes;
let one_graph = count * (max_degree * size_of::<u32>() + size_of::<u16>());
assert!(
estimate >= one_graph && estimate < 2 * one_graph,
"in-place remapping should retain one compact graph; estimate={estimate}, one_graph={one_graph}"
);
}
#[test]
fn diskann_memory_estimate_reserves_row_id_encoding_scratch() {
assert_eq!(row_id_encoding_scratch_bytes(1024).unwrap(), 8 * 1024);
assert!(row_id_encoding_scratch_bytes(usize::MAX).is_err());
}
#[test]
fn diskann_memory_budget_automatically_selects_overlapping_shards() {
// Pin the worker count so this test exercises the same memory-plan
// relationship on small CI runners and developer machines.
rayon::ThreadPoolBuilder::new()
.num_threads(12)
.build()
.unwrap()
.install(|| {
let count = 2_048;
let dimension = 8;
let params = DiskAnnBuildParams {
max_degree: 256,
build_search_list_size: 256,
..DiskAnnBuildParams::default()
};
let mut index = DiskAnnIndex::new(dimension, MetricType::L2, 2, params);
index.ids = (0..count as i64).collect();
index.vectors = vec![0.0; count * dimension];
let full_peak = index.estimate_build_memory_bytes().unwrap();
index.build_params.memory_budget_bytes = full_peak - 1;
let shard_count = index.graph_build_shard_count().unwrap();
assert!(shard_count > 1);
assert!(shard_count <= 64);
});
}
#[test]
fn diskann_locality_permutation_uses_bfs_then_unreachable_ids() {
let graph =
VamanaGraph::from_adjacency(2, vec![vec![], vec![], vec![3, 1], vec![4], vec![]]);
let permutation = bfs_locality_permutation(&graph);
assert_eq!(permutation.new_to_old, vec![2, 3, 1, 4, 0]);
assert_eq!(permutation.old_to_new, vec![4, 2, 0, 1, 3]);
}
#[test]
fn diskann_locality_remaps_entry_and_neighbor_ids() {
let mut graph =
VamanaGraph::from_adjacency(2, vec![vec![], vec![], vec![3, 1], vec![4], vec![]]);
let permutation = bfs_locality_permutation(&graph);
remap_graph_in_place(&mut graph, &permutation);
assert_eq!(graph.entry_node, 0);
assert_eq!(
graph
.adjacency
.iter()
.map(<[u32]>::to_vec)
.collect::<Vec<_>>(),
vec![vec![1, 2], vec![3], vec![], vec![], vec![]]
);
}
#[test]
fn diskann_prepare_build_keeps_rows_codes_and_vectors_aligned() {
let dimension = 8;
let training_count = 256;
let indexed_count = 64;
let data = (0..training_count * dimension)
.map(|offset| ((offset * 29) % 113) as f32)
.collect::<Vec<_>>();
let ids = (1000..1000 + indexed_count as i64).collect::<Vec<_>>();
let mut index = DiskAnnIndex::new(
dimension,
MetricType::L2,
2,
DiskAnnBuildParams {
max_degree: 8,
build_search_list_size: 16,
..DiskAnnBuildParams::default()
},
);
index.train(&data, training_count).unwrap();
index.add(&data[..indexed_count * dimension], &ids);
let prepared = index.prepare_build().unwrap();
assert_eq!(prepared.graph.entry_node, 0);
assert_eq!(prepared.pq_codes.len(), indexed_count * 2);
for new_id in 0..indexed_count {
let old_id = prepared.permutation.new_to_old[new_id] as usize;
assert_eq!(prepared.row_id(&index, new_id), ids[old_id]);
assert_eq!(
prepared.vector(&index, new_id),
&data[old_id * dimension..(old_id + 1) * dimension]
);
assert_eq!(prepared.pq_code(&index, new_id).len(), 2);
}
}
#[test]
fn diskann_cosine_normalizes_vectors_before_pq_encoding_and_persistence() {
let mut index =
DiskAnnIndex::with_pq_bits(2, MetricType::Cosine, 1, 4, DiskAnnBuildParams::default());
index.add(&[3.0, 4.0, 0.0, 0.0], &[10, 11]);
assert_eq!(index.vectors, vec![0.6, 0.8, 0.0, 0.0]);
}
#[test]
fn diskann_only_allocates_metric_preprocessing_for_cosine() {
let data = [3.0, 4.0];
for metric in [MetricType::L2, MetricType::InnerProduct] {
let index = DiskAnnIndex::with_pq_bits(2, metric, 1, 4, DiskAnnBuildParams::default());
assert!(matches!(
index.preprocess_vectors(&data, 1),
Cow::Borrowed(_)
));
}
let index =
DiskAnnIndex::with_pq_bits(2, MetricType::Cosine, 1, 4, DiskAnnBuildParams::default());
assert!(matches!(index.preprocess_vectors(&data, 1), Cow::Owned(_)));
}
}