Skip to main content

velesdb_core/quantization/
scalar.rs

1//! Scalar Quantization (SQ8) for memory-efficient vector storage.
2//!
3//! Implements 8-bit scalar quantization to reduce memory usage by 4x
4//! while maintaining >95% recall accuracy. Includes both scalar and
5//! SIMD-optimized distance functions.
6
7use std::io;
8
9use super::codec_helpers::{serialize_with_header, validate_and_split_header};
10use super::QuantizationCodec;
11
12/// A quantized vector using 8-bit scalar quantization.
13///
14/// Each f32 value is mapped to a u8 (0-255) using min/max scaling.
15/// The original value can be reconstructed as: `value = (data[i] / 255.0) * (max - min) + min`
16///
17/// Self-describing per-vector codec (each vector carries its own min/max,
18/// no training) — distinct from the trained per-dimension
19/// `ScalarQuantizer` in `index::hnsw::native` that the SQ8 HNSW backend
20/// traverses on.
21#[derive(Debug, Clone)]
22pub struct QuantizedVector {
23    /// Quantized data (1 byte per dimension instead of 4).
24    pub data: Vec<u8>,
25    /// Minimum value in the original vector.
26    pub min: f32,
27    /// Maximum value in the original vector.
28    pub max: f32,
29}
30
31impl QuantizedVector {
32    /// Creates a new quantized vector from f32 data.
33    ///
34    /// # Arguments
35    ///
36    /// * `vector` - The original f32 vector to quantize
37    #[must_use]
38    pub fn from_f32(vector: &[f32]) -> Self {
39        // Caller guarantees non-empty (dimension validated at collection level).
40        debug_assert!(!vector.is_empty(), "Cannot quantize empty vector");
41
42        let min = vector.iter().copied().fold(f32::INFINITY, f32::min);
43        let max = vector.iter().copied().fold(f32::NEG_INFINITY, f32::max);
44
45        let range = max - min;
46        let data = if range < f32::EPSILON {
47            // All values are the same, map to 128 (middle of range)
48            vec![128u8; vector.len()]
49        } else {
50            let scale = 255.0 / range;
51            // Reason: Value is clamped to [0.0, 255.0] before cast, guaranteeing it fits in u8.
52            // cast_sign_loss is safe because clamped value is always non-negative.
53            // cast_possible_truncation is safe because clamped value is always <= 255.
54            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
55            vector
56                .iter()
57                .map(|&v| {
58                    let normalized = (v - min) * scale;
59                    normalized.round().clamp(0.0, 255.0) as u8
60                })
61                .collect()
62        };
63
64        Self { data, min, max }
65    }
66
67    /// Reconstructs the original f32 vector from quantized data.
68    ///
69    /// Note: This is a lossy operation. The reconstructed values are approximations.
70    #[must_use]
71    pub fn to_f32(&self) -> Vec<f32> {
72        let range = self.max - self.min;
73        if range < f32::EPSILON {
74            // All values were the same
75            vec![self.min; self.data.len()]
76        } else {
77            let scale = range / 255.0;
78            self.data
79                .iter()
80                .map(|&v| f32::from(v) * scale + self.min)
81                .collect()
82        }
83    }
84
85    /// Returns the dimension of the vector.
86    #[must_use]
87    pub fn dimension(&self) -> usize {
88        self.data.len()
89    }
90
91    /// Returns the memory size in bytes.
92    #[must_use]
93    pub fn memory_size(&self) -> usize {
94        self.data.len() + 8 // data + min(4) + max(4)
95    }
96}
97
98/// SQ8 header: `[min: f32 LE][max: f32 LE]` = 8 bytes.
99const SQ8_HEADER_SIZE: usize = 8;
100
101impl QuantizationCodec for QuantizedVector {
102    fn to_bytes(&self) -> Vec<u8> {
103        let mut header = [0u8; SQ8_HEADER_SIZE];
104        header[..4].copy_from_slice(&self.min.to_le_bytes());
105        header[4..].copy_from_slice(&self.max.to_le_bytes());
106        serialize_with_header(&header, &self.data)
107    }
108
109    fn from_bytes(bytes: &[u8]) -> io::Result<Self> {
110        let (header, payload) =
111            validate_and_split_header(bytes, SQ8_HEADER_SIZE, "QuantizedVector")?;
112
113        let min = f32::from_le_bytes([header[0], header[1], header[2], header[3]]);
114        let max = f32::from_le_bytes([header[4], header[5], header[6], header[7]]);
115        let data = payload.to_vec();
116
117        Ok(Self { data, min, max })
118    }
119}
120
121// =========================================================================
122// Dequantization parameters (RF-DEDUP)
123// =========================================================================
124
125/// Dequantization parameters extracted from a `QuantizedVector`.
126///
127/// RF-DEDUP: Eliminates repeated `range < EPSILON` / `scale = range / 255.0`
128/// boilerplate across all distance functions.
129struct DequantParams {
130    scale: f32,
131    offset: f32,
132}
133
134/// Extracts dequantization parameters from a quantized vector.
135///
136/// Returns `None` when the range is degenerate (all values identical),
137/// in which case callers should use the flat-value fallback path.
138fn dequant_params(quantized: &QuantizedVector) -> Option<DequantParams> {
139    let range = quantized.max - quantized.min;
140    if range < f32::EPSILON {
141        return None;
142    }
143    Some(DequantParams {
144        scale: range / 255.0,
145        offset: quantized.min,
146    })
147}
148
149// =========================================================================
150// Scalar distance functions
151// =========================================================================
152
153/// Computes the approximate dot product between a query vector (f32) and a quantized vector.
154///
155/// This avoids full dequantization for better performance.
156#[must_use]
157pub fn dot_product_quantized(query: &[f32], quantized: &QuantizedVector) -> f32 {
158    debug_assert_eq!(
159        query.len(),
160        quantized.data.len(),
161        "Dimension mismatch in dot_product_quantized"
162    );
163
164    let Some(params) = dequant_params(quantized) else {
165        // All quantized values are the same
166        return query.iter().sum::<f32>() * quantized.min;
167    };
168
169    // Compute dot product with on-the-fly dequantization
170    query
171        .iter()
172        .zip(quantized.data.iter())
173        .map(|(&q, &v)| q * (f32::from(v) * params.scale + params.offset))
174        .sum()
175}
176
177/// Computes the approximate squared Euclidean distance between a query (f32) and quantized vector.
178#[must_use]
179pub fn euclidean_squared_quantized(query: &[f32], quantized: &QuantizedVector) -> f32 {
180    debug_assert_eq!(
181        query.len(),
182        quantized.data.len(),
183        "Dimension mismatch in euclidean_squared_quantized"
184    );
185
186    let Some(params) = dequant_params(quantized) else {
187        let value = quantized.min;
188        return query.iter().map(|&q| (q - value).powi(2)).sum();
189    };
190
191    query
192        .iter()
193        .zip(quantized.data.iter())
194        .map(|(&q, &v)| {
195            let dequantized = f32::from(v) * params.scale + params.offset;
196            (q - dequantized).powi(2)
197        })
198        .sum()
199}
200
201/// Computes approximate cosine similarity between a query (f32) and quantized vector.
202///
203/// F-10: Computes quantized vector norm without full dequantization allocation.
204/// Uses on-the-fly dequantization to accumulate norm squared, avoiding a
205/// 3KB `Vec<f32>` allocation per call (for dim=768).
206///
207/// Note: For best accuracy, the query should be normalized.
208#[must_use]
209pub fn cosine_similarity_quantized(query: &[f32], quantized: &QuantizedVector) -> f32 {
210    cosine_from_dot(dot_product_quantized(query, quantized), query, quantized)
211}
212
213/// Shared cosine similarity computation from a precomputed dot product.
214///
215/// RF-DEDUP: Both `cosine_similarity_quantized` and `cosine_similarity_quantized_simd`
216/// share identical norm computation and zero-check logic. This helper eliminates
217/// that duplication.
218fn cosine_from_dot(dot: f32, query: &[f32], quantized: &QuantizedVector) -> f32 {
219    use crate::simd_native;
220
221    let query_norm = simd_native::norm_native(query);
222
223    // F-10: Compute quantized norm without allocating a full f32 vector
224    let quantized_norm = quantized_vector_norm(quantized);
225
226    if query_norm < f32::EPSILON || quantized_norm < f32::EPSILON {
227        return 0.0;
228    }
229
230    dot / (query_norm * quantized_norm)
231}
232
233/// Computes the L2 norm of a quantized vector without full dequantization.
234///
235/// F-10: Avoids allocating a `Vec<f32>` just to compute a norm.
236/// Uses on-the-fly dequantization with 4-wide unrolling.
237#[inline]
238fn quantized_vector_norm(quantized: &QuantizedVector) -> f32 {
239    let Some(params) = dequant_params(quantized) else {
240        let value = quantized.min;
241        #[allow(clippy::cast_precision_loss)]
242        return value.abs() * (quantized.data.len() as f32).sqrt();
243    };
244
245    let len = quantized.data.len();
246    let chunks = len / 4;
247    let remainder = len % 4;
248
249    let mut sum0: f32 = 0.0;
250    let mut sum1: f32 = 0.0;
251    let mut sum2: f32 = 0.0;
252    let mut sum3: f32 = 0.0;
253
254    for i in 0..chunks {
255        let base = i * 4;
256        let d0 = f32::from(quantized.data[base]) * params.scale + params.offset;
257        let d1 = f32::from(quantized.data[base + 1]) * params.scale + params.offset;
258        let d2 = f32::from(quantized.data[base + 2]) * params.scale + params.offset;
259        let d3 = f32::from(quantized.data[base + 3]) * params.scale + params.offset;
260        sum0 += d0 * d0;
261        sum1 += d1 * d1;
262        sum2 += d2 * d2;
263        sum3 += d3 * d3;
264    }
265
266    let base = chunks * 4;
267    for i in 0..remainder {
268        let d = f32::from(quantized.data[base + i]) * params.scale + params.offset;
269        sum0 += d * d;
270    }
271
272    (sum0 + sum1 + sum2 + sum3).sqrt()
273}
274
275// =========================================================================
276// SIMD-optimized distance functions for SQ8 quantized vectors
277// =========================================================================
278
279// F-11: Removed dead `use std::arch::x86_64::*` import — no intrinsics used in this file.
280
281/// Dot product between f32 query and SQ8 quantized vector with 8-wide unrolling.
282///
283/// F-11: Renamed from `simd_dot_product_avx2` — this is an unrolled scalar
284/// implementation, NOT actual AVX2 intrinsics. The 8-wide unrolling helps
285/// LLVM auto-vectorize but does not guarantee SIMD execution.
286#[must_use]
287pub fn dot_product_quantized_simd(query: &[f32], quantized: &QuantizedVector) -> f32 {
288    debug_assert_eq!(
289        query.len(),
290        quantized.data.len(),
291        "Dimension mismatch in dot_product_quantized_simd"
292    );
293
294    let Some(params) = dequant_params(quantized) else {
295        return query.iter().sum::<f32>() * quantized.min;
296    };
297
298    dot_product_dequant_unrolled_8(query, &quantized.data, params.scale, params.offset)
299}
300
301/// F-11: Honest name — unrolled scalar dequantize+dot, not intrinsics.
302#[inline]
303fn dot_product_dequant_unrolled_8(query: &[f32], data: &[u8], scale: f32, offset: f32) -> f32 {
304    let len = query.len();
305    let chunks = len / 8;
306    let remainder = len % 8;
307
308    let mut sum = 0.0f32;
309
310    for i in 0..chunks {
311        let base = i * 8;
312        for j in 0..8 {
313            let dequant = f32::from(data[base + j]) * scale + offset;
314            sum += query[base + j] * dequant;
315        }
316    }
317
318    let base = chunks * 8;
319    for i in 0..remainder {
320        let dequant = f32::from(data[base + i]) * scale + offset;
321        sum += query[base + i] * dequant;
322    }
323
324    sum
325}
326
327/// SIMD-optimized squared Euclidean distance between f32 query and SQ8 vector.
328#[must_use]
329pub fn euclidean_squared_quantized_simd(query: &[f32], quantized: &QuantizedVector) -> f32 {
330    debug_assert_eq!(
331        query.len(),
332        quantized.data.len(),
333        "Dimension mismatch in euclidean_squared_quantized_simd"
334    );
335
336    let Some(params) = dequant_params(quantized) else {
337        let value = quantized.min;
338        return query.iter().map(|&q| (q - value).powi(2)).sum();
339    };
340
341    // Optimized loop with manual unrolling
342    let len = query.len();
343    let chunks = len / 4;
344    let remainder = len % 4;
345    let mut sum = 0.0f32;
346
347    for i in 0..chunks {
348        let base = i * 4;
349        let d0 = f32::from(quantized.data[base]) * params.scale + params.offset;
350        let d1 = f32::from(quantized.data[base + 1]) * params.scale + params.offset;
351        let d2 = f32::from(quantized.data[base + 2]) * params.scale + params.offset;
352        let d3 = f32::from(quantized.data[base + 3]) * params.scale + params.offset;
353
354        let diff0 = query[base] - d0;
355        let diff1 = query[base + 1] - d1;
356        let diff2 = query[base + 2] - d2;
357        let diff3 = query[base + 3] - d3;
358
359        sum += diff0 * diff0 + diff1 * diff1 + diff2 * diff2 + diff3 * diff3;
360    }
361
362    let base = chunks * 4;
363    for i in 0..remainder {
364        let dequant = f32::from(quantized.data[base + i]) * params.scale + params.offset;
365        let diff = query[base + i] - dequant;
366        sum += diff * diff;
367    }
368
369    sum
370}
371
372/// SIMD-optimized cosine similarity between f32 query and SQ8 vector.
373///
374/// RF-DEDUP: Delegates to `cosine_from_dot`, sharing norm computation and
375/// zero-check logic with `cosine_similarity_quantized`.
376#[must_use]
377pub fn cosine_similarity_quantized_simd(query: &[f32], quantized: &QuantizedVector) -> f32 {
378    cosine_from_dot(
379        dot_product_quantized_simd(query, quantized),
380        query,
381        quantized,
382    )
383}