Skip to main content

urna_runtime/simd/
mod.rs

1//! SIMD-accelerated dot products for the search hot path.
2//!
3//! Three dtype paths, three SIMD targets:
4//!
5//! ```text
6//!                AVX2 (x86_64)         NEON (aarch64)        Scalar
7//!   f32 · f32    8 lanes (f32x8)       4 lanes (f32x4)       autovec
8//!   f32 · f16    8 lanes (load+cvt)    4 lanes (load+cvt)    autovec
9//!   f32 · i8     16 lanes (i8 -> i32)  16 lanes (i8 -> i32)  autovec
10//!   f32 · i4     32-nib unpack/step    32-nib unpack/step    autovec
11//! ```
12//!
13//! The int4 kernel is block-64 (per-group f16 absmax scale). SIMD
14//! vectorizes the nibble unpack but reduces per group identically to the
15//! scalar path, so all three backends agree bit-for-bit.
16//!
17//! Accumulators are always f32. The query is f32 (L2-normalized), the
18//! database is f32 / f16 / i8 (i8 with a per-vector scale). Final score
19//! is the real cosine.
20//!
21//! Detection happens once at module load via `OnceLock`. The dispatch
22//! function is a function pointer chosen at first call, so the per-query
23//! cost is one indirect call, not a CPUID check per vector.
24//!
25//! Safety boundary. Every `unsafe` kernel below reads through raw pointers
26//! sized by `q.len()` / `dim`, so the safe dispatchers in this module are
27//! the ONLY place the length invariants are checked, and they check with
28//! `assert!` (kept in release builds), never `debug_assert!`. The reader
29//! validates section sizes at open time too, but a defence three layers
30//! away from the pointer is not a defence; the cost is one integer compare
31//! per row, invisible next to the dot product itself.
32
33#[cfg(target_arch = "x86_64")]
34mod avx2;
35#[cfg(target_arch = "aarch64")]
36mod neon;
37mod scalar;
38
39#[cfg(test)]
40mod tests;
41
42use std::sync::OnceLock;
43
44use urna_format::Int8EmbeddingsView;
45
46pub use scalar::{
47    dot_f32_f16_scalar, dot_f32_i4_blocked_scalar, dot_f32_i8_scalar, dot_f32_scalar,
48};
49
50/// What backend is the runtime using right now? Useful for `urna stats`
51/// / benchmarks so the user can see whether SIMD is active.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum SimdBackend {
54    Scalar,
55    Avx2,
56    Neon,
57}
58
59impl SimdBackend {
60    pub fn name(self) -> &'static str {
61        match self {
62            Self::Scalar => "scalar",
63            Self::Avx2 => "avx2",
64            Self::Neon => "neon",
65        }
66    }
67}
68
69static BACKEND: OnceLock<SimdBackend> = OnceLock::new();
70
71/// The SIMD backend selected at runtime. Cached after the first call.
72///
73/// Set `URNA_FORCE_SCALAR=1` to disable SIMD entirely — useful for
74/// before/after SIMD benchmarks on the same binary.
75pub fn detect_backend() -> SimdBackend {
76    *BACKEND.get_or_init(|| {
77        if std::env::var("URNA_FORCE_SCALAR")
78            .map(|v| v != "0")
79            .unwrap_or(false)
80        {
81            return SimdBackend::Scalar;
82        }
83        #[cfg(target_arch = "x86_64")]
84        {
85            if std::is_x86_feature_detected!("avx2") && std::is_x86_feature_detected!("fma") {
86                return SimdBackend::Avx2;
87            }
88        }
89        #[cfg(target_arch = "aarch64")]
90        {
91            if std::arch::is_aarch64_feature_detected!("neon") {
92                return SimdBackend::Neon;
93            }
94        }
95        SimdBackend::Scalar
96    })
97}
98
99/// Dot product between an f32 query and an f32 row stored as little-endian
100/// bytes (the way embeddings live in mmap).
101#[inline]
102pub fn dot_f32_bytes(q: &[f32], row_bytes: &[u8]) -> f32 {
103    assert_eq!(
104        row_bytes.len(),
105        q.len() * 4,
106        "dot_f32_bytes: row byte length must be 4 * query dim"
107    );
108    match detect_backend() {
109        // SAFETY: the assert above bounds every `add(i * 8)` load in the
110        // kernel (`i < dim / 8`) inside both `q` and `row_bytes`; the backend
111        // was selected only after `avx2` + `fma` were detected on this CPU.
112        #[cfg(target_arch = "x86_64")]
113        SimdBackend::Avx2 => unsafe { avx2::dot_f32_avx2(q, row_bytes) },
114        // SAFETY: same length invariant (lanes of 4 here); `neon` detected.
115        #[cfg(target_arch = "aarch64")]
116        SimdBackend::Neon => unsafe { neon::dot_f32_neon(q, row_bytes) },
117        _ => scalar::dot_f32_scalar(q, row_bytes),
118    }
119}
120
121/// Dot product between an f32 query and an f16 row stored as little-endian
122/// bytes. Accumulates in f32. The query stays f32 (it is normalized once
123/// per call, no need to drop precision there).
124#[inline]
125pub fn dot_f32_f16_bytes(q: &[f32], row_bytes: &[u8]) -> f32 {
126    assert_eq!(
127        row_bytes.len(),
128        q.len() * 2,
129        "dot_f32_f16_bytes: row byte length must be 2 * query dim"
130    );
131    match detect_backend() {
132        // the neon f16 arm needs rustc >= 1.94 (cfg(neon_f16), see build.rs);
133        // on older toolchains only this f16 path falls back to scalar.
134        // SAFETY: the assert above bounds every 4-lane u16 load (`i < dim / 4`)
135        // inside `row_bytes` and every f32 load inside `q`; `neon` detected.
136        #[cfg(all(target_arch = "aarch64", neon_f16))]
137        SimdBackend::Neon => unsafe { neon::dot_f32_f16_neon(q, row_bytes) },
138        // AVX2 has no native f16->f32 unless F16C is present; our cutoff
139        // is "AVX2 + FMA" which usually pulls F16C along. Using a portable
140        // unpack here keeps the AVX2 path simple and avoids the F16C
141        // detection branch.
142        _ => scalar::dot_f32_f16_scalar(q, row_bytes),
143    }
144}
145
146/// Dot product between an f32 query and a single i8 row, multiplied by
147/// the row's f32 scale. `q` stays f32; the i8 row is widened to i32 in
148/// the inner loop, multiplied by f32 lanes of `q`, accumulated in f32.
149///
150/// `f32_value ≈ i8_value * scale`, so:
151///   `q · v = scale * sum_i(q_i * i8_i)`.
152#[inline]
153pub fn dot_f32_i8(q: &[f32], row: &[i8], scale: f32) -> f32 {
154    assert_eq!(
155        row.len(),
156        q.len(),
157        "dot_f32_i8: int8 row length must equal query dim"
158    );
159    let acc = match detect_backend() {
160        // SAFETY: the assert above bounds every 8-lane load (`i < dim / 8`)
161        // inside both `q` and `row`; the backend was selected only after
162        // `avx2` + `fma` were detected on this CPU.
163        #[cfg(target_arch = "x86_64")]
164        SimdBackend::Avx2 => unsafe { avx2::dot_f32_i8_avx2(q, row) },
165        // SAFETY: same length invariant; `neon` detected.
166        #[cfg(target_arch = "aarch64")]
167        SimdBackend::Neon => unsafe { neon::dot_f32_i8_neon(q, row) },
168        _ => scalar::dot_f32_i8_scalar(q, row),
169    };
170    acc * scale
171}
172
173/// Fused dequant + dot for an int4 block-`block` row against an f32 query.
174/// `codes` is `dim/2` packed nibble bytes (low nibble first), `group_scales`
175/// is one f32 per `block`-dim group. The SIMD backends vectorize the nibble
176/// unpack but reduce per-group identically to scalar, so the result is
177/// bit-for-bit equal across all three backends (float add is not
178/// associative; a lane-parallel reduction would diverge in the last ulp).
179///
180/// `scratch` is the caller-owned `dim`-long f32 row the SIMD backends unpack
181/// the nibbles into. It is an argument, not a local, so a rerank over
182/// thousands of candidates reuses ONE allocation instead of paying a
183/// `malloc`/`free` pair per row (the scalar backend never touches it).
184/// Its contents on return are unspecified.
185///
186/// `f32_value ~= code * group_scales[group]`, so the cosine is
187/// `sum_g scale_g * sum_{j in g} q_j * code_j` accumulated in f32.
188///
189/// # Panics
190///
191/// When any length disagrees with `dim` / `block`: these asserts are the
192/// safety boundary for the raw-pointer kernels and stay on in release.
193#[inline]
194pub fn dot_f32_i4_blocked(
195    q: &[f32],
196    codes: &[u8],
197    group_scales: &[f32],
198    dim: usize,
199    block: usize,
200    scratch: &mut [f32],
201) -> f32 {
202    assert!(
203        block > 0 && dim % block == 0,
204        "int4: dim must be a multiple of block"
205    );
206    assert_eq!(q.len(), dim, "int4: query length must equal dim");
207    assert_eq!(
208        codes.len(),
209        dim / 2,
210        "int4: packed codes must be dim/2 bytes"
211    );
212    assert_eq!(group_scales.len(), dim / block, "int4: one scale per block");
213    assert_eq!(scratch.len(), dim, "int4: scratch row must be dim long");
214    match detect_backend() {
215        // SAFETY: the asserts above give the kernel `dim/2` code bytes, a
216        // `dim`-long scratch row and `dim/block` scales, so every 16-byte
217        // nibble load (`c < (dim/2) / 16`) and every 32-lane scratch store
218        // (`c * 32 + 32 <= dim`) is in bounds; `avx2` + `fma` detected.
219        #[cfg(target_arch = "x86_64")]
220        SimdBackend::Avx2 => unsafe {
221            avx2::dot_f32_i4_avx2(q, codes, group_scales, dim, block, scratch)
222        },
223        // SAFETY: same invariants; `neon` detected.
224        #[cfg(target_arch = "aarch64")]
225        SimdBackend::Neon => unsafe {
226            neon::dot_f32_i4_neon(q, codes, group_scales, dim, block, scratch)
227        },
228        _ => scalar::dot_f32_i4_blocked_scalar(q, codes, group_scales, dim, block),
229    }
230}
231
232/// Score every row of an int8 embeddings section against `q`.
233/// `out[i]` is the cosine score; the runtime sorts these.
234pub fn score_int8_section(q: &[f32], view: &Int8EmbeddingsView<'_>, out: &mut [f32]) {
235    assert_eq!(
236        out.len(),
237        view.n,
238        "score_int8_section: one output slot per row"
239    );
240    for (i, slot) in out.iter_mut().enumerate().take(view.n) {
241        let scale = view.scale(i);
242        let row = view.row(i);
243        *slot = dot_f32_i8(q, row, scale);
244    }
245}