Skip to main content

kevy_vector/
dist.rs

1//! Distance metrics + the wire vector format (RFC D1/D3).
2
3/// Distance metric. Scores are "smaller = closer" for every variant
4/// (cosine → `1 - cos`, ip → `-dot`), so one ascending merge works.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum Distance {
7    /// Cosine distance (vectors pre-normalized at insert).
8    #[default]
9    Cosine,
10    /// Squared euclidean.
11    L2,
12    /// Negative inner product.
13    Ip,
14}
15
16impl Distance {
17    /// Tag for sidecar round-trip.
18    pub fn tag(self) -> &'static str {
19        match self {
20            Distance::Cosine => "cosine",
21            Distance::L2 => "l2",
22            Distance::Ip => "ip",
23        }
24    }
25
26    /// Parse a tag (ASCII case-insensitive).
27    pub fn parse(raw: &[u8]) -> Option<Distance> {
28        if raw.eq_ignore_ascii_case(b"cosine") {
29            Some(Distance::Cosine)
30        } else if raw.eq_ignore_ascii_case(b"l2") {
31            Some(Distance::L2)
32        } else if raw.eq_ignore_ascii_case(b"ip") {
33            Some(Distance::Ip)
34        } else {
35            None
36        }
37    }
38
39    /// Distance between two prepared vectors (see [`prepare`]).
40    #[inline]
41    pub(crate) fn eval(self, a: &[f32], b: &[f32]) -> f32 {
42        match self {
43            // prepared cosine vectors are unit length → 1 - dot
44            Distance::Cosine => 1.0 - dot(a, b),
45            Distance::L2 => l2(a, b),
46            Distance::Ip => -dot(a, b),
47        }
48    }
49
50    /// Normalize a vector into its stored/query form (cosine only).
51    pub(crate) fn prepare(self, v: &mut [f32]) {
52        if self == Distance::Cosine {
53            let norm = dot(v, v).sqrt();
54            if norm > 0.0 {
55                for x in v.iter_mut() {
56                    *x /= norm;
57                }
58            }
59        }
60    }
61}
62
63// A single-accumulator float reduction is a loop-carried dependency
64// the compiler may NOT reassociate — it compiles to a SCALAR add
65// chain. Eight independent lanes let it auto-vectorize at whatever
66// SIMD width the target enables (perf-record put the distance kernel
67// inside the 65% beam-search dominator on the KNN shape).
68//
69// The portable baseline vectorizes at the DEFAULT target width
70// (SSE2 on x86_64) — still 48% of the KNN shape. On x86_64 a
71// runtime-dispatched AVX2+FMA clone of the SAME loops doubles the
72// lane width (the hnswlib/VecSim pattern): `#[target_feature]`
73// recompiles the body at the wider ISA, and the one-time
74// `is_x86_feature_detected!` gate makes the call sound.
75#[cfg(target_arch = "x86_64")]
76mod avx {
77    #[target_feature(enable = "avx2,fma")]
78    pub(super) unsafe fn dot(a: &[f32], b: &[f32]) -> f32 {
79        super::dot_lanes(a, b)
80    }
81
82    #[target_feature(enable = "avx2,fma")]
83    pub(super) unsafe fn l2(a: &[f32], b: &[f32]) -> f32 {
84        super::l2_lanes(a, b)
85    }
86}
87
88#[cfg(target_arch = "x86_64")]
89#[inline]
90fn has_avx2() -> bool {
91    use std::sync::atomic::{AtomicU8, Ordering};
92    static STATE: AtomicU8 = AtomicU8::new(0);
93    match STATE.load(Ordering::Relaxed) {
94        0 => {
95            let yes = std::arch::is_x86_feature_detected!("avx2")
96                && std::arch::is_x86_feature_detected!("fma");
97            STATE.store(if yes { 2 } else { 1 }, Ordering::Relaxed);
98            yes
99        }
100        s => s == 2,
101    }
102}
103
104#[inline]
105fn dot(a: &[f32], b: &[f32]) -> f32 {
106    #[cfg(target_arch = "x86_64")]
107    if has_avx2() {
108        // SAFETY: gated on runtime AVX2+FMA detection.
109        return unsafe { avx::dot(a, b) };
110    }
111    dot_lanes(a, b)
112}
113
114#[inline]
115fn l2(a: &[f32], b: &[f32]) -> f32 {
116    #[cfg(target_arch = "x86_64")]
117    if has_avx2() {
118        // SAFETY: gated on runtime AVX2+FMA detection.
119        return unsafe { avx::l2(a, b) };
120    }
121    l2_lanes(a, b)
122}
123
124// inline(always): innermost distance kernel on the HNSW search hot path;
125// the call sites are tiny wrappers whose whole point is this loop.
126#[allow(clippy::inline_always)]
127#[inline(always)]
128fn dot_lanes(a: &[f32], b: &[f32]) -> f32 {
129    let mut acc = [0.0f32; 8];
130    let (ac, ar) = a.split_at(a.len() - a.len() % 8);
131    let (bc, br) = b.split_at(ac.len().min(b.len()));
132    for (ca, cb) in ac.chunks_exact(8).zip(bc.chunks_exact(8)) {
133        for i in 0..8 {
134            acc[i] += ca[i] * cb[i];
135        }
136    }
137    let mut s: f32 = acc.iter().sum();
138    for (x, y) in ar.iter().zip(br) {
139        s += x * y;
140    }
141    s
142}
143
144// inline(always): same hot-path rationale as `dot_lanes`.
145#[allow(clippy::inline_always)]
146#[inline(always)]
147fn l2_lanes(a: &[f32], b: &[f32]) -> f32 {
148    let mut acc = [0.0f32; 8];
149    let (ac, ar) = a.split_at(a.len() - a.len() % 8);
150    let (bc, br) = b.split_at(ac.len().min(b.len()));
151    for (ca, cb) in ac.chunks_exact(8).zip(bc.chunks_exact(8)) {
152        for i in 0..8 {
153            let d = ca[i] - cb[i];
154            acc[i] += d * d;
155        }
156    }
157    let mut s: f32 = acc.iter().sum();
158    for (x, y) in ar.iter().zip(br) {
159        let d = x - y;
160        s += d * d;
161    }
162    s
163}
164
165/// Decode a wire vector: raw f32 LE bytes (`len == dim*4`), or the
166/// debug form `csv:1.0,2.5,…`. `None` on any mismatch.
167pub fn parse_vector(raw: &[u8], dim: usize) -> Option<Vec<f32>> {
168    if let Some(csv) = raw.strip_prefix(b"csv:") {
169        let vals: Option<Vec<f32>> = std::str::from_utf8(csv)
170            .ok()?
171            .split(',')
172            .map(|s| s.trim().parse::<f32>().ok())
173            .collect();
174        let vals = vals?;
175        return (vals.len() == dim && vals.iter().all(|x| x.is_finite())).then_some(vals);
176    }
177    if raw.len() != dim * 4 {
178        return None;
179    }
180    let mut out = Vec::with_capacity(dim);
181    for chunk in raw.chunks_exact(4) {
182        let x = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
183        if !x.is_finite() {
184            return None;
185        }
186        out.push(x);
187    }
188    Some(out)
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn metrics_smaller_is_closer() {
197        let mut a = vec![1.0, 0.0];
198        let mut b = vec![0.9, 0.1];
199        let mut c = vec![-1.0, 0.0];
200        for v in [&mut a, &mut b, &mut c] {
201            Distance::Cosine.prepare(v);
202        }
203        assert!(Distance::Cosine.eval(&a, &b) < Distance::Cosine.eval(&a, &c));
204        assert!(Distance::L2.eval(&[0.0, 0.0], &[1.0, 1.0]) > Distance::L2.eval(&[0.0, 0.0], &[0.5, 0.5]));
205        assert!(Distance::Ip.eval(&[1.0, 1.0], &[2.0, 2.0]) < Distance::Ip.eval(&[1.0, 1.0], &[0.1, 0.1]));
206    }
207
208    #[test]
209    fn wire_formats() {
210        let mut raw = Vec::new();
211        for x in [1.0f32, -2.5, 3.25] {
212            raw.extend_from_slice(&x.to_le_bytes());
213        }
214        assert_eq!(parse_vector(&raw, 3), Some(vec![1.0, -2.5, 3.25]));
215        assert_eq!(parse_vector(&raw, 4), None, "dim mismatch");
216        assert_eq!(parse_vector(b"csv:1, 2.5, -3", 3), Some(vec![1.0, 2.5, -3.0]));
217        assert_eq!(parse_vector(b"csv:1,x,3", 3), None);
218        let mut nan = Vec::new();
219        for x in [1.0f32, f32::NAN] {
220            nan.extend_from_slice(&x.to_le_bytes());
221        }
222        assert_eq!(parse_vector(&nan, 2), None, "non-finite rejected");
223        assert!(Distance::parse(b"COSINE") == Some(Distance::Cosine));
224        assert!(Distance::parse(b"nope").is_none());
225    }
226}