Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
99 changes: 12 additions & 87 deletions diskann-providers/src/model/pq/fixed_chunk_pq_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -671,9 +671,8 @@ pub fn compute_pq_distance_for_pq_coordinates(
mod fixed_chunk_pq_table_test {
use core::ops::Range;

use crate::storage::{StorageReadProvider, VirtualStorageProvider};
use crate::storage::{PQStorage, VirtualStorageProvider};
use approx::assert_relative_eq;
use diskann::error::ErrorContext;
use diskann_utils::test_data_root;
use diskann_vector::{
PureDistanceFunction,
Expand All @@ -682,9 +681,17 @@ mod fixed_chunk_pq_table_test {
use itertools::iproduct;

use super::*;
use crate::{model::NUM_PQ_CENTROIDS, utils::read_bin_from};
use crate::model::NUM_PQ_CENTROIDS;

const DIM: usize = 128;
const PQ_PIVOTS_PATH: &str = "/sift/siftsmall_learn_pq_pivots.bin";

fn load_test_pivots() -> FixedChunkPQTable {
let storage_provider = VirtualStorageProvider::new_overlay(test_data_root());
PQStorage::new(PQ_PIVOTS_PATH, "", None)
.load_pq_pivots_bin(PQ_PIVOTS_PATH, 1, &storage_provider)
.unwrap()
}

#[test]
fn constructor_errors() {
Expand Down Expand Up @@ -807,14 +814,8 @@ mod fixed_chunk_pq_table_test {

#[test]
fn load_pivot_test() {
let storage_provider = VirtualStorageProvider::new_overlay(test_data_root());
let pq_pivots_path: &str = "/sift/siftsmall_learn_pq_pivots.bin";
let (dim, pq_table, chunk_offsets) =
load_pq_pivots_bin(pq_pivots_path, &1, &storage_provider).unwrap();
let fixed_chunk_pq_table =
FixedChunkPQTable::new(dim, pq_table.into(), chunk_offsets.into()).unwrap();
let fixed_chunk_pq_table = load_test_pivots();

assert_eq!(dim, DIM);
assert_eq!(fixed_chunk_pq_table.table.dim(), DIM);
assert_eq!(fixed_chunk_pq_table.table.ncenters(), NUM_PQ_CENTROIDS);

Expand All @@ -834,14 +835,7 @@ mod fixed_chunk_pq_table_test {

#[test]
fn calculate_distances_tests() {
let storage_provider = VirtualStorageProvider::new_overlay(test_data_root());

let pq_pivots_path: &str = "/sift/siftsmall_learn_pq_pivots.bin";

let (dim, pq_table, chunk_offsets) =
load_pq_pivots_bin(pq_pivots_path, &1, &storage_provider).unwrap();
let fixed_chunk_pq_table =
FixedChunkPQTable::new(dim, pq_table.into(), chunk_offsets.into()).unwrap();
let fixed_chunk_pq_table = load_test_pivots();

let query_vec: Vec<f32> = vec![
32.39f32, 78.57f32, 50.32f32, 80.46f32, 6.47f32, 69.76f32, 94.2f32, 83.36f32, 5.8f32,
Expand Down Expand Up @@ -990,75 +984,6 @@ mod fixed_chunk_pq_table_test {
}
}

type LoadPQPivotResult = (usize, Vec<f32>, Vec<usize>);
fn load_pq_pivots_bin<StorageProvider: StorageReadProvider>(
pq_pivots_path: &str,
num_pq_chunks: &usize,
storage_provider: &StorageProvider,
) -> ANNResult<LoadPQPivotResult> {
let mut reader = storage_provider
.open_reader(pq_pivots_path)
.with_context(|| format!("ERROR: Opening PQ k-means pivot file {}", pq_pivots_path))?;

let offsets = read_bin_from::<u64>(&mut reader, 0)?;
if offsets.nrows() != 4 {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. \
Offsets don't contain correct metadata, \
# offsets = {}, but expecting 4.",
pq_pivots_path,
offsets.nrows()
)));
}
let file_offset_data = offsets.map(|x| x.into_usize());

let mut pivots = read_bin_from::<f32>(&mut reader, file_offset_data[(0, 0)])?;

if pivots.nrows() != NUM_PQ_CENTROIDS {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_num_centers = {}, but expecting {} centers.",
pq_pivots_path,
pivots.nrows(),
NUM_PQ_CENTROIDS
)));
}
let dim = pivots.ncols();

let centroids = read_bin_from::<f32>(&mut reader, file_offset_data[(1, 0)])?;
if centroids.nrows() != dim || centroids.ncols() != 1 {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_dim = {}, \
file_cols = {} but expecting {} entries in 1 dimension.",
pq_pivots_path,
centroids.nrows(),
centroids.ncols(),
dim
)));
}

pivots.row_iter_mut().for_each(|row| {
std::iter::zip(row.iter_mut(), centroids.as_slice().iter()).for_each(|(p, c)| *p += *c);
});

let chunk_offsets_m = read_bin_from::<u32>(&mut reader, file_offset_data[(2, 0)])?;
if chunk_offsets_m.nrows() != num_pq_chunks + 1 || chunk_offsets_m.ncols() != 1 {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file at chunk offsets; \
file has nr={}, nc={} but expecting nr={} and nc=1.",
chunk_offsets_m.nrows(),
chunk_offsets_m.ncols(),
num_pq_chunks + 1
)));
}
let chunk_offsets = chunk_offsets_m.map(|x| x.into_usize());

