Skip to main content

diskann_quantization/algorithms/transforms/
random_rotation.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use std::num::NonZeroUsize;
7
8use diskann_linalg::{self, Transpose};
9use diskann_utils::views::rowmajor::{Matrix, MatrixMut};
10#[cfg(feature = "flatbuffers")]
11use flatbuffers::{FlatBufferBuilder, WIPOffset};
12use rand::Rng;
13#[cfg(feature = "flatbuffers")]
14use thiserror::Error;
15
16use super::{
17    TargetDim,
18    utils::{TransformFailed, check_dims},
19};
20#[cfg(feature = "flatbuffers")]
21use crate::flatbuffers as fb;
22
23//////////////////////
24// Dense Transforms //
25//////////////////////
26
27/// A distance-perserving transformation from `N`-dimensions to `N`-dimensions.
28///
29/// This struct materializes a full `NxN` transformation matrix and mechanically applies
30/// transformations via matrix multiplication.
31#[derive(Debug, Clone)]
32#[cfg_attr(test, derive(PartialEq))]
33pub struct RandomRotation {
34    /// This data structure maintains the invariant that this **must** be a square matrix.
35    transform: diskann_utils::views::rowmajor::Owned<f32>,
36}
37
38impl RandomRotation {
39    /// Construct a new `RandomRotation` that transforms input vectors of dimension `dim`.
40    ///
41    /// The parameter `rng` is used to randomly sample the transformation matrix.
42    ///
43    /// The following dimensionalities will be configured depending on the value of `target`:
44    ///
45    /// * [`TargetDim::Same`]
46    ///   - `self.input_dim() == dim.get()`
47    ///   - `self.output_dim() == dim.get()`
48    /// * [`TargetDim::Natural`]
49    ///   - `self.input_dim() == dim.get()`
50    ///   - `self.output_dim() == dim.get()`
51    /// * [`TargetDim::Override`]
52    ///   - `self.input_dim() == dim.get()`
53    ///   - `self.output_dim()`: The value provided by the override.
54    ///
55    /// Sub-sampling occurs if `self.output_dim()` is less than `self.input_dim()`.
56    pub fn new<R>(dim: NonZeroUsize, target_dim: TargetDim, rng: &mut R) -> Self
57    where
58        R: Rng + ?Sized,
59    {
60        let dim = dim.get();
61
62        // There are three cases we need to consider:
63        //
64        // 1. If the target dim is the **same** as `dim`, then our transformation matrix can
65        //    be square.
66        //
67        // 2. If the target dim is **less** than `dim`, then we generate a random `dim x dim`
68        //    matrix and remove the output rows that would not be included in the output,
69        //    resulting in a `target_dim x dim` matrix.
70        //
71        // 3. If the target dim is **greater** than `dim`, then we generate a
72        //    `target_dim x target_dim` random matrix and remove columns to end up with a
73        //    `target_dim x dim` matrix.
74        //
75        //    Removing columns is equivalent to zero padding the original vector up to
76        //    `target_dim` and multiplying by the full matrix.
77        let (target_dim, matrix_dim) = match target_dim {
78            TargetDim::Same | TargetDim::Natural => (dim, dim),
79            TargetDim::Override(target) => {
80                let target_dim = target.get();
81                if target_dim <= dim {
82                    (target_dim, dim)
83                } else {
84                    (target_dim, target_dim)
85                }
86            }
87        };
88
89        // Lint: By construction, the matrix returned from
90        // `diskann_linalg::random_distance_preserving_matrix` will by `matrix_dim x matrix_dim`.
91        #[expect(clippy::unwrap_used)]
92        let initial = diskann_utils::views::rowmajor::Owned::try_from_data(
93            diskann_linalg::random_distance_preserving_matrix(matrix_dim, rng).into(),
94            matrix_dim,
95            matrix_dim,
96        )
97        .unwrap();
98
99        // Restructure the matrix as needed to apply the desired sub/super sampling.
100        let transform = match target_dim.cmp(&dim) {
101            std::cmp::Ordering::Equal => initial,
102            std::cmp::Ordering::Less => {
103                let indices = rand::seq::index::sample(rng, dim, target_dim);
104                let scaling = (dim as f32 / target_dim as f32).sqrt();
105
106                let mut transform =
107                    diskann_utils::views::rowmajor::Owned::from_element(target_dim, dim, 0.0f32);
108                std::iter::zip(transform.rows_mut(), indices.iter()).for_each(|(ro, ri)| {
109                    std::iter::zip(ro.iter_mut(), initial.row(ri).iter()).for_each(|(o, i)| {
110                        *o = scaling * (*i);
111                    })
112                });
113                transform
114            }
115            std::cmp::Ordering::Greater => {
116                let mut transform =
117                    diskann_utils::views::rowmajor::Owned::from_element(target_dim, dim, 0.0f32);
118                std::iter::zip(transform.rows_mut(), initial.rows())
119                    .for_each(|(o, i)| o.copy_from_slice(&i[..dim]));
120                transform
121            }
122        };
123
124        Self { transform }
125    }
126
127    /// Return the input dimension for the transformation.
128    pub fn input_dim(&self) -> usize {
129        self.transform.ncols()
130    }
131
132    /// Return the output dimension for the transformation.
133    pub fn output_dim(&self) -> usize {
134        self.transform.nrows()
135    }
136
137    /// Return whether or not the transform preserves norms.
138    ///
139    /// For this transform, norms are not preserved when the output dimensionality is less
140    /// than the input dimensionality.
141    pub fn preserves_norms(&self) -> bool {
142        self.output_dim() >= self.input_dim()
143    }
144
145    /// Perform the transformation of the `src` vector into the `dst` vector.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if
150    ///
151    /// * `src.len() != self.input_dim()`.
152    /// * `dst.len() != self.output_dim()`.
153    pub fn transform_into(&self, dst: &mut [f32], src: &[f32]) -> Result<(), TransformFailed> {
154        let input_dim = self.input_dim();
155        let output_dim = self.output_dim();
156        check_dims(dst, src, input_dim, output_dim)?;
157        diskann_linalg::sgemm(
158            Transpose::None,
159            Transpose::None,
160            output_dim,
161            1,
162            input_dim,
163            1.0,
164            self.transform.as_slice(),
165            src,
166            None,
167            dst,
168        )?;
169        Ok(())
170    }
171}
172
173#[cfg(feature = "flatbuffers")]
174#[cfg_attr(docsrs, doc(cfg(feature = "flatbuffers")))]
175#[derive(Debug, Clone, Copy, Error, PartialEq)]
176#[non_exhaustive]
177pub enum RandomRotationError {
178    #[error("buffer size not product of rows and columns")]
179    IncorrectDim,
180    #[error("number of rows cannot be zero")]
181    RowsZero,
182    #[error("number of cols cannot be zero")]
183    ColsZero,
184}
185
186// Serialization
187#[cfg(feature = "flatbuffers")]
188impl RandomRotation {
189    /// Pack into a [`crate::flatbuffers::transforms::RandomRotation`] serialized
190    /// representation.
191    pub(crate) fn pack<'a, A>(
192        &self,
193        buf: &mut FlatBufferBuilder<'a, A>,
194    ) -> WIPOffset<fb::transforms::RandomRotation<'a>>
195    where
196        A: flatbuffers::Allocator + 'a,
197    {
198        let data = buf.create_vector(self.transform.as_slice());
199
200        fb::transforms::RandomRotation::create(
201            buf,
202            &fb::transforms::RandomRotationArgs {
203                data: Some(data),
204                nrows: self.transform.nrows() as u32,
205                ncols: self.transform.ncols() as u32,
206            },
207        )
208    }
209
210    /// Attempt to unpack from a [`crate::flatbuffers::transforms::RandomRotation`]
211    /// serialized representation, returning any error if encountered.
212    pub(crate) fn try_unpack(
213        proto: fb::transforms::RandomRotation<'_>,
214    ) -> Result<Self, RandomRotationError> {
215        let nrows = proto.nrows();
216        let ncols = proto.ncols();
217        if nrows == 0 {
218            return Err(RandomRotationError::RowsZero);
219        }
220        if ncols == 0 {
221            return Err(RandomRotationError::ColsZero);
222        }
223
224        let data = proto.data().into_iter().collect();
225        let transform = diskann_utils::views::rowmajor::Owned::try_from_data(
226            data,
227            nrows as usize,
228            ncols as usize,
229        )
230        .map_err(|_| RandomRotationError::IncorrectDim)?;
231
232        Ok(Self { transform })
233    }
234}
235
236///////////
237// Tests //
238///////////
239
240#[cfg(test)]
241mod tests {
242    use diskann_utils::lazy_format;
243    use rand::{SeedableRng, rngs::StdRng};
244
245    use super::*;
246    use crate::{
247        algorithms::transforms::{Transform, TransformFailed, TransformKind, test_utils},
248        alloc::GlobalAllocator,
249        test_util::Check,
250    };
251
252    impl test_utils::Transformer for RandomRotation {
253        fn input_dim_(&self) -> usize {
254            self.input_dim()
255        }
256        fn output_dim_(&self) -> usize {
257            self.output_dim()
258        }
259        fn transform_into_(&self, dst: &mut [f32], src: &[f32]) -> Result<(), TransformFailed> {
260            self.transform_into(dst, src)
261        }
262    }
263
264    #[test]
265    fn test_transform_matrix() {
266        let nonsubsampled_errors = test_utils::ErrorSetup {
267            norm: Check::ulp(10),
268            l2: Check::ulp(10),
269            ip: Check::absrel(2e-5, 1e-4),
270        };
271
272        // Because we're using relatively low dimensions, subsampling yields pretty large
273        // variances. We can't use higher dimensionality, though, because then the tests
274        // would never complete.
275        let subsampled_errors = test_utils::ErrorSetup {
276            norm: Check::absrel(0.0, 0.18),
277            l2: Check::absrel(0.0, 0.18),
278            ip: Check::skip(),
279        };
280
281        let target_dim = |v| TargetDim::Override(NonZeroUsize::new(v).unwrap());
282
283        // Combinations of input to output dimensions.
284        let dim_combos = [
285            // Same dimension
286            (15, 15, true, TargetDim::Same, &nonsubsampled_errors),
287            (15, 15, true, TargetDim::Natural, &nonsubsampled_errors),
288            (16, 16, true, TargetDim::Same, &nonsubsampled_errors),
289            (100, 100, true, TargetDim::Same, &nonsubsampled_errors),
290            (100, 100, true, TargetDim::Natural, &nonsubsampled_errors),
291            (256, 256, true, TargetDim::Same, &nonsubsampled_errors),
292            // Super Sampling
293            (15, 20, true, target_dim(20), &nonsubsampled_errors),
294            // Sub Sampling
295            (256, 200, false, target_dim(200), &subsampled_errors),
296        ];
297
298        let trials_per_combo = 20;
299        let trials_per_dim = 50;
300
301        let mut rng = StdRng::seed_from_u64(0x30e37c10c36cc64b);
302        for (input, output, preserves_norms, target, errors) in dim_combos {
303            let input_nz = NonZeroUsize::new(input).unwrap();
304            for trial in 0..trials_per_combo {
305                let ctx = &lazy_format!(
306                    "input dim = {}, output dim = {}, macro trial {} of {}",
307                    input,
308                    output,
309                    trial,
310                    trials_per_combo
311                );
312
313                let mut checker = |io: test_utils::IO<'_>, context: &dyn std::fmt::Display| {
314                    test_utils::check_errors(io, context, errors);
315                };
316
317                // Clone the Rng state so the abstract transform behaves the same.
318                let mut rng_clone = rng.clone();
319
320                // Test the underlying transformer.
321                {
322                    let transformer =
323                        RandomRotation::new(NonZeroUsize::new(input).unwrap(), target, &mut rng);
324                    assert_eq!(transformer.input_dim(), input, "{}", ctx);
325                    assert_eq!(transformer.output_dim(), output, "{}", ctx);
326                    assert_eq!(transformer.preserves_norms(), preserves_norms, "{}", ctx);
327
328                    test_utils::test_transform(
329                        &transformer,
330                        trials_per_dim,
331                        &mut checker,
332                        &mut rng,
333                        ctx,
334                    );
335                }
336
337                // Abstract Transformer
338                {
339                    let kind = TransformKind::RandomRotation { target_dim: target };
340                    let transformer =
341                        Transform::new(kind, input_nz, Some(&mut rng_clone), GlobalAllocator)
342                            .unwrap();
343
344                    assert_eq!(transformer.input_dim(), input);
345                    assert_eq!(transformer.output_dim(), output);
346                    assert_eq!(transformer.preserves_norms(), preserves_norms);
347
348                    test_utils::test_transform(
349                        &transformer,
350                        trials_per_dim,
351                        &mut checker,
352                        &mut rng_clone,
353                        ctx,
354                    )
355                }
356            }
357        }
358    }
359
360    #[cfg(feature = "flatbuffers")]
361    mod serialization {
362        use super::*;
363        use crate::flatbuffers::to_flatbuffer;
364
365        #[test]
366        fn random_rotation() {
367            let mut rng = StdRng::seed_from_u64(0x123456789abcdef0);
368
369            // Test various dimension combinations
370            let test_cases = [
371                (5, TargetDim::Same),
372                (10, TargetDim::Natural),
373                (8, TargetDim::Override(NonZeroUsize::new(12).unwrap())),
374                (15, TargetDim::Override(NonZeroUsize::new(10).unwrap())),
375            ];
376
377            for (dim, target_dim) in test_cases {
378                let transform =
379                    RandomRotation::new(NonZeroUsize::new(dim).unwrap(), target_dim, &mut rng);
380                let data = to_flatbuffer(|buf| transform.pack(buf));
381
382                let proto = flatbuffers::root::<fb::transforms::RandomRotation>(&data).unwrap();
383                let reloaded = RandomRotation::try_unpack(proto).unwrap();
384
385                assert_eq!(transform, reloaded);
386            }
387
388            // Test error cases for invalid dimensions
389            {
390                let data = to_flatbuffer(|buf| {
391                    let data = buf.create_vector::<f32>(&[1.0, 0.0, 0.0, 1.0]); // 2x2 matrix
392                    fb::transforms::RandomRotation::create(
393                        buf,
394                        &fb::transforms::RandomRotationArgs {
395                            data: Some(data),
396                            nrows: 0, // Invalid: zero rows
397                            ncols: 2,
398                        },
399                    )
400                });
401
402                let proto = flatbuffers::root::<fb::transforms::RandomRotation>(&data).unwrap();
403                let err = RandomRotation::try_unpack(proto).unwrap_err();
404                assert_eq!(err, RandomRotationError::RowsZero);
405            }
406
407            {
408                let data = to_flatbuffer(|buf| {
409                    let data = buf.create_vector::<f32>(&[1.0, 0.0, 0.0, 1.0]);
410                    fb::transforms::RandomRotation::create(
411                        buf,
412                        &fb::transforms::RandomRotationArgs {
413                            data: Some(data), // 2x2 matrix
414                            nrows: 2,
415                            ncols: 0, // Invalid: zero cols
416                        },
417                    )
418                });
419
420                let proto = flatbuffers::root::<fb::transforms::RandomRotation>(&data).unwrap();
421                let err = RandomRotation::try_unpack(proto).unwrap_err();
422                assert_eq!(err, RandomRotationError::ColsZero);
423            }
424
425            {
426                let data = to_flatbuffer(|buf| {
427                    let data = buf.create_vector::<f32>(&[1.0, 0.0, 0.0]); // 3 elements
428                    fb::transforms::RandomRotation::create(
429                        buf,
430                        &fb::transforms::RandomRotationArgs {
431                            data: Some(data),
432                            nrows: 2,
433                            ncols: 2, // Should be 4 elements for 2x2 matrix
434                        },
435                    )
436                });
437
438                let proto = flatbuffers::root::<fb::transforms::RandomRotation>(&data).unwrap();
439                let err = RandomRotation::try_unpack(proto).unwrap_err();
440                assert_eq!(err, RandomRotationError::IncorrectDim);
441            }
442        }
443    }
444}