Skip to main content

diskann_quantization/product/tables/
basic.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use crate::traits::CompressInto;
7use crate::views::{ChunkOffsetsBase, ChunkOffsetsView};
8use diskann_utils::{
9    strided::Strided,
10    views::{
11        DenseData,
12        rowmajor::{self, Matrix},
13    },
14};
15use diskann_vector::{PureDistanceFunction, distance::SquaredL2};
16use thiserror::Error;
17
18/// A basic PQ table that stores the pivot table in the following dense, row-major form:
19/// ```text
20///           | -- chunk 0 -- | -- chunk 1 -- | -- chunk 2 -- | .... | -- chunk N-1 -- |
21///           +------------------------------------------------------------------------+
22///  pivot 0  | c000 c001 ... | c010 c011 ... | c020 c021 ... | .... |       ...       |
23///  pivot 1  | c100 c101 ... | c110 c111 ... | c120 c121 ... | .... |       ...       |
24///    ...    |      ...      |      ...      |      ...      | .... |       ...       |
25///  pivot K  | cK00 cK01 ... | cK10 cK11 ... | cK20 cK21 ... | .... |       ...       |
26/// ```
27/// The member `offsets` describes the number of dimensions of each chunk.
28///
29/// # Invariants
30///
31/// * `offsets.dim() == pivots.nrows()`: The dimensionality of the two must agree.
32#[derive(Debug, Clone)]
33pub struct BasicTableBase<T, U>
34where
35    T: Matrix<Element = f32>,
36    U: DenseData<Elem = usize>,
37{
38    pivots: T,
39    offsets: ChunkOffsetsBase<U>,
40}
41
42/// A `BasicTableBase` that owns its contents.
43pub type BasicTable = BasicTableBase<rowmajor::Owned<f32>, Box<[usize]>>;
44
45/// A `BasicTableBase` that references its contents. Construction of such a table will
46/// not result in a memory allocation.
47pub type BasicTableView<'a> = BasicTableBase<rowmajor::Ref<'a, f32>, &'a [usize]>;
48
49#[derive(Error, Debug)]
50#[non_exhaustive]
51pub enum BasicTableError {
52    #[error("pivots have {pivot_dim} dimensions while the offsets expect {offsets_dim}")]
53    DimMismatch {
54        pivot_dim: usize,
55        offsets_dim: usize,
56    },
57    #[error("pivots cannot be empty")]
58    PivotsEmpty,
59}
60
61impl<T, U> BasicTableBase<T, U>
62where
63    T: Matrix<Element = f32>,
64    U: DenseData<Elem = usize>,
65{
66    /// Construct a new `BasicTableBase` over the pivot table and offsets.
67    ///
68    /// # Error
69    ///
70    /// Returns an error if `pivots.ncols() != offsets.dim()` or if `pivots.nrows() == 0`.
71    pub fn new(pivots: T, offsets: ChunkOffsetsBase<U>) -> Result<Self, BasicTableError> {
72        let pivot_dim = pivots.ncols();
73        let offsets_dim = offsets.dim();
74
75        if pivot_dim != offsets_dim {
76            Err(BasicTableError::DimMismatch {
77                pivot_dim,
78                offsets_dim,
79            })
80        } else if pivots.nrows() == 0 {
81            Err(BasicTableError::PivotsEmpty)
82        } else {
83            Ok(Self { pivots, offsets })
84        }
85    }
86
87    /// Return a view over the pivot table.
88    pub fn view_pivots(&self) -> rowmajor::Ref<'_, f32> {
89        self.pivots.as_view()
90    }
91
92    /// Return a view over the schema offsets.
93    pub fn view_offsets(&self) -> ChunkOffsetsView<'_> {
94        self.offsets.as_view()
95    }
96
97    /// Return the number of pivots in each PQ chunk.
98    pub fn ncenters(&self) -> usize {
99        self.pivots.nrows()
100    }
101
102    /// Return the number of PQ chunks.
103    pub fn nchunks(&self) -> usize {
104        self.offsets.len()
105    }
106
107    /// Return the dimensionality of the full-precision vectors associated with this table.
108    pub fn dim(&self) -> usize {
109        self.pivots.ncols()
110    }
111
112    /// Return `self` as a [`BasicTableView`].
113    pub fn as_view(&self) -> BasicTableView<'_> {
114        BasicTableView {
115            pivots: self.pivots.as_view(),
116            offsets: self.offsets.as_view(),
117        }
118    }
119
120    /// Return a [`Strided`] for the raw pivots of the requested chunk.
121    ///
122    /// Returns `None` if `chunk >= self.nchunks()`.
123    pub fn pivots_for(&self, chunk: usize) -> Option<Strided<'_, f32>> {
124        let range = self.offsets.get(chunk)?;
125
126        #[expect(
127            clippy::expect_used,
128            reason = "the BasicTable's invariants mean this panic should be unreachable"
129        )]
130        let strided = Strided::try_from_data(
131            &self.pivots.as_slice()[range.start..],
132            self.pivots.nrows(),
133            range.len(),
134            self.pivots.ncols(),
135        )
136        .expect("BasicTable asserts that this layout is valid");
137
138        Some(strided)
139    }
140}
141
142#[derive(Error, Debug)]
143#[non_exhaustive]
144pub enum TableCompressionError {
145    #[error("num centers ({0}) must be at most 256 to compress into a byte vector")]
146    CannotCompressToByte(usize),
147    #[error("invalid input len - expected {0}, got {1}")]
148    InvalidInputDim(usize, usize),
149    #[error("invalid PQ buffer len - expected {0}, got {1}")]
150    InvalidOutputDim(usize, usize),
151    #[error("a value of infinity or NaN was observed while compressing chunk {0}")]
152    InfinityOrNaN(usize),
153}
154
155impl<T, U> CompressInto<&[f32], &mut [u8]> for BasicTableBase<T, U>
156where
157    T: Matrix<Element = f32>,
158    U: DenseData<Elem = usize>,
159{
160    type Error = TableCompressionError;
161    type Output = ();
162
163    /// Compress the full-precision vector `from` into the PQ byte buffer `to`.
164    ///
165    /// Compression is performed by partitioning `from` into chunks according to the offsets
166    /// schema in the table and then finding the closest pivot according to the L2 distance.
167    ///
168    /// The final compressed value is the index of the closest pivot.
169    ///
170    /// # Errors
171    ///
172    /// Returns errors under the following conditions:
173    ///
174    /// * `self.ncenters() > 256`: If the number of centers exceeds 256, then it cannot be
175    ///   guaranteed that the index of the closest pivot for a chunk will fit losslessly in
176    ///   an 8-bit integer.
177    ///
178    /// * `from.len() != self.dim()`: The full precision vector must have the dimensionality
179    ///   expected by the compression.
180    ///
181    /// * `to.len() != self.nchunks()`: The PQ buffer must be sized appropriately.
182    ///
183    /// * If any chunk is sufficiently far from all centers that its distance becomes
184    ///   infinity to all centers.
185    ///
186    /// # Allocates
187    ///
188    /// This function should not allocate when successful.
189    ///
190    /// # Parallelism
191    ///
192    /// This function is single-threaded.
193    fn compress_into(&self, from: &[f32], to: &mut [u8]) -> Result<(), Self::Error> {
194        if self.ncenters() > 256 {
195            return Err(Self::Error::CannotCompressToByte(self.ncenters()));
196        }
197        if from.len() != self.dim() {
198            return Err(Self::Error::InvalidInputDim(self.dim(), from.len()));
199        }
200        if to.len() != self.nchunks() {
201            return Err(Self::Error::InvalidOutputDim(self.nchunks(), to.len()));
202        }
203
204        to.iter_mut().enumerate().try_for_each(|(chunk, to)| {
205            let mut min_distance = f32::INFINITY;
206            let mut min_index = usize::MAX;
207            let range = self.offsets.at(chunk);
208            let slice = &from[range.clone()];
209
210            self.pivots.rows().enumerate().for_each(|(index, row)| {
211                let distance: f32 = SquaredL2::evaluate(slice, &row[range.clone()]);
212                if distance < min_distance {
213                    min_distance = distance;
214                    min_index = index;
215                }
216            });
217
218            if min_distance.is_infinite() {
219                Err(Self::Error::InfinityOrNaN(chunk))
220            } else {
221                // This is guaranteed to be lossless because we have at most 256 centers.
222                *to = min_index as u8;
223                Ok(())
224            }
225        })
226    }
227}
228
229///////////
230// Tests //
231///////////
232
233#[cfg(test)]
234mod tests {
235    use diskann_utils::lazy_format;
236    use rand::{
237        SeedableRng,
238        distr::{Distribution, StandardUniform},
239    };
240
241    use super::*;
242    use crate::product::tables::test::{
243        check_pqtable_single_compression_errors, create_dataset, create_pivot_tables,
244    };
245
246    /////////////////////////
247    // Basic Table Methods //
248    /////////////////////////
249
250    // Test that an error is returned when the dimension between the pivots and offsets
251    // disagree.
252    #[test]
253    fn error_on_mismatch_dim() {
254        let pivots = rowmajor::Owned::from_element(3, 5, 0.0);
255        let offsets = crate::views::ChunkOffsets::new(Box::new([0, 1, 6])).unwrap();
256        let result = BasicTable::new(pivots, offsets);
257        assert!(result.is_err(), "dimensions are not equal");
258        assert_eq!(
259            result.unwrap_err().to_string(),
260            "pivots have 5 dimensions while the offsets expect 6"
261        );
262    }
263
264    // Test that the table constructor errors when there are no pivots.
265    #[test]
266    fn error_on_no_pivots() {
267        let pivots = rowmajor::Owned::from_element(0, 5, 0.0);
268        let offsets = crate::views::ChunkOffsets::new(Box::new([0, 1, 2, 5])).unwrap();
269        let result = BasicTable::new(pivots, offsets);
270        assert!(result.is_err(), "pivots is empty");
271        assert_eq!(result.unwrap_err().to_string(), "pivots cannot be empty",);
272    }
273
274    #[test]
275    fn basic_table() {
276        let mut rng = rand::rngs::StdRng::seed_from_u64(0xd96bac968083ec29);
277        for dim in [5, 10, 12] {
278            for total in [1, 2, 3] {
279                let pivots = rowmajor::Owned::from_fn(total, dim, |_| -> f32 {
280                    StandardUniform {}.sample(&mut rng)
281                });
282                let offsets = crate::views::ChunkOffsets::new(Box::new([0, 1, 3, dim])).unwrap();
283
284                let table = BasicTable::new(pivots.clone(), offsets.clone()).unwrap();
285
286                assert_eq!(table.ncenters(), total);
287                assert_eq!(table.nchunks(), offsets.len());
288                assert_eq!(table.dim(), offsets.dim());
289                assert_eq!(table.view_pivots().as_view(), pivots.as_view());
290                assert_eq!(table.view_offsets().as_view(), offsets.as_view());
291            }
292        }
293    }
294
295    /////////////////
296    // Compression //
297    /////////////////
298
299    #[test]
300    fn test_happy_path() {
301        // Feed in chunks of dimension 1, 2, 3, ... 16.
302        //
303        // If we're using MIRI, max out at 7 dimensions.
304        let offsets: Vec<usize> = if cfg!(miri) {
305            vec![0, 1, 3, 6, 10, 15, 21, 28, 36]
306        } else {
307            vec![
308                0, 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 66, 78, 91, 105, 120, 136,
309            ]
310        };
311
312        let schema = crate::views::ChunkOffsetsView::new(&offsets).unwrap();
313        let mut rng = rand::rngs::StdRng::seed_from_u64(0xda5b2e661eabacea);
314
315        let num_data = 20;
316        let num_trials = if cfg!(miri) { 1 } else { 10 };
317
318        for &num_centers in [16, 24, 13, 17].iter() {
319            for trial in 0..num_trials {
320                let context = lazy_format!(
321                    "happy path, num centers = {}, num data = {}, trial = {}",
322                    num_centers,
323                    num_data,
324                    trial,
325                );
326
327                println!("Currently = {}", context);
328
329                let (pivots, offsets) = create_pivot_tables(schema.to_owned(), num_centers);
330                let table = BasicTable::new(pivots, offsets).unwrap();
331
332                // Check that `pivots_for` works as expected with respect to the documented
333                // table configuration for `create_pivot_tables`.
334                for chunk in 0..schema.len() {
335                    let strided = table.pivots_for(chunk).unwrap();
336                    assert_eq!(strided.nrows(), num_centers);
337                    assert_eq!(strided.ncols(), schema.at(chunk).len());
338
339                    for (center, row) in strided.rows().enumerate() {
340                        let base = ((center + chunk) % num_centers) as f32;
341                        row.iter().enumerate().for_each(|(dim, b)| {
342                            let offset = if dim.is_multiple_of(2) { 0.25 } else { -0.25 };
343                            assert_eq!(
344                                *b,
345                                base + offset,
346                                "failed: chunk {} of {}, center {} of {}, dim {} of {}",
347                                chunk,
348                                schema.len(),
349                                center,
350                                num_centers,
351                                dim,
352                                row.len(),
353                            );
354                        })
355                    }
356                }
357
358                assert!(
359                    table.pivots_for(schema.len()).is_none(),
360                    "out of bounds access should return `None`",
361                );
362
363                let (data, expected) = create_dataset(schema, num_centers, num_data, &mut rng);
364
365                let mut output = vec![0; schema.len()];
366                for (input, expected) in std::iter::zip(data.rows(), expected.rows()) {
367                    table.compress_into(input, &mut output).unwrap();
368                    for (entry, (e, o)) in
369                        std::iter::zip(expected.iter(), output.iter()).enumerate()
370                    {
371                        let o: usize = (*o).into();
372                        assert_eq!(*e, o, "unexpected assignment at dim {}", entry);
373                    }
374                }
375            }
376        }
377    }
378
379    #[test]
380    fn test_compression_error() {
381        let dim = 10;
382        let num_chunks = 3;
383        let offsets = crate::views::ChunkOffsets::new(Box::new([0, 4, 9, 10])).unwrap();
384
385        // Set up `ncenters > 256`.
386        {
387            let pivots = rowmajor::Owned::from_element(257, dim, 0.0);
388            let table = BasicTable::new(pivots, offsets.clone()).unwrap();
389
390            let input = vec![f32::default(); dim];
391            let mut output = vec![u8::MAX; num_chunks];
392            let result = table.compress_into(&input, &mut output);
393            assert!(result.is_err());
394            assert_eq!(
395                result.unwrap_err().to_string(),
396                "num centers (257) must be at most 256 to compress into a byte vector"
397            );
398            assert!(
399                output.iter().all(|i| *i == u8::MAX),
400                "output vector should be unmodified"
401            );
402        }
403
404        // Setup input dim not equal to expected.
405        {
406            let pivots = rowmajor::Owned::from_element(10, dim, 0.0);
407            let table = BasicTable::new(pivots, offsets.clone()).unwrap();
408
409            let input = vec![f32::default(); dim - 1];
410            let mut output = vec![u8::MAX; num_chunks];
411            let result = table.compress_into(&input, &mut output);
412            assert!(result.is_err());
413            assert_eq!(
414                result.unwrap_err().to_string(),
415                format!("invalid input len - expected {}, got {}", dim, dim - 1),
416            );
417            assert!(
418                output.iter().all(|i| *i == u8::MAX),
419                "output vector should be unmodified"
420            );
421        }
422
423        // Setup output dim not equal to expected.
424        {
425            let pivots = rowmajor::Owned::from_element(10, dim, 0.0);
426            let table = BasicTable::new(pivots, offsets.clone()).unwrap();
427
428            let input = vec![f32::default(); dim];
429            let mut output = vec![u8::MAX; num_chunks - 1];
430            let result = table.compress_into(&input, &mut output);
431            assert!(result.is_err());
432            assert_eq!(
433                result.unwrap_err().to_string(),
434                format!(
435                    "invalid PQ buffer len - expected {}, got {}",
436                    num_chunks,
437                    num_chunks - 1
438                ),
439            );
440            assert!(
441                output.iter().all(|i| *i == u8::MAX),
442                "output vector should be unmodified"
443            );
444        }
445    }
446
447    #[test]
448    fn test_table_single_compression_errors() {
449        check_pqtable_single_compression_errors(
450            &|pivots: rowmajor::Owned<f32>, offsets| BasicTable::new(pivots, offsets).unwrap(),
451            &"BasicTable",
452        )
453    }
454}