Ok((
dim,
pivots.into_inner().into_vec(),
chunk_offsets.into_inner().into_vec(),
))
}

#[test]
fn test_populate_chunk_distances() {
let dim = 8;
Expand Down
175 changes: 105 additions & 70 deletions diskann-providers/src/storage/pq_storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ type FullPivotDataType = Vec<f32>;
type CentroidType = Vec<f32>;
type ChunkOffsetsType = Vec<usize>;

#[derive(Debug)]
struct PivotFileParts {
pivots: Matrix<f32>,
centroid: Matrix<f32>,
chunk_offsets: Matrix<usize>,
}

impl PivotFileParts {
fn dim(&self) -> usize {
self.pivots.ncols()
}
}

#[derive(Debug, Clone)]
pub struct PQStorage {
/// Pivot table path
Expand Down Expand Up @@ -179,63 +192,18 @@ impl PQStorage {
where
Storage: StorageReadProvider,
{
// Load file offset data. File layout: offset table(4*1) -> pivot data(num_centers*dim) -> centroid(dim*1) -> chunk offsets(num_chunks+1*1)
let reader = &mut storage_provider.open_reader(&self.pivot_data_path)?;

let offsets = read_bin_from::<u64>(reader, 0)?;
if offsets.nrows() != 4 {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. Offsets don't contain correct \
metadata, # offsets = {}, but expecting 4.",
&self.pivot_data_path,
offsets.nrows()
)));
}
let file_offset_data = offsets.map(|x| x.into_usize());

info!(" Offset data: {:?}", file_offset_data.as_slice());

let pivots = read_bin_from::<f32>(reader, file_offset_data[(0, 0)])?;
if pivots.nrows() != *num_centers || pivots.ncols() != *dim {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_num_centers = {}, \
file_dim = {} but expecting {} centers in {} dimensions.",
&self.pivot_data_path,
pivots.nrows(),
pivots.ncols(),
num_centers,
dim
)));
}

let centroid_m = read_bin_from::<f32>(reader, file_offset_data[(1, 0)])?;
if centroid_m.nrows() != *dim || centroid_m.ncols() != 1 {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_dim = {}, \
file_cols = {} but expecting {} entries in 1 dimension.",
&self.pivot_data_path,
centroid_m.nrows(),
centroid_m.ncols(),
dim
)));
}

let chunk_offsets_m = read_bin_from::<u32>(reader, file_offset_data[(2, 0)])?;
if chunk_offsets_m.nrows() != *num_pq_chunks + 1 || chunk_offsets_m.ncols() != 1 {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file at chunk offsets; \
file has nr={}, nc={} but expecting nr={} and nc=1.",
chunk_offsets_m.nrows(),
chunk_offsets_m.ncols(),
num_pq_chunks + 1
)));
}
let chunk_offsets = chunk_offsets_m.map(|x| x.into_usize());
let parts = self.load_pivot_file_parts(
&self.pivot_data_path,
Some(*num_pq_chunks),
Some(*num_centers),
Some(*dim),
storage_provider,
)?;
Comment thread
Copilot marked this conversation as resolved.

