Skip to main content

slate_simd/
dispatch.rs

1//! Runtime CPU-feature dispatch.
2//!
3//! The best available kernel tier is detected **once** (first use) and the
4//! resolved `fn` pointers are cached in `OnceLock`s. After warm-up, each call is
5//! a plain indirect call with no feature-detection overhead.
6//!
7//! Selection order (best first):
8//! - x86-64: AVX-512F → AVX2+FMA → scalar
9//! - aarch64: NEON → scalar
10//! - other:   scalar
11//!
12//! Public kernels in [`crate`] route through here. Tests can also force a tier
13//! to compare a specific implementation against the scalar oracle.
14
15use std::sync::OnceLock;
16
17use crate::scalar;
18#[cfg(target_arch = "x86_64")]
19use crate::{avx2, avx512};
20#[cfg(target_arch = "aarch64")]
21use crate::neon;
22
23/// Which implementation tier the dispatcher selected for this CPU.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Tier {
26    /// Portable scalar fallback (always available).
27    Scalar,
28    /// AVX2 + FMA (x86-64).
29    Avx2,
30    /// AVX-512F (x86-64).
31    Avx512,
32    /// ARM NEON (aarch64).
33    Neon,
34}
35
36impl Tier {
37    /// Human-readable tier name (for logs / CLI `bench` output).
38    #[must_use]
39    pub const fn as_str(self) -> &'static str {
40        match self {
41            Tier::Scalar => "scalar",
42            Tier::Avx2 => "avx2",
43            Tier::Avx512 => "avx512",
44            Tier::Neon => "neon",
45        }
46    }
47}
48
49/// Detect the best tier supported by the current CPU.
50///
51/// Uses compile-time `cfg` to pick the candidate set per architecture and
52/// runtime `is_*_feature_detected!` macros to confirm availability. The result
53/// is cached by [`active_tier`].
54#[must_use]
55pub fn detect_tier() -> Tier {
56    #[cfg(target_arch = "x86_64")]
57    {
58        if is_x86_feature_detected!("avx512f") {
59            return Tier::Avx512;
60        }
61        if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") {
62            return Tier::Avx2;
63        }
64    }
65    #[cfg(target_arch = "aarch64")]
66    {
67        if std::arch::is_aarch64_feature_detected!("neon") {
68            return Tier::Neon;
69        }
70    }
71    Tier::Scalar
72}
73
74/// The cached active tier for this process.
75#[must_use]
76pub fn active_tier() -> Tier {
77    static TIER: OnceLock<Tier> = OnceLock::new();
78    *TIER.get_or_init(detect_tier)
79}
80
81/// Signature shared by the L2² / dot kernels.
82pub type BinaryKernel = fn(&[f32], &[f32]) -> f32;
83
84// --- Thin safe wrappers that assert the target feature before the unsafe call.
85// Each wrapper is only ever installed as the active pointer when `detect_tier`
86// confirmed the corresponding feature, so the precondition holds.
87
88#[cfg(target_arch = "x86_64")]
89fn l2_sq_avx2(a: &[f32], b: &[f32]) -> f32 {
90    // SAFETY: only selected when AVX2+FMA detected (see `resolve_l2_sq`).
91    unsafe { avx2::l2_sq(a, b) }
92}
93#[cfg(target_arch = "x86_64")]
94fn l2_sq_avx512(a: &[f32], b: &[f32]) -> f32 {
95    // SAFETY: only selected when AVX-512F detected.
96    unsafe { avx512::l2_sq(a, b) }
97}
98#[cfg(target_arch = "x86_64")]
99fn dot_avx2(a: &[f32], b: &[f32]) -> f32 {
100    // SAFETY: only selected when AVX2+FMA detected.
101    unsafe { avx2::dot(a, b) }
102}
103#[cfg(target_arch = "x86_64")]
104fn dot_avx512(a: &[f32], b: &[f32]) -> f32 {
105    // SAFETY: only selected when AVX-512F detected.
106    unsafe { avx512::dot(a, b) }
107}
108
109#[cfg(target_arch = "aarch64")]
110fn l2_sq_neon(a: &[f32], b: &[f32]) -> f32 {
111    // SAFETY: only selected when NEON detected.
112    unsafe { neon::l2_sq(a, b) }
113}
114#[cfg(target_arch = "aarch64")]
115fn dot_neon(a: &[f32], b: &[f32]) -> f32 {
116    // SAFETY: only selected when NEON detected.
117    unsafe { neon::dot(a, b) }
118}
119
120fn resolve_l2_sq() -> BinaryKernel {
121    match active_tier() {
122        #[cfg(target_arch = "x86_64")]
123        Tier::Avx512 => l2_sq_avx512,
124        #[cfg(target_arch = "x86_64")]
125        Tier::Avx2 => l2_sq_avx2,
126        #[cfg(target_arch = "aarch64")]
127        Tier::Neon => l2_sq_neon,
128        _ => scalar::l2_sq,
129    }
130}
131
132fn resolve_dot() -> BinaryKernel {
133    match active_tier() {
134        #[cfg(target_arch = "x86_64")]
135        Tier::Avx512 => dot_avx512,
136        #[cfg(target_arch = "x86_64")]
137        Tier::Avx2 => dot_avx2,
138        #[cfg(target_arch = "aarch64")]
139        Tier::Neon => dot_neon,
140        _ => scalar::dot,
141    }
142}
143
144/// Cached best `l2_sq` kernel.
145#[must_use]
146pub fn l2_sq_kernel() -> BinaryKernel {
147    static K: OnceLock<BinaryKernel> = OnceLock::new();
148    *K.get_or_init(resolve_l2_sq)
149}
150
151/// Cached best `dot` kernel.
152#[must_use]
153pub fn dot_kernel() -> BinaryKernel {
154    static K: OnceLock<BinaryKernel> = OnceLock::new();
155    *K.get_or_init(resolve_dot)
156}
157
158/// Compute the raw cosine accumulators `(dot, ‖a‖², ‖b‖²)` with the best tier.
159///
160/// Cosine returns three values, so it does not share [`BinaryKernel`]. We branch
161/// on the cached tier each call; the branch is trivially predictable.
162#[must_use]
163pub fn cosine_parts(a: &[f32], b: &[f32]) -> (f32, f32, f32) {
164    match active_tier() {
165        #[cfg(target_arch = "x86_64")]
166        Tier::Avx512 => unsafe { avx512::cosine_parts(a, b) }, // SAFETY: tier-gated
167        #[cfg(target_arch = "x86_64")]
168        Tier::Avx2 => unsafe { avx2::cosine_parts(a, b) }, // SAFETY: tier-gated
169        #[cfg(target_arch = "aarch64")]
170        Tier::Neon => unsafe { neon::cosine_parts(a, b) }, // SAFETY: tier-gated
171        _ => {
172            // Scalar single-pass parts.
173            let mut d = 0.0f32;
174            let mut na = 0.0f32;
175            let mut nb = 0.0f32;
176            for i in 0..a.len() {
177                d += a[i] * b[i];
178                na += a[i] * a[i];
179                nb += b[i] * b[i];
180            }
181            (d, na, nb)
182        }
183    }
184}
185
186// --- Narrow-stored kernel dispatch (f32 query vs f16/i8 store) ----------------
187//
188// Narrow kernels do not share `BinaryKernel` (extra `stored`/`scale`+`codes`
189// args), so they branch on the cached tier each call like `cosine_parts`. The
190// `f16` widen needs an extra CPU feature beyond the base tier — `f16c` on
191// x86-64, `fp16` on aarch64 — so the `f16` paths consult [`f16_simd_ok`] and
192// fall back to the scalar oracle when it is absent. The `i8` paths need nothing
193// beyond the base tier.
194
195/// Whether the active tier can run the native `f16` widen (`vcvtph2ps`).
196///
197/// AVX-512F already implies `_mm512_cvtph_ps`; the AVX2 path additionally needs
198/// `f16c`; the NEON path needs `fp16`. Cached for the process.
199#[must_use]
200fn f16_simd_ok() -> bool {
201    static OK: OnceLock<bool> = OnceLock::new();
202    *OK.get_or_init(|| match active_tier() {
203        #[cfg(target_arch = "x86_64")]
204        Tier::Avx512 => true,
205        #[cfg(target_arch = "x86_64")]
206        Tier::Avx2 => is_x86_feature_detected!("f16c"),
207        #[cfg(target_arch = "aarch64")]
208        Tier::Neon => std::arch::is_aarch64_feature_detected!("fp16"),
209        _ => false,
210    })
211}
212
213/// Dispatch `l2_sq` between an `f32` query and an `f16` store.
214#[must_use]
215pub fn l2_sq_f16(query: &[f32], stored: &[u8]) -> f32 {
216    if f16_simd_ok() {
217        match active_tier() {
218            #[cfg(target_arch = "x86_64")]
219            Tier::Avx512 => return unsafe { avx512::l2_sq_f16(query, stored) }, // SAFETY: tier-gated
220            #[cfg(target_arch = "x86_64")]
221            Tier::Avx2 => return unsafe { avx2::l2_sq_f16(query, stored) }, // SAFETY: tier+f16c-gated
222            #[cfg(target_arch = "aarch64")]
223            Tier::Neon => return unsafe { neon::l2_sq_f16(query, stored) }, // SAFETY: tier+fp16-gated
224            _ => {}
225        }
226    }
227    scalar::l2_sq_f16(query, stored)
228}
229
230/// Dispatch raw `dot` between an `f32` query and an `f16` store.
231#[must_use]
232pub fn dot_f16(query: &[f32], stored: &[u8]) -> f32 {
233    if f16_simd_ok() {
234        match active_tier() {
235            #[cfg(target_arch = "x86_64")]
236            Tier::Avx512 => return unsafe { avx512::dot_f16(query, stored) }, // SAFETY: tier-gated
237            #[cfg(target_arch = "x86_64")]
238            Tier::Avx2 => return unsafe { avx2::dot_f16(query, stored) }, // SAFETY: tier+f16c-gated
239            #[cfg(target_arch = "aarch64")]
240            Tier::Neon => return unsafe { neon::dot_f16(query, stored) }, // SAFETY: tier+fp16-gated
241            _ => {}
242        }
243    }
244    scalar::dot_f16(query, stored)
245}
246
247/// Dispatch cosine accumulators for an `f32` query and an `f16` store.
248#[must_use]
249pub fn cosine_parts_f16(query: &[f32], stored: &[u8]) -> (f32, f32, f32) {
250    if f16_simd_ok() {
251        match active_tier() {
252            #[cfg(target_arch = "x86_64")]
253            Tier::Avx512 => return unsafe { avx512::cosine_parts_f16(query, stored) }, // SAFETY: tier-gated
254            #[cfg(target_arch = "x86_64")]
255            Tier::Avx2 => return unsafe { avx2::cosine_parts_f16(query, stored) }, // SAFETY: tier+f16c-gated
256            #[cfg(target_arch = "aarch64")]
257            Tier::Neon => return unsafe { neon::cosine_parts_f16(query, stored) }, // SAFETY: tier+fp16-gated
258            _ => {}
259        }
260    }
261    scalar::cosine_parts_f16(query, stored)
262}
263
264/// Dispatch `l2_sq` between an `f32` query and an `i8` store.
265#[must_use]
266pub fn l2_sq_i8(query: &[f32], scale: f32, codes: &[i8]) -> f32 {
267    match active_tier() {
268        #[cfg(target_arch = "x86_64")]
269        Tier::Avx512 => unsafe { avx512::l2_sq_i8(query, scale, codes) }, // SAFETY: tier-gated
270        #[cfg(target_arch = "x86_64")]
271        Tier::Avx2 => unsafe { avx2::l2_sq_i8(query, scale, codes) }, // SAFETY: tier-gated
272        #[cfg(target_arch = "aarch64")]
273        Tier::Neon => unsafe { neon::l2_sq_i8(query, scale, codes) }, // SAFETY: tier-gated
274        _ => scalar::l2_sq_i8(query, scale, codes),
275    }
276}
277
278/// Dispatch raw `dot` between an `f32` query and an `i8` store.
279#[must_use]
280pub fn dot_i8(query: &[f32], scale: f32, codes: &[i8]) -> f32 {
281    match active_tier() {
282        #[cfg(target_arch = "x86_64")]
283        Tier::Avx512 => unsafe { avx512::dot_i8(query, scale, codes) }, // SAFETY: tier-gated
284        #[cfg(target_arch = "x86_64")]
285        Tier::Avx2 => unsafe { avx2::dot_i8(query, scale, codes) }, // SAFETY: tier-gated
286        #[cfg(target_arch = "aarch64")]
287        Tier::Neon => unsafe { neon::dot_i8(query, scale, codes) }, // SAFETY: tier-gated
288        _ => scalar::dot_i8(query, scale, codes),
289    }
290}
291
292/// Dispatch cosine accumulators for an `f32` query and an `i8` store.
293#[must_use]
294pub fn cosine_parts_i8(query: &[f32], scale: f32, codes: &[i8]) -> (f32, f32, f32) {
295    match active_tier() {
296        #[cfg(target_arch = "x86_64")]
297        Tier::Avx512 => unsafe { avx512::cosine_parts_i8(query, scale, codes) }, // SAFETY: tier-gated
298        #[cfg(target_arch = "x86_64")]
299        Tier::Avx2 => unsafe { avx2::cosine_parts_i8(query, scale, codes) }, // SAFETY: tier-gated
300        #[cfg(target_arch = "aarch64")]
301        Tier::Neon => unsafe { neon::cosine_parts_i8(query, scale, codes) }, // SAFETY: tier-gated
302        _ => scalar::cosine_parts_i8(query, scale, codes),
303    }
304}