Skip to main content

lance_core/utils/cpu/
dispatch.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Selecting one kernel per operation from candidates ranked by preference.
5//!
6//! ```
7//! use lance_core::utils::cpu::Dispatch;
8//!
9//! type DotFn = unsafe fn(&[f32], &[f32]) -> f32;
10//!
11//! lance_core::target_kernel! {
12//!     #[target_features()]
13//!     #[candidate(DOT_PORTABLE: DotFn)]
14//!     fn dot_portable(x: &[f32], y: &[f32]) -> f32 {
15//!         x.iter().zip(y).map(|(a, b)| a * b).sum()
16//!     }
17//! }
18//!
19//! lance_core::dispatch! {
20//!     /// Candidates best first; the last must need no features.
21//!     static DOT: Dispatch<DotFn> = &[DOT_PORTABLE];
22//! }
23//!
24//! assert_eq!(DOT.get().call(&[1.0, 2.0], &[3.0, 4.0]), 11.0);
25//! ```
26
27use std::sync::OnceLock;
28
29use super::CpuFeatures;
30
31/// A kernel together with the CPU features it needs.
32///
33/// Construct one with [`target_kernel!`](crate::target_kernel), which derives
34/// the features from the kernel's own `#[target_feature]` list.
35#[derive(Clone, Copy)]
36pub struct Candidate<F> {
37    name: &'static str,
38    features: CpuFeatures,
39    kernel: F,
40}
41
42impl<F: Copy> Candidate<F> {
43    /// A candidate named `name` that runs `kernel` on hosts with `features`.
44    /// Prefer [`target_kernel!`](crate::target_kernel), which derives
45    /// `features` from the kernel's `#[target_feature]` list.
46    ///
47    /// # Safety
48    ///
49    /// Calling `kernel` must be sound on every host that supports `features`.
50    pub const unsafe fn new(name: &'static str, features: CpuFeatures, kernel: F) -> Self {
51        Self {
52            name,
53            features,
54            kernel,
55        }
56    }
57
58    /// The kernel's function name.
59    pub const fn name(&self) -> &'static str {
60        self.name
61    }
62
63    /// Features the host needs to run this kernel.
64    pub const fn features(&self) -> CpuFeatures {
65        self.features
66    }
67
68    /// The kernel, if the host supports it. Lets tests run every candidate.
69    pub fn kernel(&self) -> Option<Kernel<F>> {
70        CpuFeatures::host()
71            .contains(self.features)
72            .then_some(Kernel(self.kernel))
73    }
74}
75
76/// A kernel the host is known to support, so calling it is safe.
77#[derive(Clone, Copy)]
78pub struct Kernel<F>(F);
79
80impl<A: ?Sized, B: ?Sized, R> Kernel<unsafe fn(&A, &B) -> R> {
81    /// Runs a pair kernel, such as a distance between two vectors.
82    #[inline(always)]
83    pub fn call(self, a: &A, b: &B) -> R {
84        // SAFETY: a `Kernel` only exists for a candidate whose features the
85        // host supports, which is the kernel's only precondition.
86        unsafe { (self.0)(a, b) }
87    }
88}
89
90impl<A: ?Sized, B: ?Sized, C, D: ?Sized, R> Kernel<unsafe fn(&A, &B, C, &mut D) -> R> {
91    /// Runs a slice-output kernel, such as distances from one query to a
92    /// batch written into an output slice.
93    #[inline(always)]
94    pub fn call(self, a: &A, b: &B, c: C, d: &mut D) -> R {
95        // SAFETY: see the two-argument `call`.
96        unsafe { (self.0)(a, b, c, d) }
97    }
98}
99
100/// One operation's kernels, best first, with the host's choice made once.
101///
102/// Declare with [`dispatch!`](crate::dispatch). When the build baseline already
103/// supports the best candidate, [`Self::get`] returns it without touching the
104/// host feature set, so the compiler sees a direct call it can inline.
105pub struct Dispatch<F: 'static> {
106    candidates: &'static [Candidate<F>],
107    // Kept outside `Dispatch` so a `static Dispatch` has no interior
108    // mutability; the compiler can then constant-fold `is_static` and the
109    // first kernel in `get`.
110    selected: &'static Selection<F>,
111    is_static: bool,
112}
113
114/// Where a [`Dispatch`] caches its host-selected kernel.
115///
116/// Only [`Dispatch::get`] writes it, and only with a kernel the host supports,
117/// so safe code cannot plant a kernel that skips the host check.
118///
119/// Each `Selection` must belong to exactly one [`Dispatch`]. Two dispatches
120/// sharing one is still sound, but the second returns the first's kernel.
121/// [`dispatch!`](crate::dispatch) creates a private one per dispatch.
122pub struct Selection<F>(OnceLock<F>);
123
124impl<F> Selection<F> {
125    /// An empty cache, filled on the owning dispatch's first non-static `get`.
126    #[allow(clippy::new_without_default)]
127    pub const fn new() -> Self {
128        Self(OnceLock::new())
129    }
130}
131
132impl<F: Copy> Dispatch<F> {
133    /// Prefer [`dispatch!`](crate::dispatch), which provides `selected`.
134    ///
135    /// # Panics
136    ///
137    /// Panics if `candidates` is empty or its last candidate needs any
138    /// feature, so that every host has a kernel. Evaluated in a `static`, this
139    /// is a compile error.
140    pub const fn new(candidates: &'static [Candidate<F>], selected: &'static Selection<F>) -> Self {
141        let Some(fallback) = candidates.last() else {
142            panic!("a Dispatch needs at least one candidate");
143        };
144        assert!(
145            fallback.features.is_empty(),
146            "the last Dispatch candidate must not need any CPU feature"
147        );
148        Self {
149            candidates,
150            selected,
151            is_static: CpuFeatures::baseline().contains(candidates[0].features),
152        }
153    }
154
155    /// The best kernel the host supports.
156    #[inline(always)]
157    pub fn get(&self) -> Kernel<F> {
158        if self.is_static {
159            // Sound without a host check: `new` set `is_static` only if the
160            // build baseline covers the first candidate's features, and every
161            // host that can run this binary has the baseline.
162            return Kernel(self.candidates[0].kernel);
163        }
164        Kernel(*self.selected.0.get_or_init(|| self.selected().kernel))
165    }
166
167    /// The candidate [`Self::get`] returns.
168    pub fn selected(&self) -> &'static Candidate<F> {
169        &self.candidates[select(self.candidates, CpuFeatures::host())]
170    }
171
172    /// Whether the build baseline supports the best candidate, so selection
173    /// happens at compile time.
174    #[cfg(test)]
175    pub const fn is_static(&self) -> bool {
176        self.is_static
177    }
178
179    /// All candidates, best first.
180    pub const fn candidates(&self) -> &'static [Candidate<F>] {
181        self.candidates
182    }
183}
184
185/// Index of the first candidate `host` supports.
186fn select<F>(candidates: &[Candidate<F>], host: CpuFeatures) -> usize {
187    candidates
188        .iter()
189        .position(|candidate| host.contains(candidate.features))
190        .expect("Dispatch::new requires a candidate without features")
191}
192
193/// Declares a kernel and a [`Candidate`] for it from one feature list.
194///
195/// The kernel gets one `#[target_feature(enable = ...)]` per listed feature,
196/// and the candidate requires exactly those features plus the ones rustc
197/// implies, so the two cannot drift apart. The kernel is a safe function: its
198/// only precondition is the target features, and a [`Dispatch`] or
199/// [`Candidate::kernel`] checks those before handing out a callable
200/// [`Kernel`]. Unknown feature names fail to compile.
201///
202/// The candidate type must be the `unsafe fn` pointer type of the kernel.
203///
204/// The kernel takes only doc comments and `#[inline]`. Arbitrary attributes
205/// are rejected because a `#[target_feature]` among them would enable
206/// features the candidate does not require.
207#[macro_export]
208macro_rules! target_kernel {
209    (
210        #[target_features($($feature:literal),* $(,)?)]
211        #[candidate($candidate_vis:vis $candidate:ident: $kernel_type:ty)]
212        $(#[doc = $doc:literal])*
213        $(#[inline $(($inline:ident))?])?
214        $vis:vis fn $name:ident($($params:tt)*) $(-> $ret:ty)? $body:block
215    ) => {
216        $(#[doc = $doc])*
217        $(#[inline $(($inline))?])?
218        $(#[target_feature(enable = $feature)])*
219        $vis fn $name($($params)*) $(-> $ret)? $body
220
221        $candidate_vis const $candidate: $crate::utils::cpu::Candidate<$kernel_type> = {
222            const FEATURES: $crate::utils::cpu::CpuFeatures =
223                $crate::utils::cpu::CpuFeatures::from_target_features(&[$($feature),*]);
224            // SAFETY: the kernel is a safe function whose `#[target_feature]`
225            // attributes come from the same list as `FEATURES`.
226            unsafe { $crate::utils::cpu::Candidate::new(stringify!($name), FEATURES, $name) }
227        };
228    };
229}
230
231/// Declares a `static` [`Dispatch`] over a slice of [`Candidate`]s, best first.
232///
233/// The slice may be a block whose tail expressions are `#[cfg]`-gated per
234/// architecture.
235#[macro_export]
236macro_rules! dispatch {
237    (
238        $(#[$attr:meta])*
239        $vis:vis static $name:ident: Dispatch<$kernel_type:ty> = $candidates:expr;
240    ) => {
241        $(#[$attr])*
242        $vis static $name: $crate::utils::cpu::Dispatch<$kernel_type> = {
243            static SELECTED: $crate::utils::cpu::Selection<$kernel_type> =
244                $crate::utils::cpu::Selection::new();
245            $crate::utils::cpu::Dispatch::new($candidates, &SELECTED)
246        };
247    };
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    type SumFn = unsafe fn(&[f32], &[f32]) -> f32;
255    type FillFn = unsafe fn(&[f32], &[f32], usize, &mut [f32]);
256
257    fn portable_sum(x: &[f32], y: &[f32]) -> f32 {
258        x.iter().chain(y).sum()
259    }
260
261    fn doubled_sum(x: &[f32], y: &[f32]) -> f32 {
262        2.0 * portable_sum(x, y)
263    }
264
265    crate::target_kernel! {
266        #[target_features()]
267        #[candidate(PORTABLE_SUM: SumFn)]
268        fn macro_sum(x: &[f32], y: &[f32]) -> f32 {
269            portable_sum(x, y)
270        }
271    }
272
273    crate::target_kernel! {
274        #[target_features()]
275        #[candidate(PORTABLE_FILL: FillFn)]
276        fn macro_fill(x: &[f32], batch: &[f32], dimension: usize, out: &mut [f32]) {
277            for (o, y) in out.iter_mut().zip(batch.chunks_exact(dimension)) {
278                *o = portable_sum(x, y);
279            }
280        }
281    }
282
283    #[cfg(target_arch = "x86_64")]
284    crate::target_kernel! {
285        #[target_features("avx2", "fma")]
286        #[candidate(ARCH_SUM: SumFn)]
287        fn arch_sum(x: &[f32], y: &[f32]) -> f32 {
288            portable_sum(x, y)
289        }
290    }
291
292    #[cfg(target_arch = "aarch64")]
293    crate::target_kernel! {
294        #[target_features("neon")]
295        #[candidate(ARCH_SUM: SumFn)]
296        fn arch_sum(x: &[f32], y: &[f32]) -> f32 {
297            portable_sum(x, y)
298        }
299    }
300
301    fn feature_missing_from_host() -> CpuFeatures {
302        let host = CpuFeatures::host();
303        CpuFeatures::all()
304            .find(|feature| !host.contains(*feature))
305            .expect("every host lacks the features of the other architectures")
306    }
307
308    #[test]
309    fn select_takes_the_first_candidate_the_host_supports() {
310        let missing = feature_missing_from_host();
311        // SAFETY: the kernels are plain Rust functions without target features.
312        let candidates: [Candidate<SumFn>; 2] = unsafe {
313            [
314                Candidate::new("unsupported", missing, doubled_sum),
315                Candidate::new("portable", CpuFeatures::empty(), portable_sum),
316            ]
317        };
318        assert_eq!(select(&candidates, CpuFeatures::host()), 1);
319        assert_eq!(select(&candidates, missing), 0);
320    }
321
322    #[test]
323    fn unsupported_candidate_yields_no_kernel() {
324        // SAFETY: the kernel is a plain Rust function without target features.
325        let candidate: Candidate<SumFn> =
326            unsafe { Candidate::new("unsupported", feature_missing_from_host(), doubled_sum) };
327        assert!(candidate.kernel().is_none());
328    }
329
330    crate::dispatch! {
331        static PORTABLE_ONLY: Dispatch<SumFn> = &[PORTABLE_SUM];
332    }
333
334    #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
335    crate::dispatch! {
336        static ARCH_FIRST: Dispatch<SumFn> = &[ARCH_SUM, PORTABLE_SUM];
337    }
338
339    crate::dispatch! {
340        static FILL: Dispatch<FillFn> = &[PORTABLE_FILL];
341    }
342
343    #[test]
344    fn portable_only_dispatch_is_static_and_callable() {
345        assert!(PORTABLE_ONLY.is_static());
346        assert_eq!(PORTABLE_ONLY.get().call(&[1.0, 2.0], &[3.0]), 6.0);
347        assert_eq!(PORTABLE_ONLY.selected().name(), "macro_sum");
348    }
349
350    /// A feature no host of this architecture has, as a `const` so it can
351    /// gate a `static` dispatch.
352    const OTHER_ARCH_FEATURE: CpuFeatures = if cfg!(target_arch = "x86_64") {
353        CpuFeatures::NEON
354    } else {
355        CpuFeatures::AVX512F
356    };
357
358    // SAFETY: the kernel is a plain Rust function without target features.
359    const UNSUPPORTED_SUM: Candidate<SumFn> =
360        unsafe { Candidate::new("unsupported", OTHER_ARCH_FEATURE, doubled_sum) };
361
362    crate::dispatch! {
363        static UNSUPPORTED_FIRST: Dispatch<SumFn> = &[UNSUPPORTED_SUM, PORTABLE_SUM];
364    }
365
366    #[test]
367    fn runtime_dispatch_selects_and_caches_the_supported_kernel() {
368        assert!(!UNSUPPORTED_FIRST.is_static());
369        assert_eq!(UNSUPPORTED_FIRST.selected().name(), "macro_sum");
370        // The first call selects and caches; the second reads the cache.
371        assert_eq!(UNSUPPORTED_FIRST.get().call(&[1.0, 2.0], &[3.0]), 6.0);
372        assert_eq!(UNSUPPORTED_FIRST.get().call(&[1.0, 2.0], &[3.0]), 6.0);
373    }
374
375    #[test]
376    fn new_rejects_candidate_lists_without_a_portable_fallback() {
377        static SELECTED: Selection<SumFn> = Selection::new();
378        // Called at runtime: in a `static` these are compile errors.
379        let empty = std::panic::catch_unwind(|| Dispatch::<SumFn>::new(&[], &SELECTED));
380        assert!(empty.is_err());
381        let needs_features =
382            std::panic::catch_unwind(|| Dispatch::new(&[UNSUPPORTED_SUM], &SELECTED));
383        assert!(needs_features.is_err());
384    }
385
386    #[test]
387    fn slice_output_kernel_is_callable() {
388        let mut out = [0.0; 2];
389        FILL.get().call(&[1.0], &[2.0, 3.0], 1, &mut out);
390        assert_eq!(out, [3.0, 4.0]);
391    }
392
393    #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
394    #[test]
395    fn macro_mask_matches_declared_target_features() {
396        #[cfg(target_arch = "x86_64")]
397        let names: &[&str] = &["avx2", "fma"];
398        #[cfg(target_arch = "aarch64")]
399        let names: &[&str] = &["neon"];
400        assert_eq!(
401            ARCH_SUM.features(),
402            CpuFeatures::from_target_features(names)
403        );
404        assert_eq!(ARCH_SUM.name(), "arch_sum");
405        assert!(PORTABLE_SUM.features().is_empty());
406
407        let is_supported = CpuFeatures::host().contains(ARCH_SUM.features());
408        assert_eq!(ARCH_FIRST.selected().name() == "arch_sum", is_supported);
409        assert_eq!(
410            ARCH_FIRST.is_static(),
411            CpuFeatures::baseline().contains(ARCH_SUM.features())
412        );
413        for candidate in ARCH_FIRST.candidates() {
414            if let Some(kernel) = candidate.kernel() {
415                assert_eq!(kernel.call(&[1.0], &[2.0]), 3.0, "{}", candidate.name());
416            }
417        }
418    }
419}