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