Skip to main content

diskann_disk/utils/
math_util.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5#![warn(missing_debug_implementations, missing_docs)]
6
7//! Mathematical utilities for distance computations and center finding.
8//!
9//! This module contains optimized functions for computing squared L2 norms,
10//! finding closest centers, and processing residuals. These are primarily
11//! used in k-means clustering and disk index partitioning.
12
13use std::{cmp::Ordering, collections::BinaryHeap};
14
15use diskann::{ANNError, ANNResult};
16use diskann_linalg::{self, Transpose};
17use diskann_providers::{
18    forward_threadpool,
19    utils::{AsThreadPool, ParallelIteratorInPool, RayonThreadPool},
20};
21use rayon::prelude::*;
22
23// This is the chunk size applied when computing the closest centers in a block.
24// The chunk size is the number of points to process in a single iteration to reduce memory usage of
25// distance_matrix.
26// 1200 is a number we tested to be optimal for the number of points in a chunk that
27// * Large enough to take advantage of BLAS operations
28// * Small enough to avoid hefty memory allocations
29// the experiment performance of pq construction:
30// | Chunk Size   |1087932vector384dim  |8717820vector384dim  |
31// |--------------|---------------------|---------------------|
32// | 1            | 169.082s/3.181GB    | 202.175s/2.892GB    |
33// | 2            | 156.726s/1.704GB    | 189.860s/1.444GB    |
34// | 8            | 151.853s/0.996GB    | 185.035s/0.838GB    |
35// | 16           | 145.725s/0.995GB    | 185.756s/0.831GB    |
36// | 32           | 122.644s/0.996GB    | 141.831s/0.841GB    |
37// | 64           | 83.927s/0.994GB     | 97.761s/0.840GB     |
38// | 128          | 64.404s/0.994GB     | 79s/0.841GB         |
39// | 256          | 59.662s/0.995GB     | 73s/0.841GB         |
40// | 512          | 58.331s/0.996GB     | 70.552s/0.819GB     |
41// we are currently using the chunk size of 256 (about 1200 (256000 train data / 256))
42// test results are collected from i9-10900X 3.7GHz 10 cores 20 threads 32GB RAM
43// key parameters -M 1000 -R 59 -L 64 -T 8 -B 0.195 --dist_fn CosineNormalized
44const POINTS_PER_CHUNK: usize = 1200;
45
46struct PivotContainer {
47    piv_id: usize,
48    piv_dist: f32,
49}
50
51/// The PartialOrd trait is for types that can be partially ordered, i.e., where some pairs of values are incomparable (like with floating-point numbers when one of them is NaN).
52/// So the correct way to implement PartialOrd for a type that has Ord is to use self.cmp(other) directly.
53impl PartialOrd for PivotContainer {
54    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
55        Some(self.cmp(other))
56    }
57}
58
59/// The Ord trait is for types that have a total order, where every pair of values is comparable.
60impl Ord for PivotContainer {
61    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
62        // Treat NaN as less than all other values.
63        // piv_dist should never be NaN.
64        other
65            .piv_dist
66            .partial_cmp(&self.piv_dist)
67            .unwrap_or(Ordering::Less)
68    }
69}
70
71impl PartialEq for PivotContainer {
72    fn eq(&self, other: &Self) -> bool {
73        self.piv_dist == other.piv_dist
74    }
75}
76
77impl Eq for PivotContainer {}
78
79/// The implementation of computing L2-squared norm of a vector
80fn compute_vec_l2sq(data: &[f32], index: usize, dim: usize) -> f32 {
81    let start = index * dim;
82    let slice = unsafe { std::slice::from_raw_parts(data.as_ptr().add(start), dim) };
83    let mut sum_squared = 0.0;
84    for &value in slice {
85        sum_squared += value * value;
86    }
87
88    sum_squared
89}
90
91/// Compute L2-squared norms of data stored in row-major num_points * dim,
92/// need to be pre-allocated
93pub fn compute_vecs_l2sq<Pool: AsThreadPool>(
94    vecs_l2sq: &mut [f32],
95    data: &[f32],
96    num_points: usize,
97    dim: usize,
98    pool: Pool,
99) -> ANNResult<()> {
100    if data.len() != num_points * dim {
101        return Err(ANNError::log_pq_error(format_args!(
102            "data.len() {} should be num_points {} * dim {}",
103            data.len(),
104            num_points,
105            dim
106        )));
107    }
108
109    if dim < 5 {
110        for (i, vec_l2sq) in vecs_l2sq.iter_mut().enumerate() {
111            *vec_l2sq = compute_vec_l2sq(data, i, dim);
112        }
113    } else {
114        forward_threadpool!(pool = pool);
115        vecs_l2sq
116            .par_iter_mut()
117            .enumerate()
118            .for_each_in_pool(pool, |(i, vec_l2sq)| {
119                *vec_l2sq = compute_vec_l2sq(data, i, dim);
120            });
121    }
122
123    Ok(())
124}
125
126/// Calculate k closest centers to data of num_points * dim (row-major)
127/// Centers is num_centers * dim (row-major)
128/// data_l2sq has pre-computed squared norms of data
129/// centers_l2sq has pre-computed squared norms of centers
130/// Pre-allocated center_index will contain id of nearest center
131/// Pre-allocated dist_matrix should be num_points * num_centers and contain squared distances
132/// Default value of k is 1
133/// Ideally used only by compute_closest_centers
134#[allow(clippy::too_many_arguments)]
135pub fn compute_closest_centers_in_block(
136    data: &[f32],
137    num_points: usize,
138    dim: usize,
139    centers: &[f32],
140    num_centers: usize,
141    docs_l2sq: &[f32],
142    centers_l2sq: &[f32],
143    center_index: &mut [u32],
144    dist_matrix: &mut [f32],
145    k: usize,
146    pool: &RayonThreadPool,
147) -> ANNResult<()> {
148    if k > num_centers {
149        return Err(ANNError::log_index_error(format_args!(
150            "ERROR: k ({}) > num_centers({})",
151            k, num_centers
152        )));
153    }
154
155    let ones_a: Vec<f32> = vec![1.0; num_centers];
156    let ones_b: Vec<f32> = vec![1.0; num_points];
157
158    diskann_linalg::sgemm(
159        Transpose::None,
160        Transpose::Ordinary,
161        num_points,
162        num_centers,
163        1,
164        1.0,
165        docs_l2sq,
166        &ones_a,
167        None, // Initialize the destination matrix
168        dist_matrix,
169    );
170
171    diskann_linalg::sgemm(
172        Transpose::None,
173        Transpose::Ordinary,
174        num_points,
175        num_centers,
176        1,
177        1.0,
178        &ones_b,
179        centers_l2sq,
180        Some(1.0), // Add to the destination matrix
181        dist_matrix,
182    );
183
184    diskann_linalg::sgemm(
185        Transpose::None,
186        Transpose::Ordinary,
187        num_points,
188        num_centers,
189        dim,
190        -2.0,
191        data,
192        centers,
193        Some(1.0), // Add to the destination matrix.
194        dist_matrix,
195    );
196
197    if k == 1 {
198        center_index
199            .par_iter_mut()
200            .enumerate()
201            .for_each_in_pool(pool, |(i, center_idx)| {
202                let mut min = f32::MAX;
203                let current = &dist_matrix[i * num_centers..(i + 1) * num_centers];
204                let mut min_idx = 0;
205                for (j, &distance) in current.iter().enumerate() {
206                    if distance < min {
207                        min = distance;
208                        min_idx = j;
209                    }
210                }
211                *center_idx = min_idx as u32;
212            });
213    } else {
214        center_index
215            .par_chunks_mut(k)
216            .enumerate()
217            .for_each_in_pool(pool, |(i, center_chunk)| {
218                let current = &dist_matrix[i * num_centers..(i + 1) * num_centers];
219                let mut top_k_queue = BinaryHeap::new();
220                for (j, &distance) in current.iter().enumerate() {
221                    let this_piv = PivotContainer {
222                        piv_id: j,
223                        piv_dist: distance,
224                    };
225                    top_k_queue.push(this_piv);
226                }
227                for center_idx in center_chunk.iter_mut() {
228                    if let Some(this_piv) = top_k_queue.pop() {
229                        *center_idx = this_piv.piv_id as u32;
230                    } else {
231                        break;
232                    }
233                }
234            });
235    }
236
237    Ok(())
238}
239
240/// Given data in num_points * new_dim row major
241/// Pivots stored in full_pivot_data as num_centers * new_dim row major
242/// Calculate the k closest pivot for each point and store it in vector
243/// closest_centers_ivf (row major, num_points*k) (which needs to be allocated
244/// outside) Additionally, if inverted index is not null (and pre-allocated),
245/// it will return inverted index for each center, assuming each of the inverted
246/// indices is an empty vector. Additionally, if pts_norms_squared is not null,
247/// then it will assume that point norms are pre-computed and use those values
248#[allow(clippy::too_many_arguments)]
249pub fn compute_closest_centers<Pool: AsThreadPool>(
250    data: &[f32],
251    num_points: usize,
252    dim: usize,
253    pivot_data: &[f32],
254    num_centers: usize,
255    k: usize,
256    closest_centers_ivf: &mut [u32],
257    mut inverted_index: Option<&mut Vec<Vec<usize>>>,
258    pts_norms_squared: Option<&[f32]>,
259    pool: Pool,
260) -> ANNResult<()> {
261    if k > num_centers {
262        return Err(ANNError::log_index_error(format_args!(
263            "ERROR: k ({}) > num_centers({})",
264            k, num_centers
265        )));
266    }
267
268    forward_threadpool!(pool = pool);
269
270    let pts_norms_squared = if let Some(pts_norms) = pts_norms_squared {
271        pts_norms.to_vec()
272    } else {
273        let mut norms_squared = vec![0.0; num_points];
274        compute_vecs_l2sq(&mut norms_squared, data, num_points, dim, pool)?;
275        norms_squared
276    };
277
278    let mut pivs_norms_squared = vec![0.0; num_centers];
279    compute_vecs_l2sq(&mut pivs_norms_squared, pivot_data, num_centers, dim, pool)?;
280
281    let mut distance_matrix = vec![0.0; POINTS_PER_CHUNK * num_centers];
282    let mut closest_center_indices = vec![0; POINTS_PER_CHUNK * k];
283    let pts_norms_squared_chunks = pts_norms_squared.chunks(POINTS_PER_CHUNK);
284
285    for (chunk_index, (data_chunk, pts_norms_squared_chunk)) in data
286        .chunks(dim * POINTS_PER_CHUNK)
287        .zip(pts_norms_squared_chunks)
288        .enumerate()
289    {
290        // actual chunk size maybe less than the pt_num_per_chunk for the last chunk
291        let chunk_size = data_chunk.len() / dim;
292
293        // Potentially shrink scratch data structures.
294        let this_distance_matrix = &mut distance_matrix[..num_centers * chunk_size];
295        let this_closest_center_indices = &mut closest_center_indices[..k * chunk_size];
296
297        compute_closest_centers_in_block(
298            data_chunk,
299            chunk_size,
300            dim,
301            pivot_data,
302            num_centers,
303            pts_norms_squared_chunk,
304            &pivs_norms_squared,
305            this_closest_center_indices,
306            this_distance_matrix,
307            k,
308            pool,
309        )?;
310
311        let point_start_index = chunk_index * POINTS_PER_CHUNK;
312
313        for point_index in point_start_index..point_start_index + chunk_size {
314            for l in 0..k {
315                let center_chunk_index = (point_index - point_start_index) * k + l;
316                let ivf_index = point_index * k + l;
317
318                let this_center_index = closest_center_indices[center_chunk_index];
319                closest_centers_ivf[ivf_index] = this_center_index;
320
321                if let Some(inverted_index) = &mut inverted_index {
322                    inverted_index[this_center_index as usize].push(point_index);
323                }
324            }
325        }
326    }
327    Ok(())
328}
329
330#[cfg(test)]
331mod math_util_test {
332    use approx::assert_abs_diff_eq;
333
334    use super::*;
335    use diskann_providers::utils::create_thread_pool_for_test;
336
337    #[test]
338    fn partial_ord_test() {
339        let pviot1 = PivotContainer {
340            piv_id: 2,
341            piv_dist: f32::NAN,
342        };
343        let pivot2 = PivotContainer {
344            piv_id: 1,
345            piv_dist: 1.0,
346        };
347
348        assert_eq!(pviot1.partial_cmp(&pivot2), Some(Ordering::Less));
349    }
350
351    #[test]
352    fn ord_test() {
353        let pviot1 = PivotContainer {
354            piv_id: 1,
355            piv_dist: f32::NAN,
356        };
357        let pivot2 = PivotContainer {
358            piv_id: 2,
359            piv_dist: 1.0,
360        };
361
362        assert_eq!(pviot1.cmp(&pivot2), Ordering::Less);
363    }
364
365    #[test]
366    fn compute_vecs_l2sq_small_dim_test() {
367        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
368        let num_points = 2;
369        let dim = 3;
370        let mut vecs_l2sq = vec![0.0; num_points];
371        let pool = create_thread_pool_for_test();
372
373        compute_vecs_l2sq(&mut vecs_l2sq, &data, num_points, dim, &pool).unwrap();
374
375        let expected = [14.0, 77.0];
376
377        assert_eq!(vecs_l2sq.len(), num_points);
378        assert_abs_diff_eq!(vecs_l2sq[0], expected[0], epsilon = 1e-6);
379        assert_abs_diff_eq!(vecs_l2sq[1], expected[1], epsilon = 1e-6);
380    }
381
382    #[test]
383    fn compute_vecs_l2sq_large_dim_test() {
384        let data = vec![
385            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
386        ];
387        let num_points = 2;
388        let dim = 8;
389        let mut vecs_l2sq = vec![0.0; num_points];
390        let pool = create_thread_pool_for_test();
391        compute_vecs_l2sq(&mut vecs_l2sq, &data, num_points, dim, &pool).unwrap();
392
393        let expected = [204.0, 1292.0];
394
395        assert_eq!(vecs_l2sq.len(), num_points);
396        assert_abs_diff_eq!(vecs_l2sq[0], expected[0], epsilon = 1e-6);
397        assert_abs_diff_eq!(vecs_l2sq[1], expected[1], epsilon = 1e-6);
398    }
399
400    #[test]
401    fn compute_closest_centers_in_block_test() {
402        let num_points = 10;
403        let dim = 5;
404        let num_centers = 3;
405        let data = vec![
406            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0,
407            17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0, 29.0, 30.0,
408            31.0, 32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0, 40.0, 41.0, 42.0, 43.0, 44.0,
409            45.0, 46.0, 47.0, 48.0, 49.0, 50.0,
410        ];
411        let centers = vec![
412            1.0, 2.0, 3.0, 4.0, 5.0, 21.0, 22.0, 23.0, 24.0, 25.0, 31.0, 32.0, 33.0, 34.0, 35.0,
413        ];
414        let mut docs_l2sq = vec![0.0; num_points];
415        let pool = create_thread_pool_for_test();
416        compute_vecs_l2sq(&mut docs_l2sq, &data, num_points, dim, &pool).unwrap();
417        let mut centers_l2sq = vec![0.0; num_centers];
418        compute_vecs_l2sq(&mut centers_l2sq, &centers, num_centers, dim, &pool).unwrap();
419        let mut center_index = vec![0; num_points];
420        let mut dist_matrix = vec![0.0; num_points * num_centers];
421        let k = 1;
422
423        compute_closest_centers_in_block(
424            &data,
425            num_points,
426            dim,
427            &centers,
428            num_centers,
429            &docs_l2sq,
430            &centers_l2sq,
431            &mut center_index,
432            &mut dist_matrix,
433            k,
434            &pool,
435        )
436        .unwrap();
437
438        assert_eq!(center_index.len(), num_points);
439        let expected_center_index = vec![0, 0, 0, 1, 1, 1, 2, 2, 2, 2];
440        assert_abs_diff_eq!(*center_index, expected_center_index);
441
442        assert_eq!(dist_matrix.len(), num_points * num_centers);
443        let expected_dist_matrix = vec![
444            0.0, 2000.0, 4500.0, 125.0, 1125.0, 3125.0, 500.0, 500.0, 2000.0, 1125.0, 125.0,
445            1125.0, 2000.0, 0.0, 500.0, 3125.0, 125.0, 125.0, 4500.0, 500.0, 0.0, 6125.0, 1125.0,
446            125.0, 8000.0, 2000.0, 500.0, 10125.0, 3125.0, 1125.0,
447        ];
448        assert_abs_diff_eq!(*dist_matrix, expected_dist_matrix, epsilon = 1e-2);
449    }
450
451    #[test]
452    fn compute_closest_centers_in_block_test_k_equals_two() {
453        let num_points = 2;
454        let dim = 5;
455        let num_centers = 4;
456        let data = vec![41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0, 49.0, 50.0];
457        let centers = vec![
458            1.0, 2.0, 3.0, 4.0, 5.0, 21.0, 22.0, 23.0, 24.0, 25.0, 31.0, 32.0, 33.0, 34.0, 35.0,
459            46.0, 47.0, 48.0, 49.0, 50.0,
460        ];
461        let mut docs_l2sq = vec![0.0; num_points];
462        let pool = create_thread_pool_for_test();
463        compute_vecs_l2sq(&mut docs_l2sq, &data, num_points, dim, &pool).unwrap();
464        let mut centers_l2sq = vec![0.0; num_centers];
465        compute_vecs_l2sq(&mut centers_l2sq, &centers, num_centers, dim, &pool).unwrap();
466        let k = 2;
467        let mut center_index = vec![0; num_points * k];
468        let mut dist_matrix = vec![0.0; num_points * num_centers];
469
470        compute_closest_centers_in_block(
471            &data,
472            num_points,
473            dim,
474            &centers,
475            num_centers,
476            &docs_l2sq,
477            &centers_l2sq,
478            &mut center_index,
479            &mut dist_matrix,
480            k,
481            &pool,
482        )
483        .unwrap();
484
485        assert_eq!(center_index.len(), num_points * k);
486        let expected_center_index = vec![3, 2, 3, 2];
487        assert_abs_diff_eq!(*center_index, expected_center_index);
488
489        assert_eq!(dist_matrix.len(), num_points * num_centers);
490        // obviously, the order of distance [8000.0, 2000.0, 500.0, 125.0], is #3, #2, #1, #0
491        // so the top 2 closest centers for the first point are #3, #2
492        // obviously, the order of distance [10125.0, 3125.0, 1125.0, 0.0], is #3, #2, #1, #0
493        // so the top 2 closest centers for the second point are #3, #2
494        let expected_dist_matrix = vec![8000.0, 2000.0, 500.0, 125.0, 10125.0, 3125.0, 1125.0, 0.0];
495        assert_abs_diff_eq!(*dist_matrix, expected_dist_matrix, epsilon = 1e-2);
496    }
497
498    #[test]
499    fn test_compute_closest_centers() {
500        let num_points = 4;
501        let dim = 3;
502        let num_centers = 2;
503        let data = vec![
504            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
505        ];
506        let pivot_data = vec![1.0, 2.0, 3.0, 10.0, 11.0, 12.0];
507        let k = 1;
508
509        let mut closest_centers_ivf = vec![0u32; num_points * k];
510        let mut inverted_index: Vec<Vec<usize>> = vec![vec![], vec![]];
511        let pool = create_thread_pool_for_test();
512        compute_closest_centers(
513            &data,
514            num_points,
515            dim,
516            &pivot_data,
517            num_centers,
518            k,
519            &mut closest_centers_ivf,
520            Some(&mut inverted_index),
521            None,
522            &pool,
523        )
524        .unwrap();
525
526        assert_eq!(closest_centers_ivf, vec![0, 0, 1, 1]);
527
528        for vec in inverted_index.iter_mut() {
529            vec.sort_unstable();
530        }
531        assert_eq!(inverted_index, vec![vec![0, 1], vec![2, 3]]);
532    }
533}