Skip to main content

diskann_quantization/minmax/
recompress.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use super::vectors::{DataMutRef, DataRef, MinMaxCompensation};
7use crate::CompressInto;
8use crate::bits::{Representation, Unsigned};
9use crate::num::Positive;
10use crate::scalar::bit_scale;
11use thiserror::Error;
12
13/// Recompression utilities for MinMax quantized vectors.
14///
15/// This struct provides functionality to further compress MinMax quantized
16/// vectors from a source bitrate `N` to a target bitrate `M` for `N` > `M`.
17///
18/// A positive `grid_scale` is used to (optionally) tighten or widen the
19/// reconstruction range that the target codes span before re-quantization.
20/// `grid_scale == 1.0` preserves the source reconstruction range exactly
21/// (legacy behavior). `grid_scale < 1.0` narrows the range, dedicating more
22/// resolution to interior values at the cost of clipping the extremes;
23/// `grid_scale > 1.0` widens the range (coarser steps, no clipping).
24///
25/// # Notes
26/// - Currently this API only supports the following conversions: 8 -> 4, 8 -> 2, 4 -> 2
27///
28/// # Example
29///
30/// ```rust
31/// use std::num::NonZeroUsize;
32/// use diskann_quantization::algorithms::{Transform, transforms::NullTransform};
33/// use diskann_quantization::minmax::{Data, MinMaxQuantizer, Recompressor};
34/// use diskann_quantization::num::Positive;
35/// use diskann_quantization::CompressInto;
36/// use diskann_utils::{Reborrow, ReborrowMut};
37///
38/// // Create a quantizer and compress an f32 vector to 8-bit
39/// let vector = vec![0.1, -0.5, 0.8, -0.2];
40/// let quantizer = MinMaxQuantizer::new(
41///     Transform::Null(NullTransform::new(NonZeroUsize::new(4).unwrap())),
42///     Positive::new(1.0).unwrap(),
43/// );
44///
45/// let mut encoded_8 = Data::<8>::new_boxed(4);
46/// quantizer.compress_into(vector.as_slice(), encoded_8.reborrow_mut()).unwrap();
47///
48/// // Recompress from 8-bit to 4-bit, preserving the source range
49/// let recompressor = Recompressor::new(Positive::new(1.0).unwrap());
50/// let mut encoded_4 = Data::<4>::new_boxed(4);
51/// recompressor.compress_into(encoded_8.reborrow(), encoded_4.reborrow_mut()).unwrap();
52/// ```
53#[derive(Debug, Clone, Copy)]
54pub struct Recompressor {
55    grid_scale: Positive<f32>,
56}
57
58impl Recompressor {
59    /// Construct a new `Recompressor` with the given positive `grid_scale`.
60    ///
61    /// `grid_scale == 1.0` reproduces the legacy behavior of preserving the
62    /// source reconstruction range exactly.
63    pub fn new(grid_scale: Positive<f32>) -> Self {
64        Self { grid_scale }
65    }
66
67    /// Returns the configured grid scale.
68    pub fn grid_scale(&self) -> Positive<f32> {
69        self.grid_scale
70    }
71}
72
73/// Error type for recompression operations.
74#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
75pub enum RecompressError {
76    /// Source and destination vectors have different dimensions.
77    #[error("dimension mismatch: source has {src} dimensions, destination has {dst}")]
78    DimensionMismatch {
79        /// Dimension of the source vector.
80        src: usize,
81        /// Dimension of the destination vector.
82        dst: usize,
83    },
84}
85
86/// Macro to implement `CompressInto<DataRef<'_, N>, DataMutRef<'_, M>>` for M > 1.
87macro_rules! impl_recompress {
88    ($n:literal -> $m:literal) => {
89        impl<'a, 'b> CompressInto<DataRef<'a, $n>, DataMutRef<'b, $m>> for Recompressor
90        where
91            Unsigned: Representation<$n> + Representation<$m>,
92        {
93            type Error = RecompressError;
94            type Output = ();
95
96            fn compress_into(
97                &self,
98                from: DataRef<'a, $n>,
99                to: DataMutRef<'b, $m>,
100            ) -> Result<(), Self::Error> {
101                recompress_kernel::<$n, $m>(from, to, self.grid_scale.into_inner())
102            }
103        }
104    };
105}
106
107impl_recompress!(8 -> 4);
108impl_recompress!(8 -> 2);
109impl_recompress!(4 -> 2);
110
111////////////////////////////////////
112// Recompression Kernel for M > 1 //
113////////////////////////////////////
114
115/// Recompress N-bit codes to M-bit codes where M > 1.
116///
117/// Recall from the algorithm for minmax described in
118/// [`crate::minmax::MinMaxQuantizer`], the encoding of a vector
119/// `X` into `N`-bits per dimension using minmax is given by:
120///
121/// ```text
122/// X' = round((X - b) * a).clamp(0, 2^n - 1))
123/// ```
124///
125/// where `b = min_i X_i` and `a = max_i X_i - b / (2^N - 1)`.
126///
127/// The source reconstruction range is `[b, b + a * (2^N - 1)]`.
128///
129/// Let `mid = b + a*(2^N-1)/2` and `w = a*(2^N-1)/2`.
130/// Given a positive `grid_scale` (`g`), the target
131/// reconstruction range is `[mid - w*g, mid + w*g]`,
132/// which yields:
133///
134/// ```text
135///   new_b = mid - w*g = b + a*(2^N-1)*(1-g)/2
136///   new_a = 2*w*g / (2^M - 1) = a*(2^N-1)*g/(2^M - 1)
137/// ```
138///
139/// For each source code `old_code`, the reconstructed value is
140/// `X_hat = old_code * a + b`, and the new code is
141///
142/// ```text
143///   new_code = round((X_hat - new_b) / new_a).clamp(0, 2^M - 1)
144///            = round((old_code - offset) * code_scale).clamp(0, 2^M - 1)
145/// ```
146///
147/// where `offset = (2^N-1)*(1-g)/2` and `code_scale = (2^M-1) / (g * (2^N-1))`.
148#[inline(always)]
149fn recompress_kernel<const N: usize, const M: usize>(
150    from: DataRef<'_, N>,
151    mut to: DataMutRef<'_, M>,
152    grid_scale: f32,
153) -> Result<(), RecompressError>
154where
155    Unsigned: Representation<N> + Representation<M>,
156{
157    const { assert!(N > M, "source bit width must exceed target bits") };
158    const { assert!(M > 1, "target bit width must exceed 1") };
159
160    // Validate dimensions
161    let dim = from.len();
162    if dim != to.vector().len() {
163        return Err(RecompressError::DimensionMismatch {
164            src: dim,
165            dst: to.vector().len(),
166        });
167    }
168
169    let src_meta = from.meta();
170    let src_a = src_meta.a;
171    let src_b = src_meta.b;
172
173    let scale_n = bit_scale::<N>();
174    let scale_m = bit_scale::<M>();
175
176    // re-center the source reconstruction range by `grid_scale`.
177    let offset = scale_n * (1.0 - grid_scale) * 0.5;
178    let code_scale = scale_m / (scale_n * grid_scale);
179
180    // new_a / new_b in source-value space.
181    let new_a = src_a / code_scale;
182    let new_b = src_b + src_a * offset;
183
184    // Single pass: encode and compute statistics
185    let from_vec = from.vector();
186    let mut to_vec = to.vector_mut();
187
188    let mut code_sum: f32 = 0.0;
189    let mut norm_squared: f32 = 0.0;
190
191    for i in 0..dim {
192        // Read source code
193        // SAFETY: we checked that `dim == from.len() == src.len()`
194        let old_code = unsafe { from_vec.get_unchecked(i) };
195        let old_code_f = old_code as f32;
196
197        // new code
198        let new_code = ((old_code_f - offset) * code_scale)
199            .round_ties_even()
200            .clamp(0.0, scale_m) as u8;
201
202        // Write destination code
203        // SAFETY: we checked that `dim == from.len() == src.len()`
204        unsafe { to_vec.set_unchecked(i, new_code) };
205
206        // Accumulate statistics using the actual truncated integer code
207        let new_code_f = new_code as f32;
208        code_sum += new_code_f;
209
210        // Reconstruct value for norm computation
211        let v_m = new_code_f * new_a + new_b;
212
213        norm_squared += v_m * v_m;
214    }
215
216    // Construct metadata
217    to.set_meta(MinMaxCompensation {
218        dim: dim as u32,
219        b: new_b,
220        a: new_a,
221        n: new_a * code_sum,
222        norm_squared,
223    });
224
225    Ok(())
226}
227
228#[cfg(test)]
229mod recompress_tests {
230    use std::num::NonZeroUsize;
231
232    use diskann_utils::{Reborrow, ReborrowMut};
233    use rand::{
234        SeedableRng,
235        distr::{Distribution, Uniform},
236        rngs::StdRng,
237    };
238
239    use super::*;
240    use crate::{
241        algorithms::{Transform, transforms::NullTransform},
242        minmax::quantizer::MinMaxQuantizer,
243        minmax::vectors::Data,
244        num::Positive,
245    };
246
247    /// Reconstruct a MinMax quantized vector to f32 values.
248    fn reconstruct<const NBITS: usize>(v: DataRef<'_, NBITS>) -> Vec<f32>
249    where
250        Unsigned: Representation<NBITS>,
251    {
252        let meta = v.meta();
253        (0..v.len())
254            .map(|i| v.vector().get(i).unwrap() as f32 * meta.a + meta.b)
255            .collect()
256    }
257
258    /// Test recompression from N bits to M bits with random vectors and a
259    /// specific `grid_scale`. The recompressed codes are compared **bit for
260    /// bit** against an inline oracle implementation derived directly from
261    /// the kernel's documented formula, and the emitted metadata is checked
262    /// for consistency with the actually-written codes.
263    fn test_recompress_random<const N: usize, const M: usize>(
264        dim: usize,
265        grid_scale: f32,
266        rng: &mut StdRng,
267    ) where
268        Unsigned: Representation<N> + Representation<M>,
269        MinMaxQuantizer: for<'a, 'b> CompressInto<&'a [f32], DataMutRef<'b, N>>,
270        Recompressor: for<'a, 'b> CompressInto<DataRef<'a, N>, DataMutRef<'b, M>, Output = ()>,
271    {
272        let distribution = Uniform::new_inclusive::<f32, f32>(-1.0, 1.0).unwrap();
273        let quantizer = MinMaxQuantizer::new(
274            Transform::Null(NullTransform::new(NonZeroUsize::new(dim).unwrap())),
275            Positive::new(1.0).unwrap(),
276        );
277
278        let g = Positive::new(grid_scale).unwrap();
279
280        let recompressor = Recompressor::new(g);
281
282        assert_eq!(recompressor.grid_scale(), g);
283
284        // Generate random vector and compress to N bits
285        let vector: Vec<f32> = distribution.sample_iter(rng).take(dim).collect();
286        let mut encoded_n = Data::<N>::new_boxed(dim);
287        quantizer
288            .compress_into(&*vector, encoded_n.reborrow_mut())
289            .unwrap();
290
291        // Recompress to M bits
292        let mut encoded_m = Data::<M>::new_boxed(dim);
293        recompressor
294            .compress_into(encoded_n.reborrow(), encoded_m.reborrow_mut())
295            .unwrap();
296
297        // ---- Oracle: recompute the expected M-bit codes from scratch using
298        // the same formula the kernel documents. This is independent of the
299        // kernel implementation but uses the same source 8-bit codes.
300        let scale_n = ((1u64 << N) - 1) as f32;
301        let scale_m = ((1u64 << M) - 1) as f32;
302        let offset = scale_n * (1.0 - grid_scale) * 0.5;
303        let code_scale = scale_m / (scale_n * grid_scale);
304        let expected_codes: Vec<u8> = (0..dim)
305            .map(|i| {
306                let c = encoded_n.vector().get(i).unwrap() as f32;
307                ((c - offset) * code_scale)
308                    .round_ties_even()
309                    .clamp(0.0, scale_m) as u8
310            })
311            .collect();
312
313        // ---- Bit-for-bit assertion against the oracle.
314        for (i, &expected) in expected_codes.iter().enumerate() {
315            let actual = encoded_m.vector().get(i).unwrap() as u8;
316            assert_eq!(
317                actual, expected,
318                "code mismatch at dim={dim}, g={grid_scale}, i={i}: expected {expected}, got {actual}"
319            );
320        }
321
322        // ---- Metadata consistency with the emitted codes.
323        let meta_m = encoded_m.meta();
324        assert_eq!(meta_m.dim as usize, dim, "Dimension should be preserved");
325
326        let actual_code_sum: f32 = (0..dim)
327            .map(|i| encoded_m.vector().get(i).unwrap() as f32)
328            .sum();
329        let computed_code_sum = meta_m.n / meta_m.a;
330        assert!(
331            (computed_code_sum - actual_code_sum).abs() < 1e-4,
332            "Code sum mismatch at g={grid_scale}: expected {actual_code_sum}, got {computed_code_sum}"
333        );
334
335        let reconstructed_m = reconstruct(encoded_m.reborrow());
336        let expected_norm_sq: f32 = reconstructed_m.iter().map(|x| x * x).sum();
337        assert!(
338            (meta_m.norm_squared - expected_norm_sq).abs() < 1e-4,
339            "norm_squared mismatch at g={grid_scale}: expected {expected_norm_sq}, got {}",
340            meta_m.norm_squared
341        );
342    }
343
344    cfg_if::cfg_if! {
345        if #[cfg(miri)] {
346            const TRIALS: usize = 2;
347            const MAX_DIM: usize = 20;
348        } else {
349            const TRIALS: usize = 10;
350            const MAX_DIM: usize = 100;
351        }
352    }
353
354    /// Grid of `grid_scale` values exercised by every `test_recompress_pair!`
355    /// invocation. Includes `1.0` (legacy-equivalent), `< 1.0` (narrowing,
356    /// exercises clamping), and `> 1.0` (widening).
357    const GRID_SCALES: &[f32] = &[1.0, 0.8, 0.6, 1.2];
358
359    macro_rules! test_recompress_pair {
360        ($name:ident, $n:literal -> $m:literal, $seed:literal) => {
361            #[test]
362            fn $name() {
363                let mut rng = StdRng::seed_from_u64($seed);
364                for dim in 10..=MAX_DIM {
365                    for _ in 0..TRIALS {
366                        for &g in GRID_SCALES {
367                            test_recompress_random::<$n, $m>(dim, g, &mut rng);
368                        }
369                    }
370                }
371            }
372        };
373    }
374
375    test_recompress_pair!(recompress_8_to_4, 8 -> 4, 0xabc123def456);
376    test_recompress_pair!(recompress_8_to_2, 8 -> 2, 0xdef456abc123);
377    test_recompress_pair!(recompress_4_to_2, 4 -> 2, 0x456def123abc);
378
379    #[test]
380    fn test_dimension_mismatch_error() {
381        // SAFETY: I'm positive that 1.0 is positive.
382        let recompressor = Recompressor::new(Positive::<f32>::new(1.0).unwrap());
383
384        let mut src = Data::<8>::new_boxed(10);
385        src.set_meta(MinMaxCompensation {
386            dim: 10,
387            b: 0.0,
388            a: 1.0,
389            n: 0.0,
390            norm_squared: 0.0,
391        });
392
393        let mut dst = Data::<4>::new_boxed(15); // Different dimension
394
395        let result: Result<(), RecompressError> =
396            recompressor.compress_into(src.reborrow(), dst.reborrow_mut());
397
398        assert_eq!(
399            result.unwrap_err(),
400            RecompressError::DimensionMismatch { src: 10, dst: 15 }
401        );
402    }
403
404    #[test]
405    fn test_constant_value_vector() {
406        let dim = 30;
407        let quantizer = MinMaxQuantizer::new(
408            Transform::Null(NullTransform::new(NonZeroUsize::new(dim).unwrap())),
409            Positive::new(1.0).unwrap(),
410        );
411
412        // SAFETY: I'm positive that 1.0 is positive.
413        let recompressor = Recompressor::new(Positive::<f32>::new(1.0).unwrap());
414
415        let constant_value = 42.5f32;
416        let vector = vec![constant_value; dim];
417
418        // Compress to 8 bits
419        let mut encoded_8 = Data::<8>::new_boxed(dim);
420        quantizer
421            .compress_into(&*vector, encoded_8.reborrow_mut())
422            .unwrap();
423
424        // Recompress to 4 bits
425        let mut encoded_4 = Data::<4>::new_boxed(dim);
426        recompressor
427            .compress_into(encoded_8.reborrow(), encoded_4.reborrow_mut())
428            .unwrap();
429
430        // For constant value, all codes should be the same
431        let first_code = encoded_4.vector().get(0).unwrap();
432        for i in 1..dim {
433            assert_eq!(
434                encoded_4.vector().get(i).unwrap(),
435                first_code,
436                "All codes should be identical for constant-value vector"
437            );
438        }
439
440        // Reconstruction should be close to original
441        let reconstructed = reconstruct(encoded_4.reborrow());
442        for &val in &reconstructed {
443            assert!(
444                (val - constant_value).abs() < 1.0,
445                "Reconstructed value {} should be close to original {}",
446                val,
447                constant_value
448            );
449        }
450    }
451}