Skip to main content

diskann_quantization/product/
train.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use diskann_utils::{
7    strided::Strided,
8    views::{
9        self,
10        rowmajor::{self, Matrix, MatrixMut},
11    },
12};
13#[cfg(feature = "rayon")]
14use rayon::iter::{IntoParallelIterator, ParallelIterator};
15use thiserror::Error;
16
17use crate::{
18    Parallelism,
19    algorithms::kmeans::{self, common::square_norm},
20    cancel::Cancelation,
21    multi_vector::BlockTransposed,
22    random::{BoxedRngBuilder, RngBuilder},
23};
24
25pub struct LightPQTrainingParameters {
26    /// The number of centers for each partition.
27    ncenters: usize,
28    /// The maximum number of iterations for Lloyd's algorithm.
29    lloyds_reps: usize,
30}
31
32impl LightPQTrainingParameters {
33    /// Construct a new light-weight PQ trainer.
34    pub fn new(ncenters: usize, lloyds_reps: usize) -> Self {
35        Self {
36            ncenters,
37            lloyds_reps,
38        }
39    }
40}
41
42#[derive(Debug)]
43pub struct SimplePivots {
44    dim: usize,
45    ncenters: usize,
46    pivots: Vec<rowmajor::Owned<f32>>,
47}
48
49fn flatten<T: Copy + Default>(
50    pivots: &[rowmajor::Owned<T>],
51    ncenters: usize,
52    dim: usize,
53) -> rowmajor::Owned<T> {
54    let mut flattened = rowmajor::Owned::from_element(ncenters, dim, T::default());
55    let mut col_start = 0;
56    for matrix in pivots {
57        assert_eq!(matrix.nrows(), flattened.nrows());
58        for (row_index, row) in matrix.rows().enumerate() {
59            let dst = &mut flattened.row_mut(row_index)[col_start..col_start + row.len()];
60            dst.copy_from_slice(row);
61        }
62        col_start += matrix.ncols();
63    }
64    flattened
65}
66
67impl SimplePivots {
68    /// Return the selected pivots for each chunk.
69    pub fn pivots(&self) -> &[rowmajor::Owned<f32>] {
70        &self.pivots
71    }
72
73    /// Concatenate the individual pivots into a dense representation.
74    pub fn flatten(&self) -> Vec<f32> {
75        flatten(self.pivots(), self.ncenters, self.dim)
76            .into_inner()
77            .into()
78    }
79}
80
81pub trait TrainQuantizer {
82    type Quantizer;
83    type Error: std::error::Error;
84
85    fn train<R, C>(
86        &self,
87        data: views::rowmajor::Ref<f32>,
88        schema: crate::views::ChunkOffsetsView<'_>,
89        parallelism: Parallelism,
90        rng_builder: &R,
91        cancelation: &C,
92    ) -> Result<Self::Quantizer, Self::Error>
93    where
94        R: RngBuilder<usize> + Sync,
95        C: Cancelation + Sync;
96}
97
98impl TrainQuantizer for LightPQTrainingParameters {
99    type Quantizer = SimplePivots;
100    type Error = PQTrainingError;
101
102    /// Perform product quantization training on the provided training set and return a
103    /// `SimplePivots` containing the result of kmeans clustering on each partition.
104    ///
105    /// # Panics
106    ///
107    /// Panics if `data.nrows() != schema.dim()`.
108    ///
109    /// # Errors
110    ///
111    /// An error type is returned under the following circumstances:
112    ///
113    /// * A cancellation request is received. This case can be queried by calling
114    ///   `was_canceled` on the returned `PQTrainingError`.
115    /// * `NaN` or infinities are observed during the training process.
116    fn train<R, C>(
117        &self,
118        data: views::rowmajor::Ref<f32>,
119        schema: crate::views::ChunkOffsetsView<'_>,
120        parallelism: Parallelism,
121        rng_builder: &R,
122        cancelation: &C,
123    ) -> Result<Self::Quantizer, Self::Error>
124    where
125        R: RngBuilder<usize> + Sync,
126        C: Cancelation + Sync,
127    {
128        // Inner method where we `dyn` away the cancellation token to reduce compile-times.
129        // Unfortunately, we can't quite do the same with the RngBuilder.
130        #[inline(never)]
131        fn train(
132            trainer: &LightPQTrainingParameters,
133            data: views::rowmajor::Ref<f32>,
134            schema: crate::views::ChunkOffsetsView<'_>,
135            parallelism: Parallelism,
136            rng_builder: &(dyn BoxedRngBuilder<usize> + Sync),
137            cancelation: &(dyn Cancelation + Sync),
138        ) -> Result<SimplePivots, PQTrainingError> {
139            // Make sure we're provided sane values for our schema.
140            assert_eq!(data.ncols(), schema.dim());
141
142            let thunk = |i| -> Result<rowmajor::Owned<f32>, PQTrainingError> {
143                let range = schema.at(i);
144
145                // Check for cancelation.
146                let exit_if_canceled = || -> Result<(), PQTrainingError> {
147                    if cancelation.should_cancel() {
148                        Err(PQTrainingError {
149                            chunk: i,
150                            of: schema.len(),
151                            dim: range.len(),
152                            kind: PQTrainingErrorKind::Canceled,
153                        })
154                    } else {
155                        Ok(())
156                    }
157                };
158
159                // This is an early check - if another task hit cancelation, this allows
160                // the remaining tasks to exit early.
161                exit_if_canceled()?;
162
163                let view = Strided::try_from_data(
164                    &(data.as_slice()[range.start..]),
165                    data.nrows(),
166                    range.len(),
167                    schema.dim(),
168                )
169                .map_err(|err| PQTrainingError {
170                    chunk: i,
171                    of: schema.len(),
172                    dim: range.len(),
173                    kind: PQTrainingErrorKind::InternalError(Box::new(err)),
174                })?;
175
176                // Allocate scratch data structures.
177                let norms: Vec<f32> = view.rows().map(square_norm).collect();
178                let transpose = BlockTransposed::<f32, 16>::from_strided(view);
179                let mut centers =
180                    rowmajor::Owned::try_from_element(trainer.ncenters, range.len(), 0.0).map_err(
181                        |err| PQTrainingError {
182                            chunk: i,
183                            of: schema.len(),
184                            dim: range.len(),
185                            kind: PQTrainingErrorKind::InternalError(Box::new(err)),
186                        },
187                    )?;
188
189                // Construct the random number generator seeded by the PQ chunk.
190                let mut rng = rng_builder.build_boxed_rng(i);
191
192                // Initialization
193                kmeans::plusplus::kmeans_plusplus_into_inner(
194                    centers.as_view_mut(),
195                    view,
196                    transpose.as_view(),
197                    &norms,
198                    &mut rng,
199                )
200                .or_else(|err| {
201                    // Suppress recoverable errors.
202                    if !err.is_numerically_recoverable() {
203                        Err(PQTrainingError {
204                            chunk: i,
205                            of: schema.len(),
206                            dim: range.len(),
207                            kind: PQTrainingErrorKind::Initialization(Box::new(err)),
208                        })
209                    } else {
210                        Ok(())
211                    }
212                })?;
213
214                // Did a cancelation request come while runing `kmeans++`?
215                exit_if_canceled()?;
216
217                // Kmeans
218                kmeans::lloyds::lloyds_inner(
219                    view,
220                    &norms,
221                    transpose.as_view(),
222                    centers.as_view_mut(),
223                    trainer.lloyds_reps,
224                );
225                Ok(centers)
226            };
227
228            let pivots: Result<Vec<_>, _> = match parallelism {
229                Parallelism::Sequential => (0..schema.len()).map(thunk).collect(),
230
231                #[cfg(feature = "rayon")]
232                Parallelism::Rayon => (0..schema.len()).into_par_iter().map(thunk).collect(),
233            };
234
235            let dim = data.ncols();
236            let ncenters = trainer.ncenters;
237            Ok(SimplePivots {
238                dim,
239                ncenters,
240                pivots: pivots?,
241            })
242        }
243
244        train(self, data, schema, parallelism, rng_builder, cancelation)
245    }
246}
247
248#[derive(Debug, Error)]
249#[error("pq training failed on chunk {chunk} of {of} (dim {dim})")]
250pub struct PQTrainingError {
251    chunk: usize,
252    of: usize,
253    dim: usize,
254    #[source]
255    kind: PQTrainingErrorKind,
256}
257
258impl PQTrainingError {
259    /// Return whether or not this error originated as a cancelation request.
260    pub fn was_canceled(&self) -> bool {
261        matches!(self.kind, PQTrainingErrorKind::Canceled)
262    }
263}
264
265#[derive(Debug, Error)]
266#[non_exhaustive]
267enum PQTrainingErrorKind {
268    #[error("canceled by request")]
269    Canceled,
270    #[error("initial pivot selection error")]
271    Initialization(#[source] Box<dyn std::error::Error + Send + Sync>),
272    #[error("internal logic error")]
273    InternalError(#[source] Box<dyn std::error::Error + Send + Sync>),
274}
275
276///////////
277// Tests //
278///////////
279
280#[cfg(not(miri))]
281#[cfg(test)]
282mod tests {
283    use std::sync::atomic::{AtomicUsize, Ordering};
284
285    use rand::{
286        Rng, SeedableRng,
287        distr::{Distribution, StandardUniform, Uniform},
288        rngs::StdRng,
289        seq::SliceRandom,
290    };
291
292    use diskann_utils::lazy_format;
293
294    use super::*;
295    use crate::{cancel::DontCancel, error::format, random::StdRngBuilder};
296
297    // With this test - we create sub-matrices that when flattened, will yield the output
298    // sequence `0, 1, 2, 3, 4, ...`.
299    #[test]
300    fn test_flatten() {
301        // The number of rows in the final matrix.
302        let nrows = 5;
303        // The dimensions in each sub-matrix.
304        let sub_dims = [1, 2, 3, 4, 5];
305        // The prefix sum of the sub dimensions.
306        let prefix_sum: Vec<usize> = sub_dims
307            .iter()
308            .scan(0, |state, i| {
309                let this = *state;
310                *state += *i;
311                Some(this)
312            })
313            .collect();
314
315        let dim: usize = sub_dims.iter().sum();
316
317        // Create the sub matrices.
318        let matrices: Vec<rowmajor::Owned<usize>> =
319            std::iter::zip(sub_dims.iter(), prefix_sum.iter())
320                .map(|(&this_dim, &offset)| {
321                    let mut m = rowmajor::Owned::from_element(nrows, this_dim, 0);
322                    for r in 0..nrows {
323                        for c in 0..this_dim {
324                            *m.element_mut(r, c) = dim * r + offset + c;
325                        }
326                    }
327                    m
328                })
329                .collect();
330
331        let flattened = flatten(&matrices, nrows, dim);
332        // Check that the output is correct.
333        for (i, v) in flattened.as_slice().iter().enumerate() {
334            assert_eq!(*v, i, "failed at index {i}");
335        }
336    }
337
338    struct DatasetBuilder {
339        nclusters: usize,
340        cluster_size: usize,
341        step_between_clusters: f32,
342    }
343
344    struct ClusteredDataset {
345        data: rowmajor::Owned<f32>,
346        // The pre-configured center point for the manufactured clusters.
347        centers: rowmajor::Owned<f32>,
348    }
349
350    impl DatasetBuilder {
351        fn build<R>(
352            &self,
353            schema: crate::views::ChunkOffsetsView<'_>,
354            rng: &mut R,
355        ) -> ClusteredDataset
356        where
357            R: Rng,
358        {
359            let ndata = self.nclusters * self.cluster_size;
360
361            // Start the clustering points at a different location for each chunk.
362            // The starting offset is chosen from this distribution.
363            let offsets_distribution = Uniform::<f32>::new(-100.0, 100.0).unwrap();
364
365            // The perturbation for vectors within a cluster - all centered around some
366            // mean.
367            let perturbation_distribution = rand_distr::StandardNormal;
368
369            // Indices that we use to shuffle the order of elements in the dataset.
370            let mut indices: Vec<usize> = (0..ndata).collect();
371
372            // Construct the dataset in pieces.
373            let (pieces, centers): (Vec<_>, Vec<_>) = (0..schema.len())
374                .map(|chunk| {
375                    let dim = schema.at(chunk).len();
376
377                    let mut initial = rowmajor::Owned::from_element(ndata, dim, 0.0);
378                    let mut centers = rowmajor::Owned::from_element(self.nclusters, 1, 0.0);
379
380                    // The starting offset for clusters.
381                    let offset = offsets_distribution.sample(rng);
382
383                    // Create a dataset with `nclusters`, each cluster
384                    for cluster in 0..self.nclusters {
385                        let this_offset = offset + (cluster as f32 * self.step_between_clusters);
386                        *centers.element_mut(cluster, 0) = this_offset;
387
388                        for element in 0..self.cluster_size {
389                            let row = initial.row_mut(cluster * self.cluster_size + element);
390                            for r in row.iter_mut() {
391                                let perturbation: f32 = perturbation_distribution.sample(rng);
392                                *r = this_offset + perturbation;
393                            }
394                        }
395                    }
396
397                    // Shuffle the dataset.
398                    indices.shuffle(rng);
399                    let mut piece = rowmajor::Owned::from_element(ndata, dim, 0.0);
400                    for (dst, src) in indices.iter().enumerate() {
401                        piece.row_mut(dst).copy_from_slice(initial.row(*src));
402                    }
403                    (piece, centers)
404                })
405                .unzip();
406
407            ClusteredDataset {
408                data: flatten(&pieces, ndata, schema.dim()),
409                centers: flatten(&centers, self.nclusters, schema.len()),
410            }
411        }
412    }
413
414    fn broadcast_distance(x: &[f32], y: f32) -> f32 {
415        x.iter()
416            .map(|i| {
417                let d = *i - y;
418                d * d
419            })
420            .sum()
421    }
422
423    // Happy Path check - varying over parallelism.
424    fn test_pq_training_happy_path(parallelism: Parallelism) {
425        let mut rng = StdRng::seed_from_u64(0x749cb951cf960384);
426        let builder = DatasetBuilder {
427            nclusters: 16,
428            cluster_size: 20,
429            // NOTE: We need to keep the step between clusters fairly large to ensure that
430            // kmeans++ adequately initializes.
431            step_between_clusters: 20.0,
432        };
433
434        let ncenters = builder.nclusters;
435
436        let offsets = [0, 2, 3, 8, 12, 16];
437        let schema = crate::views::ChunkOffsetsView::new(&offsets).unwrap();
438        let dataset = builder.build(schema, &mut rng);
439
440        let trainer = LightPQTrainingParameters::new(ncenters, 6);
441
442        let quantizer = trainer
443            .train(
444                dataset.data.as_view(),
445                schema,
446                parallelism,
447                &StdRngBuilder::new(StandardUniform {}.sample(&mut rng)),
448                &DontCancel,
449            )
450            .unwrap();
451
452        // Now that we have trained the quantizer - we need to double check that the chosen
453        // centroids match what we expect.
454        //
455        // To do this - we loop through the centroids that training picked. We match the
456        // centroids with one of the known centers in our clustering.
457        //
458        // We perform two main checks:
459        //
460        // 1. We ensure that the quantizer's center actually aligns with a cluster (i.e.,
461        //    training did not invent values out of thin air).
462        // 2. Every clustering in the original dataset has a representative in the quantizer.
463        assert_eq!(quantizer.dim, schema.dim());
464        assert_eq!(quantizer.ncenters, ncenters);
465        assert_eq!(quantizer.pivots.len(), schema.len());
466        for (i, pivot) in quantizer.pivots.iter().enumerate() {
467            // Make sure the pivot has the correct dimension.
468            assert_eq!(
469                pivot.ncols(),
470                schema.at(i).len(),
471                "center {i} has the incorrect number of columns"
472            );
473            assert_eq!(pivot.nrows(), ncenters);
474
475            // Start matching pivots to expected centers.
476            let mut seen: Vec<bool> = (0..dataset.centers.nrows()).map(|_| false).collect();
477            for row in pivot.rows() {
478                let mut min_distance = f32::MAX;
479                let mut min_index = 0;
480                for c in 0..dataset.centers.nrows() {
481                    let distance = broadcast_distance(row, *dataset.centers.element(c, i));
482                    if distance < min_distance {
483                        min_distance = distance;
484                        min_index = c;
485                    }
486                }
487
488                // Does the minimum distance suggest that we are inside a cluster.
489                assert!(
490                    min_distance < 1.0,
491                    "got a minimum distance of {}, pivot = {}. Row = {:?}",
492                    min_distance,
493                    i,
494                    row,
495                );
496
497                // Mark this index as seen.
498                let seen_before = &mut seen[min_index];
499                assert!(
500                    !*seen_before,
501                    "cluster {} has more than one assignment",
502                    min_index
503                );
504                *seen_before = true;
505            }
506
507            // Make sure that all clusters were seen.
508            assert!(seen.iter().all(|i| *i), "not all clusters were seen");
509        }
510
511        // Check `flatten`.
512        let flattened = quantizer.flatten();
513        assert_eq!(
514            &flattened,
515            flatten(&quantizer.pivots, quantizer.ncenters, quantizer.dim).as_slice()
516        );
517    }
518
519    #[test]
520    fn test_pq_training_happy_path_sequential() {
521        test_pq_training_happy_path(Parallelism::Sequential);
522    }
523
524    #[test]
525    #[cfg(feature = "rayon")]
526    fn test_pq_training_happy_path_parallel() {
527        test_pq_training_happy_path(Parallelism::Rayon);
528    }
529
530    // A canceler that cancels after a set number of invocations.
531    struct CancelAfter {
532        counter: AtomicUsize,
533        after: usize,
534    }
535
536    impl CancelAfter {
537        fn new(after: usize) -> Self {
538            Self {
539                counter: AtomicUsize::new(0),
540                after,
541            }
542        }
543    }
544
545    impl Cancelation for CancelAfter {
546        fn should_cancel(&self) -> bool {
547            let v = self.counter.fetch_add(1, Ordering::Relaxed);
548            v >= self.after
549        }
550    }
551
552    #[test]
553    fn test_cancel() {
554        let mut rng = StdRng::seed_from_u64(0xb85352d38cc5353b);
555        let builder = DatasetBuilder {
556            nclusters: 16,
557            cluster_size: 20,
558            // NOTE: We need to keep the step between clusters fairly large to ensure that
559            // kmeans++ adequately initializes.
560            step_between_clusters: 20.0,
561        };
562
563        let offsets = [0, 2, 3, 8, 12, 16];
564        let schema = crate::views::ChunkOffsetsView::new(&offsets).unwrap();
565        let dataset = builder.build(schema, &mut rng);
566
567        let trainer = LightPQTrainingParameters::new(builder.nclusters, 6);
568
569        for after in 0..10 {
570            let parallelism = [
571                Parallelism::Sequential,
572                #[cfg(feature = "rayon")]
573                Parallelism::Rayon,
574            ];
575
576            for par in parallelism {
577                let result = trainer.train(
578                    dataset.data.as_view(),
579                    schema,
580                    par,
581                    &StdRngBuilder::new(StandardUniform {}.sample(&mut rng)),
582                    &CancelAfter::new(after),
583                );
584                assert!(result.is_err(), "expected the operation to be canceled");
585                let err = result.unwrap_err();
586                assert!(
587                    err.was_canceled(),
588                    "expected the failure reason to be cancellation"
589                );
590            }
591        }
592    }
593
594    // In this test - we ensure that clustering succeeds even if the number of requested
595    // pivots exceeds the number of dataset items.
596    #[test]
597    fn tests_succeeded_with_too_many_pivots() {
598        let data = rowmajor::Owned::<f32>::from_element(10, 5, 1.0);
599        let offsets: Vec<usize> = vec![0, 1, 4, 5];
600
601        let trainer = LightPQTrainingParameters::new(2 * data.nrows(), 6);
602
603        let quantizer = trainer
604            .train(
605                data.as_view(),
606                crate::views::ChunkOffsetsView::new(&offsets).unwrap(),
607                Parallelism::Sequential,
608                &StdRngBuilder::new(0),
609                &DontCancel,
610            )
611            .unwrap();
612
613        // We are in the special position to actually know how this will behave.
614        // Since the input dataset lacks diversity, there should only have been a single
615        // pivot actually selected.
616        //
617        // All the rest should be zero.
618        let flat = flatten(&quantizer.pivots, quantizer.ncenters, quantizer.dim);
619
620        assert!(
621            flat.row(0).iter().all(|i| *i == 1.0),
622            "expected pivot 0 to be the non-zero pivot"
623        );
624
625        for (i, row) in flat.rows().enumerate() {
626            // skip the first row.
627            if i == 0 {
628                continue;
629            }
630
631            assert!(
632                row.iter().all(|j| *j == 0.0),
633                "expected pivot {i} to be all zeros"
634            );
635        }
636    }
637
638    #[test]
639    fn test_infinity_and_nan_is_not_recoverable() {
640        let num_trials = 10;
641        let nrows = 10;
642        let ncols = 5;
643
644        let offsets: Vec<usize> = vec![0, 1, 4, 5];
645
646        let trainer = LightPQTrainingParameters::new(nrows, 6);
647
648        let row_distribution = Uniform::new(0, nrows).unwrap();
649        let col_distribution = Uniform::new(0, ncols).unwrap();
650        let mut rng = StdRng::seed_from_u64(0xe746cfebba2d7e35);
651
652        for trial in 0..num_trials {
653            let context = lazy_format!("trial {} of {}", trial + 1, num_trials);
654
655            let r = row_distribution.sample(&mut rng);
656            let c = col_distribution.sample(&mut rng);
657
658            let check_result = |r: Result<_, PQTrainingError>| {
659                assert!(
660                    r.is_err(),
661                    "expected error due to infinities/NaN -- {}",
662                    context
663                );
664                let err = r.unwrap_err();
665                assert!(!err.was_canceled());
666                assert!(format(&err).contains("infinity"));
667            };
668
669            let mut data = rowmajor::Owned::<f32>::from_element(nrows, ncols, 1.0);
670
671            // Positive Infinity
672            *data.element_mut(r, c) = f32::INFINITY;
673            let result = trainer.train(
674                data.as_view(),
675                crate::views::ChunkOffsetsView::new(&offsets).unwrap(),
676                Parallelism::Sequential,
677                &StdRngBuilder::new(0),
678                &DontCancel,
679            );
680            check_result(result);
681
682            // Positive Infinity
683            *data.element_mut(r, c) = f32::NEG_INFINITY;
684            let result = trainer.train(
685                data.as_view(),
686                crate::views::ChunkOffsetsView::new(&offsets).unwrap(),
687                Parallelism::Sequential,
688                &StdRngBuilder::new(0),
689                &DontCancel,
690            );
691            check_result(result);
692
693            // NaN
694            *data.element_mut(r, c) = f32::NAN;
695            let result = trainer.train(
696                data.as_view(),
697                crate::views::ChunkOffsetsView::new(&offsets).unwrap(),
698                Parallelism::Sequential,
699                &StdRngBuilder::new(0),
700                &DontCancel,
701            );
702            check_result(result);
703        }
704    }
705}