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}