Skip to main content

diskann_quantization/algorithms/transforms/
double_hadamard.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use std::num::NonZeroUsize;
7
8#[cfg(feature = "flatbuffers")]
9use flatbuffers::{FlatBufferBuilder, WIPOffset};
10use rand::{
11    Rng,
12    distr::{Distribution, StandardUniform},
13};
14use thiserror::Error;
15
16#[cfg(feature = "flatbuffers")]
17use super::utils::{bool_to_sign, sign_to_bool};
18use super::{
19    TargetDim,
20    utils::{TransformFailed, check_dims, is_sign, subsample_indices},
21};
22#[cfg(feature = "flatbuffers")]
23use crate::flatbuffers as fb;
24use crate::{
25    algorithms::hadamard_transform,
26    alloc::{Allocator, AllocatorError, Poly, ScopedAllocator, TryClone},
27    utils,
28};
29
30/// A Double Hadamard transform that applies the signed Hadamard Transform to a head of the
31/// vector and then the tail.
32///
33/// This struct performs the transformation
34/// ```math
35/// [I 0; 0 H/sqrt(t)] · D1 · [H/sqrt(t) 0; 0 I] · zeropad(D0 · x)
36/// ```
37///
38/// * `n` is the dimensionality of the input vector.
39/// * `m` is the desired output dimensionality.
40/// * `o = max(n, m)` is an intermediate dimension.
41/// * `t` is the largest power of 2 less than or equal to `o`.
42/// * `H` is a Hadamard Matrix of dimension `t`,
43/// * `I` is the identity matrix of dimension `n - t`
44/// * `D0` and `D1` are diagonal matrices with diagonal entries in `{-1, +1}` drawn
45///   uniformly at random with lengths `n` and `o` respectively.
46/// * `x` is the input vector of dimension `n`
47/// * `[A 0; 0 B]` represents a block diagonal matrix with blocks `A` and `B`.
48/// * `zeropad` indicates that the result `D0 · x` is zero-padded to the dimension `o` if
49///   needed.
50///
51/// As indicated above, if `o` is a power of two, then only a single transformation is
52/// applied. Further, if `o` exceeds `n`, then the input vector is zero padded at the end to
53/// `o` dimensions.
54#[derive(Debug)]
55#[cfg_attr(test, derive(PartialEq))]
56pub struct DoubleHadamard<A>
57where
58    A: Allocator,
59{
60    /// Vectors of `+/-1` used for to add randomness to the Hadamard transform
61    /// in each step.
62    ///
63    /// These are stored as a slice of `u32` where each value is either `0` or `0x8000_0000`,
64    /// corresponding to the sign-bit for an `f32` value, allowing sign flipping using
65    /// a cheap `xor` operation.
66    signs0: Poly<[u32], A>,
67    signs1: Poly<[u32], A>,
68
69    /// The target output dimension of the transformation.
70    target_dim: usize,
71
72    /// Optional array storing (in sorted order) the indices to sample if `target_dim < dim`
73    subsample: Option<Poly<[u32], A>>,
74}
75
76impl<A> DoubleHadamard<A>
77where
78    A: Allocator,
79{
80    /// Construct a new `DoubleHadamard` that transforms input vectors of dimension `dim`.
81    ///
82    /// The parameter `rng` is used to randomly initialize the diagonal matrices portion of
83    /// the transform.
84    ///
85    /// The following dimensionalities will be configured depending on the value of `target`:
86    ///
87    /// * [`TargetDim::Same`]
88    ///   - `self.input_dim() == dim.get()`
89    ///   - `self.output_dim() == dim.get()`
90    /// * [`TargetDim::Natural`]
91    ///   - `self.input_dim() == dim.get()`
92    ///   - `self.output_dim() == dim.get()`
93    /// * [`TargetDim::Override`]
94    ///   - `self.input_dim() == dim.get()`
95    ///   - `self.output_dim()`: The value provided by the override.
96    ///
97    /// Subsampling occurs if `self.output_dim()` is smaller than `self.input_dim()`.
98    pub fn new<R>(
99        dim: NonZeroUsize,
100        target_dim: TargetDim,
101        rng: &mut R,
102        allocator: A,
103    ) -> Result<Self, AllocatorError>
104    where
105        R: Rng + ?Sized,
106    {
107        let dim = dim.get();
108
109        let target_dim = match target_dim {
110            TargetDim::Override(target) => target.get(),
111            TargetDim::Same => dim,
112            TargetDim::Natural => dim,
113        };
114
115        // The intermediate dimension after applying the first transform.
116        //
117        // If `target_dim` exceeds `dim`, then we perform zero padding up to `target_dim`
118        // for this stage.
119        let intermediate_dim = dim.max(target_dim);
120
121        // Generate random signs for the diagonal matrices
122        let mut sample = |_: usize| {
123            let sign: bool = StandardUniform {}.sample(rng);
124            if sign { 0x8000_0000 } else { 0 }
125        };
126
127        // Since implicit zero padding is used for this stage, we only create space for
128        // `dim` values.
129        let signs0 = Poly::from_iter((0..dim).map(&mut sample), allocator.clone())?;
130        let signs1 = Poly::from_iter((0..intermediate_dim).map(&mut sample), allocator.clone())?;
131
132        let subsample = if dim > target_dim {
133            Some(subsample_indices(rng, dim, target_dim, allocator)?)
134        } else {
135            None
136        };
137
138        Ok(Self {
139            signs0,
140            signs1,
141            target_dim,
142            subsample,
143        })
144    }
145
146    pub fn try_from_parts(
147        signs0: Poly<[u32], A>,
148        signs1: Poly<[u32], A>,
149        subsample: Option<Poly<[u32], A>>,
150    ) -> Result<Self, DoubleHadamardError> {
151        type E = DoubleHadamardError;
152        if signs0.is_empty() {
153            return Err(E::Signs0Empty);
154        }
155        if signs1.len() < signs0.len() {
156            return Err(E::Signs1TooSmall);
157        }
158        if !signs0.iter().copied().all(is_sign) {
159            return Err(E::Signs0Invalid);
160        }
161        if !signs1.iter().copied().all(is_sign) {
162            return Err(E::Signs1Invalid);
163        }
164
165        // Some preliminary checks on `subsample` that must always hold if it is present.
166        let target_dim = if let Some(ref subsample) = subsample {
167            if !utils::is_strictly_monotonic(subsample.iter()) {
168                return Err(E::SubsampleNotMonotonic);
169            }
170
171            match subsample.last() {
172                Some(last) => {
173                    if *last as usize >= signs1.len() {
174                        // Since the entries in `subsample` are used to index an output
175                        // vector of lengths `signs1`, the last element must be strictly
176                        // less than this length.
177                        //
178                        // From the monotonicity check, we can therefore deduce that *all*
179                        // entries are in-bounds.
180                        return Err(E::LastSubsampleTooLarge);
181                    }
182                }
183                None => {
184                    // Subsample cannot be empty.
185                    return Err(E::InvalidSubsampleLength);
186                }
187            }
188
189            debug_assert!(
190                subsample.len() < signs1.len(),
191                "since we've verified monotonicity and the last element, this is implied"
192            );
193
194            subsample.len()
195        } else {
196            // With no subsampling, the target dim is the length of `signs1`.
197            signs1.len()
198        };
199
200        Ok(Self {
201            signs0,
202            signs1,
203            target_dim,
204            subsample,
205        })
206    }
207
208    /// Return the input dimension for the transformation.
209    pub fn input_dim(&self) -> usize {
210        self.signs0.len()
211    }
212
213    /// Return the output dimension for the transformation.
214    pub fn output_dim(&self) -> usize {
215        self.target_dim
216    }
217
218    /// Return whether or not the transform preserves norms.
219    ///
220    /// For this transform, norms are not preserved when the output dimensionality is less
221    /// than the input dimensionality.
222    pub fn preserves_norms(&self) -> bool {
223        self.subsample.is_none()
224    }
225
226    fn intermediate_dim(&self) -> usize {
227        self.input_dim().max(self.output_dim())
228    }
229
230    /// Perform the transformation of the `src` vector into the `dst` vector.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if
235    ///
236    /// * `src.len() != self.input_dim()`.
237    /// * `dst.len() != self.output_dim()`.
238    pub fn transform_into(
239        &self,
240        dst: &mut [f32],
241        src: &[f32],
242        allocator: ScopedAllocator<'_>,
243    ) -> Result<(), TransformFailed> {
244        check_dims(dst, src, self.input_dim(), self.output_dim())?;
245
246        if let Some(indices) = self.subsample.as_ref() {
247            let mut tmp = Poly::broadcast(0.0f32, self.intermediate_dim(), allocator)?;
248            self.transform_full(&mut tmp, src);
249            let rescale = ((tmp.len() as f32) / (indices.len() as f32)).sqrt();
250            dst.iter_mut()
251                .zip(indices.iter())
252                .for_each(|(d, s)| *d = tmp[*s as usize] * rescale);
253        } else {
254            // The destination already has room for the full transform.
255            dst[src.len()..].fill(0.0);
256            self.transform_full(dst, src);
257        }
258        Ok(())
259    }
260
261    fn transform_full(&self, tmp: &mut [f32], src: &[f32]) {
262        let intermediate_dim = self.intermediate_dim();
263
264        std::iter::zip(tmp.iter_mut(), src.iter())
265            .zip(self.signs0.iter())
266            .for_each(|((dst, src), sign)| *dst = f32::from_bits(src.to_bits() ^ sign));
267
268        let split = 1usize << (usize::BITS - intermediate_dim.leading_zeros() - 1);
269
270        // `split` is less than or equal to `tmp` and is a power to 2.
271        //
272        // If it is equal to the size of `tmp`, then we only run the first transform. Otherwise,
273        // we perform two transforms on the head and tail of `tmp`.
274        #[expect(clippy::unwrap_used)]
275        hadamard_transform(&mut tmp[..split]).unwrap();
276
277        // Apply the second transformation.
278        // Since random signs are applied to the intermediate value, the second transform
279        // does not undo the first.
280        tmp.iter_mut()
281            .zip(self.signs1.iter())
282            .for_each(|(dst, sign)| *dst = f32::from_bits(dst.to_bits() ^ sign));
283
284        #[expect(clippy::unwrap_used)]
285        hadamard_transform(&mut tmp[intermediate_dim - split..]).unwrap();
286    }
287}
288
289impl<A> TryClone for DoubleHadamard<A>
290where
291    A: Allocator,
292{
293    fn try_clone(&self) -> Result<Self, AllocatorError> {
294        Ok(Self {
295            signs0: self.signs0.try_clone()?,
296            signs1: self.signs1.try_clone()?,
297            target_dim: self.target_dim,
298            subsample: self.subsample.try_clone()?,
299        })
300    }
301}
302
303#[derive(Debug, Clone, Copy, Error, PartialEq)]
304#[non_exhaustive]
305pub enum DoubleHadamardError {
306    #[error("first signs stage cannot be empty")]
307    Signs0Empty,
308    #[error("first signs stage has invalid coding")]
309    Signs0Invalid,
310
311    #[error("invalid sign representation for second stage")]
312    Signs1Invalid,
313    #[error("second sign stage must be at least as large as the first stage")]
314    Signs1TooSmall,
315
316    #[error("subsample length must equal `target_dim`")]
317    InvalidSubsampleLength,
318    #[error("subsample indices is not monotonic")]
319    SubsampleNotMonotonic,
320    #[error("last subsample index exceeded intermediate dim")]
321    LastSubsampleTooLarge,
322
323    #[error(transparent)]
324    AllocatorError(#[from] AllocatorError),
325}
326
327// Serialization
328#[cfg(feature = "flatbuffers")]
329impl<A> DoubleHadamard<A>
330where
331    A: Allocator,
332{
333    /// Pack into a [`crate::flatbuffers::transforms::DoubleHadamard`] serialized
334    /// represntation.
335    pub(crate) fn pack<'a, FA>(
336        &self,
337        buf: &mut FlatBufferBuilder<'a, FA>,
338    ) -> WIPOffset<fb::transforms::DoubleHadamard<'a>>
339    where
340        FA: flatbuffers::Allocator + 'a,
341    {
342        // Store the sign vectors.
343        let signs0 = buf.create_vector_from_iter(self.signs0.iter().copied().map(sign_to_bool));
344        let signs1 = buf.create_vector_from_iter(self.signs1.iter().copied().map(sign_to_bool));
345
346        // If subsample indices are present - save those as well.
347        let subsample = self
348            .subsample
349            .as_ref()
350            .map(|indices| buf.create_vector(indices));
351
352        fb::transforms::DoubleHadamard::create(
353            buf,
354            &fb::transforms::DoubleHadamardArgs {
355                signs0: Some(signs0),
356                signs1: Some(signs1),
357                subsample,
358            },
359        )
360    }
361
362    /// Attempt to unpack from a [`crate::flatbuffers::transforms::DoubleHadamard`]
363    /// serialized representation, returning any error if encountered.
364    pub(crate) fn try_unpack(
365        alloc: A,
366        proto: fb::transforms::DoubleHadamard<'_>,
367    ) -> Result<Self, DoubleHadamardError> {
368        let signs0 = Poly::from_iter(proto.signs0().iter().map(bool_to_sign), alloc.clone())?;
369        let signs1 = Poly::from_iter(proto.signs1().iter().map(bool_to_sign), alloc.clone())?;
370
371        let subsample = match proto.subsample() {
372            Some(subsample) => Some(Poly::from_iter(subsample.into_iter(), alloc)?),
373            None => None,
374        };
375
376        Self::try_from_parts(signs0, signs1, subsample)
377    }
378}
379
380///////////
381// Tests //
382///////////
383
384#[cfg(test)]
385#[cfg(not(miri))]
386mod tests {
387    use diskann_utils::lazy_format;
388    use rand::{SeedableRng, rngs::StdRng};
389
390    use super::*;
391    use crate::{
392        algorithms::transforms::{Transform, TransformKind, test_utils},
393        alloc::GlobalAllocator,
394        test_util::{Check, LimitedAllocator},
395    };
396
397    test_utils::delegate_transformer!(DoubleHadamard<GlobalAllocator>);
398
399    #[test]
400    fn test_double_hadamard() {
401        // Inner product computations are more susceptible to floating point error.
402        // Instead of using ULP here, we fall back to using absolute and relative error.
403        //
404        // These error bounds are for when we set the output dimenion to a power of 2 that
405        // is higher than input dimension.
406        let natural_errors = test_utils::ErrorSetup {
407            norm: Check::ulp(5),
408            l2: Check::ulp(5),
409            ip: Check::absrel(2.5e-5, 2e-4),
410        };
411
412        // NOTE: Subsampling introduces high variance in the norm and L2, so our error
413        // bounds need to be looser.
414        //
415        // Subsampling results in poor preservation of inner products, so we skip it
416        // altogether.
417        let subsampled_errors = test_utils::ErrorSetup {
418            norm: Check::absrel(0.0, 2e-2),
419            l2: Check::absrel(0.0, 2e-2),
420            ip: Check::skip(),
421        };
422
423        let target_dim = |v| TargetDim::Override(NonZeroUsize::new(v).unwrap());
424        let dim_combos = [
425            // Natural
426            (15, 15, true, TargetDim::Same, &natural_errors),
427            (15, 15, true, TargetDim::Natural, &natural_errors),
428            (16, 16, true, TargetDim::Same, &natural_errors),
429            (16, 16, true, TargetDim::Natural, &natural_errors),
430            (256, 256, true, TargetDim::Same, &natural_errors),
431            (1000, 1000, true, TargetDim::Same, &natural_errors),
432            // Larger
433            (15, 16, true, target_dim(16), &natural_errors),
434            (100, 128, true, target_dim(128), &natural_errors),
435            (15, 32, true, target_dim(32), &natural_errors),
436            (16, 64, true, target_dim(64), &natural_errors),
437            // Sub-Sampling.
438            (1024, 1023, false, target_dim(1023), &subsampled_errors),
439            (1000, 999, false, target_dim(999), &subsampled_errors),
440        ];
441
442        let trials_per_combo = 20;
443        let trials_per_dim = 100;
444
445        let mut rng = StdRng::seed_from_u64(0x6d1699abe066147);
446        for (input, output, preserves_norms, target, errors) in dim_combos {
447            let input_nz = NonZeroUsize::new(input).unwrap();
448            for trial in 0..trials_per_combo {
449                let ctx = &lazy_format!(
450                    "input dim = {}, output dim = {}, macro trial {} of {}",
451                    input,
452                    output,
453                    trial,
454                    trials_per_combo
455                );
456
457                let mut checker = |io: test_utils::IO<'_>, context: &dyn std::fmt::Display| {
458                    let d = input.min(output);
459                    assert_ne!(&io.input0[..d], &io.output0[..d]);
460                    assert_ne!(&io.input1[..d], &io.output1[..d]);
461                    test_utils::check_errors(io, context, errors);
462                };
463
464                // Clone the Rng state so the abstract transform behaves the same.
465                let mut rng_clone = rng.clone();
466
467                // Test the underlying transformer.
468                {
469                    let transformer = DoubleHadamard::new(
470                        NonZeroUsize::new(input).unwrap(),
471                        target,
472                        &mut rng,
473                        GlobalAllocator,
474                    )
475                    .unwrap();
476
477                    assert_eq!(transformer.input_dim(), input);
478                    assert_eq!(transformer.output_dim(), output);
479                    assert_eq!(transformer.preserves_norms(), preserves_norms);
480
481                    test_utils::test_transform(
482                        &transformer,
483                        trials_per_dim,
484                        &mut checker,
485                        &mut rng,
486                        ctx,
487                    );
488                }
489
490                // Abstract Transformer
491                {
492                    let kind = TransformKind::DoubleHadamard { target_dim: target };
493                    let transformer =
494                        Transform::new(kind, input_nz, Some(&mut rng_clone), GlobalAllocator)
495                            .unwrap();
496
497                    assert_eq!(transformer.input_dim(), input);
498                    assert_eq!(transformer.output_dim(), output);
499                    assert_eq!(transformer.preserves_norms(), preserves_norms);
500
501                    test_utils::test_transform(
502                        &transformer,
503                        trials_per_dim,
504                        &mut checker,
505                        &mut rng_clone,
506                        ctx,
507                    )
508                }
509            }
510
511            let mut allocation_rng = rng.clone();
512            let transformer =
513                DoubleHadamard::new(input_nz, target, &mut allocation_rng, GlobalAllocator)
514                    .unwrap();
515            let mut src = vec![1.0; input];
516            let mut dst = vec![f32::NAN; output + 2];
517            for value in [1.0, 0.0, -0.0] {
518                src.fill(value);
519                if !preserves_norms {
520                    let allocator = LimitedAllocator::new(0);
521                    assert_eq!(
522                        transformer.transform_into(
523                            &mut dst[1..=output],
524                            &src,
525                            ScopedAllocator::new(&allocator),
526                        ),
527                        Err(TransformFailed::AllocatorError(AllocatorError))
528                    );
529                }
530                let allocator = LimitedAllocator::new(usize::from(!preserves_norms));
531                transformer
532                    .transform_into(&mut dst[1..=output], &src, ScopedAllocator::new(&allocator))
533                    .unwrap();
534                assert_eq!(allocator.remaining(), 0);
535                assert!(dst[1..=output].iter().all(|value| value.is_finite()));
536                if value == 0.0 {
537                    assert!(dst[1..=output].iter().all(|value| *value == 0.0));
538                }
539                assert!(dst[0].is_nan() && dst[output + 1].is_nan());
540            }
541        }
542    }
543
544    #[cfg(feature = "flatbuffers")]
545    mod serialization {
546        use super::*;
547        use crate::flatbuffers::to_flatbuffer;
548
549        #[test]
550        fn double_hadamard() {
551            let mut rng = StdRng::seed_from_u64(0x123456789abcdef0);
552            let alloc = GlobalAllocator;
553
554            // Test various dimension combinations
555            let test_cases = [
556                // No sub or super sampling
557                (5, TargetDim::Same),
558                (8, TargetDim::Same),
559                (10, TargetDim::Natural),
560                (16, TargetDim::Natural),
561                // Super sampling with both stages
562                (8, TargetDim::Override(NonZeroUsize::new(12).unwrap())),
563                (10, TargetDim::Override(NonZeroUsize::new(12).unwrap())),
564                // Super sample with one stage
565                (15, TargetDim::Override(NonZeroUsize::new(16).unwrap())),
566                (16, TargetDim::Override(NonZeroUsize::new(16).unwrap())),
567                (15, TargetDim::Override(NonZeroUsize::new(32).unwrap())),
568                (16, TargetDim::Override(NonZeroUsize::new(32).unwrap())),
569                // Sub sampling.
570                (15, TargetDim::Override(NonZeroUsize::new(10).unwrap())),
571                (16, TargetDim::Override(NonZeroUsize::new(10).unwrap())),
572            ];
573
574            for (dim, target_dim) in test_cases {
575                let transform = DoubleHadamard::new(
576                    NonZeroUsize::new(dim).unwrap(),
577                    target_dim,
578                    &mut rng,
579                    alloc,
580                )
581                .unwrap();
582                let data = to_flatbuffer(|buf| transform.pack(buf));
583
584                let proto = flatbuffers::root::<fb::transforms::DoubleHadamard>(&data).unwrap();
585                let reloaded = DoubleHadamard::try_unpack(alloc, proto).unwrap();
586
587                assert_eq!(transform, reloaded);
588            }
589
590            let gen_err = |x: DoubleHadamard<_>| -> DoubleHadamardError {
591                let data = to_flatbuffer(|buf| x.pack(buf));
592                let proto = flatbuffers::root::<fb::transforms::DoubleHadamard>(&data).unwrap();
593                DoubleHadamard::try_unpack(alloc, proto).unwrap_err()
594            };
595
596            type E = DoubleHadamardError;
597            let error_cases = [
598                // Signs1TooSmall: signs0.len() > signs1.len()
599                (
600                    vec![0, 0, 0, 0, 0], // 5 elements
601                    vec![0, 0, 0, 0],    // 4 elements
602                    4,
603                    None,
604                    E::Signs1TooSmall,
605                ),
606                // Signs0Empty
607                (
608                    vec![], // empty signs0
609                    vec![0, 0, 0, 0],
610                    4,
611                    None,
612                    E::Signs0Empty,
613                ),
614                // SubsampleNotMonotonic: subsample indices not in increasing order
615                (
616                    vec![0, 0, 0, 0],
617                    vec![0, 0, 0, 0],
618                    3,
619                    Some(vec![0, 2, 1]), // not monotonic
620                    E::SubsampleNotMonotonic,
621                ),
622                // SubsampleNotMonotonic: duplicate values
623                (
624                    vec![0, 0, 0, 0],
625                    vec![0, 0, 0, 0],
626                    3,
627                    Some(vec![0, 1, 1]), // duplicate values
628                    E::SubsampleNotMonotonic,
629                ),
630                // LastSubsampleTooLarge: exceeds intermediate dim with signs1
631                (
632                    vec![0, 0, 0], // 3 elements
633                    vec![0, 0, 0], // 3 elements
634                    2,
635                    Some(vec![0, 3]), // index 3 >= intermediate_dim(3,2) = 3
636                    E::LastSubsampleTooLarge,
637                ),
638                // LastSubsampleTooLarge: exceeds intermediate dim with signs1
639                (
640                    vec![0, 0, 0], // 3 elements
641                    vec![0, 0, 0], // 3 elements
642                    2,
643                    Some(vec![]), // empty
644                    E::InvalidSubsampleLength,
645                ),
646            ];
647
648            let poly = |v: &Vec<u32>| Poly::from_iter(v.iter().copied(), alloc).unwrap();
649
650            for (signs0, signs1, target_dim, subsample, expected) in error_cases.iter() {
651                println!(
652                    "on case ({:?}, {:?}, {}, {:?})",
653                    signs0, signs1, target_dim, subsample,
654                );
655                let err = gen_err(DoubleHadamard {
656                    signs0: poly(signs0),
657                    signs1: poly(signs1),
658                    target_dim: *target_dim,
659                    subsample: subsample.as_ref().map(poly),
660                });
661
662                assert_eq!(
663                    err, *expected,
664                    "failed for case ({:?}, {:?}, {}, {:?})",
665                    signs0, signs1, target_dim, subsample
666                );
667            }
668        }
669    }
670}