Ok((
pivots.into_inner().into_vec(),
centroid_m.into_inner().into_vec(),
chunk_offsets.into_inner().into_vec(),
parts.pivots.into_inner().into_vec(),
parts.centroid.into_inner().into_vec(),
parts.chunk_offsets.into_inner().into_vec(),
))
}

Expand Down Expand Up @@ -289,7 +257,43 @@ impl PQStorage {

info!("Loading PQ pivots from {}...", pq_pivots);

let PivotFileParts {
mut pivots,
centroid,
chunk_offsets,
} = self.load_pivot_file_parts(
pq_pivots,
(num_pq_chunks != 0).then_some(num_pq_chunks),
None,
None,
storage_provider,
)?;

// If the centroid is non-zero, we need to add it to the pivots to restore the
// numeric behavior.
if centroid.as_slice().iter().any(|c| *c != 0.0) {
accum_row_inplace(pivots.as_mut_view(), centroid.as_slice())
}

FixedChunkPQTable::new(
pivots.ncols(),
pivots.into_inner(),
chunk_offsets.into_inner(),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for looking at this! I have a higher-level comment that I think we may want to think about instead.

The current divergent loading paths are a symptom of larger architectural issues and while unifying the load paths is a step in the right direction, it is treating a symptom rather than the cause. Much of the complexity here comes from the (optional) inline invariant checks. However, these checks are not exhaustive. For example, the offsets also need to be strictly monotonically increasing, which is not covered by the checks here. However, duplicating these checks is not strictly necessary as BasicTable and ChunkOffsets already assert the exact invariants that are needed. Duplication runs the risk of things getting out-of-sync.

This loader has two basic return paths:

  • load_pq_pivots_bin: This returns a FixedChunkPQTable, which is really a BasicTable in disguise with a terrible constructor. Anyways, since load_pq_pivots_bin already returns a FixedChunkPQTable, it's already performing the necessary invariant checks. Errors returned on FixedChunkPQTable construction can be chained with a context to provide a nice message that construction failed during file loading.
  • load_existing_pivot_data: This is where a lot of the complexity from this localized change comes from as this is the path the requests more pedantic checking due to the looser return type. However, there are just two call-sites that actually use this method:
    • This one, which also goes to the trouble of re-accumulating the centroid before calling TransposedTable::new. Note that the arguments to the transposed table can come directly from the BasicTable. So this call site can be updated to take method returning BasicTable.
    • This test function. Again, though, this can instead use a BasicTable instead if we update generate_pq_data_from_pivots_from_membuf to take a &BasicTable instead. Though if we do that - there's no need for this helper method at all because we can simply use table.compress_into! All the callers of the _membuf APIs already have a FixedChunkPQTable, so getting the inner BasicTable should be a breeze.

To recap, if this loading function only has a single pivot loading method returning a BasicTable, we get most of the invariant checking for free (outside of the file format verification checks) and a bunch of PQ related code can be simplified because it basically already operates on a BasicTable. We can add a new trivial constructor for FixedChunkPQTable taking a BasicTable and the net effect will probably be a healthy reduction in code.


fn load_pivot_file_parts<Storage: StorageReadProvider>(
&self,
pq_pivots: &str,
expected_num_pq_chunks: Option<usize>,
expected_num_centers: Option<usize>,
expected_dim: Option<usize>,
storage_provider: &Storage,
) -> ANNResult<PivotFileParts> {
Comment on lines +297 to +305

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add some documentation to this function to make sure we understand how the arguments here are used?

nit: It feels a bit hacky to me to case on these values when performing the checks in this function. It looks like the last two are used to validate pivots and the first to validate the chunks_offset. Maybe you can make this check more explicit?

let mut reader = storage_provider.open_reader(pq_pivots)?;

// File layout: offset table(4*1) -> pivot data(num_centers*dim) ->
// centroid(dim*1) -> chunk offsets(num_chunks+1*1).
let offsets = read_bin_from::<u64>(&mut reader, 0)?;
if offsets.nrows() != 4 {
return Err(ANNError::log_pq_error(format_args!(
Expand All @@ -301,31 +305,53 @@ impl PQStorage {
}
let file_offset_data = offsets.map(|x| x.into_usize());

let mut pivots = read_bin_from::<f32>(&mut reader, file_offset_data[(0, 0)])?;
if pivots.nrows() > NUM_PQ_CENTROIDS {
info!(" Offset data: {:?}", file_offset_data.as_slice());

let pivots = read_bin_from::<f32>(&mut reader, file_offset_data[(0, 0)])?;
if let Some(num_centers) = expected_num_centers {
if pivots.nrows() != num_centers {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_num_centers = {}, but expecting {} centers.",
pq_pivots,
pivots.nrows(),
num_centers
)));
Comment on lines +326 to +331

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're moving away from this pattern - #1282. How can we get ahead of this here? Or maybe we don't need to if 1282 is checked in first :)

}
} else if pivots.nrows() > NUM_PQ_CENTROIDS {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_num_centers = {}, but expecting {} centers.",
pq_pivots,
pivots.nrows(),
NUM_PQ_CENTROIDS
)));
}
let dim = pivots.ncols();

let centroids = read_bin_from::<f32>(&mut reader, file_offset_data[(1, 0)])?;
if centroids.nrows() != dim || centroids.ncols() != 1 {
if let Some(dim) = expected_dim {
if pivots.ncols() != dim {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_dim = {} but expecting {} dimensions.",
pq_pivots,
pivots.ncols(),
dim
)));
}
}

