Skip to main content

velesdb_core/
half_precision.rs

1//! Half-precision floating point support for memory-efficient vector storage.
2//!
3//! This module provides f16 (IEEE 754 half-precision) and bf16 (bfloat16) support,
4//! reducing memory usage by 50% compared to f32 with minimal precision loss.
5//!
6//! # Memory Savings
7//!
8//! | Dimension | f32 Size | f16 Size | Savings |
9//! |-----------|----------|----------|---------|
10//! | 768 (BERT)| 3.0 KB   | 1.5 KB   | 50%     |
11//! | 1536 (GPT)| 6.0 KB   | 3.0 KB   | 50%     |
12//! | 4096      | 16.0 KB  | 8.0 KB   | 50%     |
13//!
14//! # Format Comparison
15//!
16//! - **f16**: IEEE 754 half-precision, best general compatibility
17//! - **bf16**: Brain float16, same exponent range as f32, better for ML
18//!
19//! # Usage
20//!
21//! ```rust
22//! use velesdb_core::half_precision::{VectorData, VectorPrecision};
23//!
24//! // Create from f32
25//! let v = VectorData::from_f32_slice(&[0.1, 0.2, 0.3], VectorPrecision::F16);
26//!
27//! // Convert back to f32 for calculations
28//! let f32_vec = v.to_f32_vec();
29//! ```
30
31use half::{bf16, f16};
32use serde::{Deserialize, Serialize};
33
34/// Vector precision format.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
36#[non_exhaustive]
37pub enum VectorPrecision {
38    /// 32-bit floating point (4 bytes per dimension)
39    #[default]
40    F32,
41    /// 16-bit floating point IEEE 754 (2 bytes per dimension)
42    F16,
43    /// Brain float 16-bit (2 bytes per dimension, same exponent as f32)
44    BF16,
45}
46
47impl VectorPrecision {
48    /// Returns the size in bytes per dimension.
49    #[must_use]
50    pub const fn bytes_per_element(&self) -> usize {
51        match self {
52            Self::F32 => 4,
53            Self::F16 | Self::BF16 => 2,
54        }
55    }
56
57    /// Calculates total memory for a vector of given dimension.
58    #[must_use]
59    pub const fn memory_size(&self, dimension: usize) -> usize {
60        self.bytes_per_element() * dimension
61    }
62}
63
64/// Vector data supporting multiple precision formats.
65///
66/// Stores vectors in their native precision format to minimize memory usage.
67/// Provides conversion methods for distance calculations.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[non_exhaustive]
70pub enum VectorData {
71    /// Full precision f32 vector
72    F32(Vec<f32>),
73    /// Half precision f16 vector (50% memory reduction)
74    F16(Vec<f16>),
75    /// Brain float bf16 vector (50% memory reduction, ML-optimized)
76    BF16(Vec<bf16>),
77}
78
79impl VectorData {
80    /// Creates a new `VectorData` from an f32 slice with the specified precision.
81    ///
82    /// # Arguments
83    ///
84    /// * `data` - Source f32 data
85    /// * `precision` - Target precision format
86    ///
87    /// # Example
88    ///
89    /// ```
90    /// use velesdb_core::half_precision::{VectorData, VectorPrecision};
91    ///
92    /// let v = VectorData::from_f32_slice(&[0.1, 0.2, 0.3], VectorPrecision::F16);
93    /// assert_eq!(v.len(), 3);
94    /// ```
95    #[must_use]
96    pub fn from_f32_slice(data: &[f32], precision: VectorPrecision) -> Self {
97        match precision {
98            VectorPrecision::F32 => Self::F32(data.to_vec()),
99            VectorPrecision::F16 => Self::F16(data.iter().map(|&x| f16::from_f32(x)).collect()),
100            VectorPrecision::BF16 => Self::BF16(data.iter().map(|&x| bf16::from_f32(x)).collect()),
101        }
102    }
103
104    /// Creates a new `VectorData` from an f32 vec, taking ownership.
105    ///
106    /// For F32 precision, takes ownership with zero conversion overhead.
107    /// For F16/BF16, delegates to [`from_f32_slice`](Self::from_f32_slice).
108    #[must_use]
109    pub fn from_f32_vec(data: Vec<f32>, precision: VectorPrecision) -> Self {
110        if precision == VectorPrecision::F32 {
111            Self::F32(data)
112        } else {
113            // RF-DEDUP: reuse from_f32_slice for the conversion path
114            Self::from_f32_slice(&data, precision)
115        }
116    }
117
118    /// Returns the precision of this vector.
119    #[must_use]
120    pub const fn precision(&self) -> VectorPrecision {
121        match self {
122            Self::F32(_) => VectorPrecision::F32,
123            Self::F16(_) => VectorPrecision::F16,
124            Self::BF16(_) => VectorPrecision::BF16,
125        }
126    }
127
128    /// Returns the dimension (length) of the vector.
129    #[must_use]
130    pub fn len(&self) -> usize {
131        match self {
132            Self::F32(v) => v.len(),
133            Self::F16(v) => v.len(),
134            Self::BF16(v) => v.len(),
135        }
136    }
137
138    /// Returns true if the vector is empty.
139    #[must_use]
140    pub fn is_empty(&self) -> bool {
141        self.len() == 0
142    }
143
144    /// Returns the memory size in bytes.
145    #[must_use]
146    pub fn memory_size(&self) -> usize {
147        self.precision().memory_size(self.len())
148    }
149
150    /// Converts the vector to f32 for calculations.
151    ///
152    /// For F32 vectors, this clones the data.
153    /// For F16/BF16 vectors, this converts each element.
154    #[must_use]
155    pub fn to_f32_vec(&self) -> Vec<f32> {
156        match self {
157            Self::F32(v) => v.clone(),
158            Self::F16(v) => v.iter().map(|x| x.to_f32()).collect(),
159            Self::BF16(v) => v.iter().map(|x| x.to_f32()).collect(),
160        }
161    }
162
163    /// Returns a reference to the underlying f32 data if precision is F32.
164    ///
165    /// Returns `None` for F16/BF16 vectors.
166    #[must_use]
167    pub fn as_f32_slice(&self) -> Option<&[f32]> {
168        match self {
169            Self::F32(v) => Some(v.as_slice()),
170            Self::F16(_) | Self::BF16(_) => None,
171        }
172    }
173
174    /// Converts to another precision format.
175    #[must_use]
176    pub fn convert(&self, target: VectorPrecision) -> Self {
177        if self.precision() == target {
178            return self.clone();
179        }
180        Self::from_f32_slice(&self.to_f32_vec(), target)
181    }
182}
183
184impl From<Vec<f32>> for VectorData {
185    fn from(data: Vec<f32>) -> Self {
186        Self::F32(data)
187    }
188}
189
190impl From<&[f32]> for VectorData {
191    fn from(data: &[f32]) -> Self {
192        Self::F32(data.to_vec())
193    }
194}
195
196// =============================================================================
197// Distance calculations for half-precision vectors
198// =============================================================================
199
200/// Applies a SIMD distance function over two `VectorData`.
201///
202/// RF-DEDUP: Eliminates 8+ per-precision-combination match arms. The F32*F32
203/// case uses SIMD directly (zero-copy); all other combinations convert to f32
204/// vecs first, then delegate to the same SIMD path.
205///
206/// Mixed-precision paths (F16, BF16) are not hot — the allocation cost of
207/// `to_f32_vec()` is negligible compared to the element conversion overhead.
208fn with_f32_simd(a: &VectorData, b: &VectorData, simd_fn: fn(&[f32], &[f32]) -> f32) -> f32 {
209    match (a, b) {
210        (VectorData::F32(va), VectorData::F32(vb)) => simd_fn(va, vb),
211        _ => simd_fn(&a.to_f32_vec(), &b.to_f32_vec()),
212    }
213}
214
215/// Computes dot product between two `VectorData` with optimal precision handling.
216///
217/// For F32 vectors, uses SIMD-optimized f32 path.
218/// For F16/BF16 vectors, converts to f32 then delegates to SIMD.
219#[must_use]
220pub fn dot_product(a: &VectorData, b: &VectorData) -> f32 {
221    with_f32_simd(a, b, crate::simd_native::dot_product_native)
222}
223
224/// Computes cosine similarity between two `VectorData`.
225#[must_use]
226pub fn cosine_similarity(a: &VectorData, b: &VectorData) -> f32 {
227    if let (VectorData::F32(va), VectorData::F32(vb)) = (a, b) {
228        crate::simd_native::cosine_similarity_native(va, vb)
229    } else {
230        let dot = dot_product(a, b);
231        let norm_a = norm_squared(a).sqrt();
232        let norm_b = norm_squared(b).sqrt();
233
234        if norm_a < f32::EPSILON || norm_b < f32::EPSILON {
235            0.0
236        } else {
237            (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
238        }
239    }
240}
241
242/// Computes Euclidean distance between two `VectorData`.
243#[must_use]
244pub fn euclidean_distance(a: &VectorData, b: &VectorData) -> f32 {
245    with_f32_simd(a, b, crate::simd_native::euclidean_native)
246}
247
248/// Computes squared L2 norm without allocation for F32, with conversion for half-precision.
249/// RF-DEDUP: F16 and BF16 share the same `to_f32_vec` -> SIMD norm path.
250fn norm_squared(v: &VectorData) -> f32 {
251    if let VectorData::F32(data) = v {
252        let n = crate::simd_native::norm_native(data);
253        n * n
254    } else {
255        let f32_vec = v.to_f32_vec();
256        let n = crate::simd_native::norm_native(&f32_vec);
257        n * n
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_vector_data_f16_roundtrip() {
267        let data = vec![1.0, 2.0, 3.0];
268        let v = VectorData::from_f32_slice(&data, VectorPrecision::F16);
269        let result = v.to_f32_vec();
270        for (a, b) in data.iter().zip(result.iter()) {
271            assert!((a - b).abs() < 0.01);
272        }
273    }
274
275    #[test]
276    fn test_cosine_similarity_identical() {
277        let v1 = VectorData::from_f32_slice(&[1.0, 0.0, 0.0], VectorPrecision::F32);
278        let v2 = VectorData::from_f32_slice(&[1.0, 0.0, 0.0], VectorPrecision::F32);
279        let sim = cosine_similarity(&v1, &v2);
280        assert!((sim - 1.0).abs() < 1e-5);
281    }
282
283    #[test]
284    fn test_cosine_similarity_orthogonal() {
285        let v1 = VectorData::from_f32_slice(&[1.0, 0.0, 0.0], VectorPrecision::F32);
286        let v2 = VectorData::from_f32_slice(&[0.0, 1.0, 0.0], VectorPrecision::F32);
287        let sim = cosine_similarity(&v1, &v2);
288        assert!(sim.abs() < 1e-5);
289    }
290
291    #[test]
292    fn test_euclidean_distance_identical() {
293        let v1 = VectorData::from_f32_slice(&[1.0, 2.0, 3.0], VectorPrecision::F32);
294        let v2 = VectorData::from_f32_slice(&[1.0, 2.0, 3.0], VectorPrecision::F32);
295        let dist = euclidean_distance(&v1, &v2);
296        assert!(dist.abs() < 1e-5);
297    }
298
299    #[test]
300    fn test_euclidean_distance_345() {
301        let v1 = VectorData::from_f32_slice(&[0.0, 0.0], VectorPrecision::F32);
302        let v2 = VectorData::from_f32_slice(&[3.0, 4.0], VectorPrecision::F32);
303        let dist = euclidean_distance(&v1, &v2);
304        assert!((dist - 5.0).abs() < 1e-5);
305    }
306
307    #[test]
308    fn test_norm_squared_f32() {
309        let v = VectorData::from_f32_slice(&[3.0, 4.0], VectorPrecision::F32);
310        let norm = norm_squared(&v);
311        assert!((norm - 25.0).abs() < 1e-5);
312    }
313
314    #[test]
315    fn test_cosine_similarity_f16_vs_f32() {
316        let v1 = VectorData::from_f32_slice(&[1.0, 2.0, 3.0], VectorPrecision::F16);
317        let v2 = VectorData::from_f32_slice(&[1.0, 2.0, 3.0], VectorPrecision::F32);
318        let sim = cosine_similarity(&v1, &v2);
319        assert!((sim - 1.0).abs() < 0.01);
320    }
321
322    #[test]
323    fn test_cosine_similarity_is_clamped_to_unit_interval() {
324        // Mixed precision path (non-F32/F32) must respect cosine bounds.
325        let v1 = VectorData::from_f32_slice(&[1.0, 1.0, 1.0, 1.0], VectorPrecision::F16);
326        let v2 = VectorData::from_f32_slice(&[1.0, 1.0, 1.0, 1.0], VectorPrecision::BF16);
327        let sim = cosine_similarity(&v1, &v2);
328        assert!(
329            (-1.0..=1.0).contains(&sim),
330            "cosine similarity must be clamped to [-1, 1], got {sim}"
331        );
332    }
333}