Skip to main content

diskann_quantization/product/tables/
lookup.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use diskann_utils::views::rowmajor::{self, Matrix};
7use thiserror::Error;
8
9/// Policy for processing entries in [`lookup_single`].
10///
11/// The lookup operation may be performed by using multiple independent accumulators, each
12/// processing a subset of the total lookup operation.
13pub trait Lookup<T> {
14    /// The type of the accumulator.
15    ///
16    /// Multiple such accumulators may be instantiated.
17    type Accumulator;
18
19    /// The type of the final result.
20    type Output;
21
22    /// Create a default accumulator.
23    fn default(&self) -> Self::Accumulator;
24
25    /// Accumulate the element `v` with the current accumulator.
26    fn accumulate(&self, v: &T, acc: Self::Accumulator) -> Self::Accumulator;
27
28    /// Combine the results of two independent accumulators.
29    fn reduce(&self, a: Self::Accumulator, b: Self::Accumulator) -> Self::Accumulator;
30
31    /// Process the final accumulator, returning the result.
32    fn finish(self, acc: Self::Accumulator) -> Self::Output;
33}
34
35/// Use `indices` to retrieve one item from each row of `data` and accumulate the results
36/// across all rows using `policy`.
37/// ```
38/// use diskann_quantization::product::tables::lookup;
39/// use diskann_utils::views::rowmajor::{Owned, Matrix};
40///
41/// // Make the following matrix:
42/// // 0 1
43/// // 2 3
44/// // 4 5
45/// let data = Owned::from_fn(3, 2, |rc| 2 * rc.row + rc.col);
46/// let sum = lookup::lookup_single(
47///     lookup::Sum,
48///     data.as_view(),
49///     &[0, 1, 0]
50/// ).unwrap();
51///
52/// assert_eq!(sum, 0 + 3 + 4);
53/// ```
54///
55/// # Errors
56///
57/// Returns an error if `indices.len() != data.nrows()` (there must be a one-to-one
58/// correspondence between indices and rows) or if any index in `indices` exceeds `data.ncols()`.
59pub fn lookup_single<P, T>(
60    policy: P,
61    data: rowmajor::Ref<'_, T>,
62    indices: &[u8],
63) -> Result<P::Output, LookupError>
64where
65    P: Lookup<T>,
66{
67    // Check 1.
68    if indices.len() != data.nrows() {
69        return Err(LookupError::InvalidLength);
70    }
71
72    // Check 2.
73    //
74    // Conversion fails if `data.ncols()` is 256 or greater.
75    //
76    // In this case, all indices will be in bounds anyway.
77    if let Ok(ncols) = u8::try_from(data.ncols())
78        && let Some(max) = indices.iter().max()
79        && *max >= ncols
80    {
81        return Err(LookupError::OutOfBounds);
82    }
83
84    const UNROLL: usize = 4;
85
86    let mut i = 0;
87    let mut a = if data.nrows() >= UNROLL {
88        let mut a0 = policy.default();
89        let mut a1 = policy.default();
90        let mut a2 = policy.default();
91        let mut a3 = policy.default();
92
93        while i + 4 <= data.nrows() {
94            // SAFETY: Since `i + 4 <= data.nrows()`, the row index `i` is in-bounds. Check 1
95            // ensures that `i` is also a valid index for `indices`.
96            //
97            // Check 2 guarantees that `indices[i] < data.ncols()`, so the matrix access is
98            // valid.
99            let v0 = unsafe { data.element_unchecked(i, (*indices.get_unchecked(i)).into()) };
100            a0 = policy.accumulate(v0, a0);
101
102            // SAFETY: Same as above.
103            let v1 =
104                unsafe { data.element_unchecked(i + 1, (*indices.get_unchecked(i + 1)).into()) };
105            a1 = policy.accumulate(v1, a1);
106
107            // SAFETY: Same as above.
108            let v2 =
109                unsafe { data.element_unchecked(i + 2, (*indices.get_unchecked(i + 2)).into()) };
110            a2 = policy.accumulate(v2, a2);
111
112            // SAFETY: Same as above.
113            let v3 =
114                unsafe { data.element_unchecked(i + 3, (*indices.get_unchecked(i + 3)).into()) };
115            a3 = policy.accumulate(v3, a3);
116
117            i += UNROLL;
118        }
119
120        policy.reduce(policy.reduce(a0, a1), policy.reduce(a2, a3))
121    } else {
122        policy.default()
123    };
124
125    if let Some(remainder) = data.nrows().checked_sub(i) {
126        // Hint to LLVM that the loop below is bounded.
127        let remainder = remainder.min(UNROLL - 1);
128        for j in 0..remainder {
129            let k = i + j;
130
131            // SAFETY: See justification in the main unrolled loop.
132            let v = unsafe { data.element_unchecked(k, (*indices.get_unchecked(k)).into()) };
133            a = policy.accumulate(v, a);
134        }
135    }
136
137    Ok(policy.finish(a))
138}
139
140/// Errors from [`lookup_single`].
141#[derive(Debug, Error, Clone, Copy)]
142#[non_exhaustive]
143pub enum LookupError {
144    #[error("number of lookup indices does not match the number of data rows")]
145    InvalidLength,
146    #[error("at least one of the lookup indices exceeds the number of data columns")]
147    OutOfBounds,
148}
149
150/// A simple [`Lookup`] that uses `std::ops::Add` to accumulate results.
151#[derive(Debug, Clone, Copy)]
152pub struct Sum;
153
154impl<T> Lookup<T> for Sum
155where
156    T: Default + std::ops::Add<Output = T> + Copy,
157{
158    type Accumulator = T;
159    type Output = T;
160
161    fn default(&self) -> T {
162        T::default()
163    }
164
165    fn accumulate(&self, v: &T, acc: T) -> T {
166        *v + acc
167    }
168
169    fn reduce(&self, a: T, b: T) -> T {
170        a + b
171    }
172
173    fn finish(self, acc: T) -> T {
174        acc
175    }
176}
177
178/// An element for [`lookup_single`] that is used for computing cosine similarity.
179///
180/// Each [`DotAndNorm`] consists of a partial dot-product (e.g. the dot-product between a
181/// query chunk and a PQ center) as well as the PQ center's squared norm.
182///
183/// After the lookup operation, the final [`DotAndNorm`] consists of the dot-product between
184/// the query and the effective data vector as well as the total squared norm of the effective
185/// data vector.
186#[derive(Debug, Clone, Copy, Default)]
187#[repr(C)]
188pub struct DotAndNorm {
189    dot: f32,
190    square_norm: f32,
191}
192
193impl DotAndNorm {
194    /// Construct a new [`DotAndNorm`].
195    pub const fn new(dot: f32, square_norm: f32) -> Self {
196        Self { dot, square_norm }
197    }
198
199    /// Return the current value of the dot-product.
200    pub fn dot(&self) -> f32 {
201        self.dot
202    }
203
204    /// Return the current value of the squared norm.
205    pub fn square_norm(&self) -> f32 {
206        self.square_norm
207    }
208
209    /// Finish a cosine computation using `query_norm`. This computes:
210    /// ```math
211    /// 1.0 - (self.dot) / (self.square_norm.sqrt() * query_norm)
212    /// ```
213    /// taking care to avoid division by zero.
214    ///
215    /// Note that this returns a [`diskann_vector::SimilarityScore`] for use in similarity
216    /// reranking.
217    pub fn finish_cosine(&self, query_norm: f32) -> diskann_vector::SimilarityScore<f32> {
218        use diskann_vector::SimilarityScore;
219
220        if self.square_norm < f32::MIN_POSITIVE || query_norm < f32::MIN_POSITIVE {
221            SimilarityScore::new(1.0)
222        } else {
223            let v = self.dot / (self.square_norm.sqrt() * query_norm);
224            SimilarityScore::new(1.0 - (-1.0f32).max(1.0f32.min(v)))
225        }
226    }
227}
228
229impl std::ops::Add for DotAndNorm {
230    type Output = Self;
231    fn add(self, rhs: Self) -> Self {
232        Self {
233            dot: self.dot + rhs.dot,
234            square_norm: self.square_norm + rhs.square_norm,
235        }
236    }
237}
238
239///////////
240// Tests //
241///////////
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    use diskann_utils::assert_contains;
248    use rand::{
249        SeedableRng,
250        distr::{Distribution, Uniform},
251        rngs::StdRng,
252    };
253
254    fn expected_sum(x: &[u8]) -> f32 {
255        let mut sum = 0.0;
256        for (i, v) in x.iter().enumerate() {
257            sum += (i as f32) + (*v as f32)
258        }
259        sum
260    }
261
262    #[test]
263    fn test_lookup_sum() {
264        let ntrials = if cfg!(miri) { 1 } else { 10 };
265
266        let mut rng = StdRng::seed_from_u64(0xd0cc501bde4c9ddd);
267        for nrows in 0..12 {
268            let mut codes = vec![0u8; nrows];
269            for ncols in [1, 2, 255, 256] {
270                let dist = if ncols == 0 {
271                    Uniform::new(0, 1).unwrap()
272                } else {
273                    Uniform::new(0, ncols).unwrap()
274                };
275
276                let table = rowmajor::Owned::from_fn(nrows, ncols, |rc| (rc.row + rc.col) as f32);
277
278                // If it's possible for a value to be out-of-bounds, make sure we return an
279                // error if anything *is* out-of-bounds.
280                if ncols < 256 {
281                    let nc = u8::try_from(ncols).unwrap();
282                    codes.fill(0);
283                    for r in 0..nrows {
284                        codes[r] = nc;
285                        let err = lookup_single(Sum, table.as_view(), &codes).unwrap_err();
286                        assert_contains!(
287                            err.to_string(),
288                            "at least one of the lookup indices exceeds the number of data columns"
289                        );
290                        codes[r] = 0;
291                    }
292                }
293
294                // Check that too long and too short codes are detected.
295                if nrows > 0 {
296                    let too_short = vec![0; nrows - 1];
297                    let err = lookup_single(Sum, table.as_view(), &too_short).unwrap_err();
298                    assert_contains!(err.to_string(), "number of lookup indices does not match");
299                }
300
301                {
302                    let too_long = vec![0; nrows + 1];
303                    let err = lookup_single(Sum, table.as_view(), &too_long).unwrap_err();
304                    assert_contains!(err.to_string(), "number of lookup indices does not match");
305                }
306
307                // Test all zeros
308                codes.iter_mut().for_each(|c| *c = 0);
309                assert_eq!(
310                    lookup_single(Sum, table.as_view(), &codes).unwrap(),
311                    expected_sum(&codes),
312                    "all zeros - nrows = {nrows}, ncols = {ncols}",
313                );
314
315                // Test all max
316                codes
317                    .iter_mut()
318                    .for_each(|c| *c = (ncols - 1).try_into().unwrap());
319                assert_eq!(
320                    lookup_single(Sum, table.as_view(), &codes).unwrap(),
321                    expected_sum(&codes),
322                    "all max - nrows = {nrows}, ncols = {ncols}",
323                );
324
325                for trial in 0..ntrials {
326                    codes
327                        .iter_mut()
328                        .for_each(|c| *c = u8::try_from(dist.sample(&mut rng)).unwrap());
329
330                    println!("codes = {:?}, ncols = {}", codes, ncols);
331
332                    assert_eq!(
333                        lookup_single(Sum, table.as_view(), &codes).unwrap(),
334                        expected_sum(&codes),
335                        "nrows = {nrows}, ncols = {ncols}, trial = {} of {}",
336                        trial + 1,
337                        ntrials,
338                    );
339                }
340            }
341        }
342    }
343}