let centroid = read_bin_from::<f32>(&mut reader, file_offset_data[(1, 0)])?;
if centroid.nrows() != pivots.ncols() || centroid.ncols() != 1 {
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file {}. file_dim = {}, file_cols = {} \
but expecting {} entries in 1 dimension.",
pq_pivots,
centroids.nrows(),
centroids.ncols(),
dim
centroid.nrows(),
centroid.ncols(),
pivots.ncols()
)));
}

let chunk_offsets_m = read_bin_from::<u32>(&mut reader, file_offset_data[(2, 0)])?;
if (chunk_offsets_m.nrows() != num_pq_chunks + 1 && num_pq_chunks as u32 != 0)
if expected_num_pq_chunks
.is_some_and(|num_pq_chunks| chunk_offsets_m.nrows() != num_pq_chunks + 1)
|| chunk_offsets_m.ncols() != 1
{
Comment on lines 365 to 368
return Err(ANNError::log_pq_error(format_args!(
Expand All @@ -334,18 +360,27 @@ impl PQStorage {
passed as 0 if we want to infer.",
chunk_offsets_m.nrows(),
chunk_offsets_m.ncols(),
num_pq_chunks + 1
expected_num_pq_chunks.map_or(0, |num_pq_chunks| num_pq_chunks + 1)
)));
}
let chunk_offsets = chunk_offsets_m.map(|x| x.into_usize());

// If the centroid is non-zero, we need to add it to the pivots to restore the
// numeric behavior.
if centroids.as_slice().iter().any(|c| *c != 0.0) {
accum_row_inplace(pivots.as_mut_view(), centroids.as_slice())
let parts = PivotFileParts {
pivots,
centroid,
chunk_offsets,
};
if parts.chunk_offsets.nrows() < 2
|| parts.chunk_offsets[(0, 0)] != 0
|| parts.chunk_offsets[(parts.chunk_offsets.nrows() - 1, 0)] != parts.dim()
{
return Err(ANNError::log_pq_error(format_args!(
"Error reading pq_pivots file at chunk offsets; chunk offsets must start at 0, end at dim {}, and contain at least two entries.",
parts.dim()
)));
}

FixedChunkPQTable::new(dim, pivots.into_inner(), chunk_offsets.into_inner())
Ok(parts)
}

/// streams data from the file, and samples each vector with probability p_val
Expand Down
Loading