Skip to content

Commit 4e1a780

Browse files
committed
feat: add cpu_dispatch! macro for one-time CPU-feature kernel selection
Several hot kernels re-run cached CPU-feature checks on every call (is_x86_feature_detected! is an atomic load + bit-test + branch chain per check, per call). Add vortex_buffer::cpu_dispatch!, an ifunc-style self-patching function pointer: the selector runs once on first call, patches an AtomicPtr slot, and every later call is a single relaxed load plus a predictable indirect call. Convert the worst repeat-detection sites as a demonstration: - fastlanes transpose_bits/untranspose_bits (2 feature checks per 128B call) - vortex-buffer select_in_chunk (3 checks per 64B chunk), scan_chunks_impl, and select_in_word aarch64 paths are intentionally left as direct #[cfg] calls: NEON is architecturally guaranteed, so compile-time dispatch is already zero-cost and inlinable there. The macro documents when to use it (coarse-grained kernels with >1 runtime candidate) and when not to (per-element loops, which should hoist + monomorphize like vortex-mask::intersect_by_rank). Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUFxXZLv4xxEqEYcoGWhfJ
1 parent 5abaf98 commit 4e1a780

4 files changed

Lines changed: 206 additions & 44 deletions

File tree

encodings/fastlanes/src/bit_transpose/mod.rs

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -45,56 +45,64 @@ const TRANSPOSE_8X8: u64 = 0x0000_0000_F0F0_F0F0;
4545

4646
/// Transpose 1024-bits into FastLanes layout.
4747
///
48-
/// Dispatch to the best available implementation at runtime.
48+
/// Dispatch to the best available implementation, selected once on first call.
4949
#[inline]
5050
pub fn transpose_bits(input: &[u8; 128], output: &mut [u8; 128]) {
5151
#[cfg(target_arch = "x86_64")]
52-
{
52+
return transpose_bits_x86(input, output);
53+
// SAFETY: NEON is architecturally guaranteed on aarch64.
54+
#[cfg(target_arch = "aarch64")]
55+
return unsafe { aarch64::transpose_bits_neon(input, output) };
56+
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
57+
scalar::transpose_bits_scalar(input, output);
58+
}
59+
60+
#[cfg(target_arch = "x86_64")]
61+
vortex_buffer::cpu_dispatch! {
62+
fn transpose_bits_x86(input: &[u8; 128], output: &mut [u8; 128]);
63+
select {
5364
// VBMI is fastest
5465
if x86::has_vbmi() {
55-
unsafe { x86::transpose_bits_vbmi(input, output) };
56-
return;
66+
// SAFETY: VBMI support was just detected.
67+
return |input, output| unsafe { x86::transpose_bits_vbmi(input, output) };
5768
}
5869
if x86::has_bmi2() {
59-
unsafe { x86::transpose_bits_bmi2(input, output) };
60-
return;
70+
// SAFETY: BMI2 support was just detected.
71+
return |input, output| unsafe { x86::transpose_bits_bmi2(input, output) };
6172
}
62-
// Fall back to scalar
63-
scalar::transpose_bits_scalar(input, output);
73+
scalar::transpose_bits_scalar
6474
}
65-
#[cfg(target_arch = "aarch64")]
66-
{
67-
unsafe { aarch64::transpose_bits_neon(input, output) };
68-
}
69-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
70-
scalar::transpose_bits_scalar(input, output);
7175
}
7276

7377
/// Untranspose 1024-bits from FastLanes layout.
7478
///
75-
/// Dispatch untranspose to the best available implementation at runtime.
79+
/// Dispatch untranspose to the best available implementation, selected once on first call.
7680
#[inline]
7781
pub fn untranspose_bits(input: &[u8; 128], output: &mut [u8; 128]) {
7882
#[cfg(target_arch = "x86_64")]
79-
{
83+
return untranspose_bits_x86(input, output);
84+
// SAFETY: NEON is architecturally guaranteed on aarch64.
85+
#[cfg(target_arch = "aarch64")]
86+
return unsafe { aarch64::untranspose_bits_neon(input, output) };
87+
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
88+
scalar::untranspose_bits_scalar(input, output);
89+
}
90+
91+
#[cfg(target_arch = "x86_64")]
92+
vortex_buffer::cpu_dispatch! {
93+
fn untranspose_bits_x86(input: &[u8; 128], output: &mut [u8; 128]);
94+
select {
8095
// VBMI is fastest
8196
if x86::has_vbmi() {
82-
unsafe { x86::untranspose_bits_vbmi(input, output) };
83-
return;
97+
// SAFETY: VBMI support was just detected.
98+
return |input, output| unsafe { x86::untranspose_bits_vbmi(input, output) };
8499
}
85100
if x86::has_bmi2() {
86-
unsafe { x86::untranspose_bits_bmi2(input, output) };
87-
return;
101+
// SAFETY: BMI2 support was just detected.
102+
return |input, output| unsafe { x86::untranspose_bits_bmi2(input, output) };
88103
}
89-
// Fall back to scalar
90-
scalar::untranspose_bits_scalar(input, output);
104+
scalar::untranspose_bits_scalar
91105
}
92-
#[cfg(target_arch = "aarch64")]
93-
{
94-
unsafe { aarch64::untranspose_bits_neon(input, output) };
95-
}
96-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
97-
scalar::untranspose_bits_scalar(input, output);
98106
}
99107

100108
#[cfg(test)]

vortex-buffer/src/bit/select.rs

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,18 @@ fn scan_chunks_impl(chunks: &[[u8; 64]], remaining: usize, pos: usize) -> (usize
9494
}
9595

9696
#[cfg(target_arch = "x86_64")]
97-
#[inline]
98-
fn scan_chunks_impl(chunks: &[[u8; 64]], remaining: usize, pos: usize) -> (usize, usize, usize) {
99-
if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vpopcntdq") {
100-
// SAFETY: runtime detection guarantees the required target features.
101-
return unsafe { scan_chunks_avx512_vpopcnt(chunks, remaining, pos) };
102-
}
97+
crate::cpu_dispatch! {
98+
fn scan_chunks_impl(chunks: &[[u8; 64]], remaining: usize, pos: usize) -> (usize, usize, usize);
99+
select {
100+
if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vpopcntdq") {
101+
// SAFETY: runtime detection guarantees the required target features.
102+
return |chunks, remaining, pos| unsafe {
103+
scan_chunks_avx512_vpopcnt(chunks, remaining, pos)
104+
};
105+
}
103106

104-
scan_chunks_scalar(chunks, remaining, pos)
107+
scan_chunks_scalar
108+
}
105109
}
106110

107111
#[cfg(target_arch = "aarch64")]
@@ -281,20 +285,27 @@ fn scan_words_scalar(
281285
// ── In-chunk select ─────────────────────────────────────────────────────
282286

283287
/// Position of the `nth` set bit inside a 64-byte chunk (0-indexed).
288+
#[cfg(not(target_arch = "x86_64"))]
284289
#[inline]
285290
fn select_in_chunk(chunk: &[u8; 64], nth: usize) -> usize {
286-
#[cfg(target_arch = "x86_64")]
287-
{
291+
select_in_chunk_scalar(chunk, nth)
292+
}
293+
294+
#[cfg(target_arch = "x86_64")]
295+
crate::cpu_dispatch! {
296+
/// Position of the `nth` set bit inside a 64-byte chunk (0-indexed).
297+
fn select_in_chunk(chunk: &[u8; 64], nth: usize) -> usize;
298+
select {
288299
if is_x86_feature_detected!("avx512f")
289300
&& is_x86_feature_detected!("avx512vpopcntdq")
290301
&& is_x86_feature_detected!("avx512vbmi2")
291302
{
292303
// SAFETY: runtime detection guarantees the required target features.
293-
return unsafe { select_in_chunk_vbmi2(chunk, nth) };
304+
return |chunk, nth| unsafe { select_in_chunk_vbmi2(chunk, nth) };
294305
}
295-
}
296306

297-
select_in_chunk_scalar(chunk, nth)
307+
select_in_chunk_scalar
308+
}
298309
}
299310

300311
#[cfg(target_arch = "x86_64")]
@@ -360,16 +371,23 @@ fn count_ones_chunk(chunk: &[u8; 64]) -> usize {
360371
// ── In-word select ──────────────────────────────────────────────────────
361372

362373
/// Position of the `nth` set bit inside a u64 (0-indexed, little-endian bit order).
374+
#[cfg(not(target_arch = "x86_64"))]
363375
#[inline]
364376
fn select_in_word(word: u64, nth: usize) -> usize {
365-
#[cfg(target_arch = "x86_64")]
366-
{
377+
select_in_word_scalar(word, nth)
378+
}
379+
380+
#[cfg(target_arch = "x86_64")]
381+
crate::cpu_dispatch! {
382+
/// Position of the `nth` set bit inside a u64 (0-indexed, little-endian bit order).
383+
fn select_in_word(word: u64, nth: usize) -> usize;
384+
select {
367385
if is_x86_feature_detected!("bmi2") {
368386
// SAFETY: runtime detection guarantees the required target feature.
369-
return unsafe { select_in_word_bmi2(word, nth) };
387+
return |word, nth| unsafe { select_in_word_bmi2(word, nth) };
370388
}
389+
select_in_word_scalar
371390
}
372-
select_in_word_scalar(word, nth)
373391
}
374392

375393
/// BMI2: deposit a single bit at the nth set-bit position, then count trailing zeros.

vortex-buffer/src/dispatch.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! One-time CPU-feature-based function dispatch.
5+
//!
6+
//! [`cpu_dispatch!`](crate::cpu_dispatch) defines a function whose implementation is
7+
//! selected by runtime CPU-feature detection exactly once, on the first call. Every
8+
//! later call is a single relaxed atomic load plus an indirect call — no repeated
9+
//! feature checks, no branch chains, no `LazyLock` initialized-state branch.
10+
//!
11+
//! This is the "ifunc" self-patching pattern: the function-pointer slot initially
12+
//! points at a resolver; the resolver picks the best implementation, patches the slot,
13+
//! and forwards the first call. Races are benign — the slot only ever holds valid
14+
//! function pointers of the same type, and every candidate computes the same result.
15+
//!
16+
//! # When to use it
17+
//!
18+
//! Use this for *coarse-grained* kernels (a whole chunk or buffer per call) that have
19+
//! more than one runtime candidate on the target architecture, e.g. x86_64 kernels
20+
//! with AVX-512 / BMI2 / scalar variants. Today that means x86_64: on aarch64 NEON is
21+
//! architecturally guaranteed, so a plain `#[cfg(target_arch = "aarch64")]` direct
22+
//! call is already zero-cost and inlinable — do not funnel it through a function
23+
//! pointer. If SVE/SVE2 kernels are added later, aarch64 call sites get the same
24+
//! one-time selection by adding `is_aarch64_feature_detected!` arms to the selector.
25+
//!
26+
//! # When NOT to use it
27+
//!
28+
//! Do not put the dispatched call inside a per-element hot loop: the indirect call
29+
//! blocks inlining. Hoist the decision to the outermost per-buffer entry point and
30+
//! monomorphize the loop instead, like the `Bmi2`/`Portable` type-parameter pattern in
31+
//! `vortex-mask::intersect_by_rank`.
32+
33+
/// Define a function whose implementation is chosen by CPU-feature detection once, on
34+
/// first call, and cached in a self-patching function pointer thereafter.
35+
///
36+
/// The `select` block is the body of a resolver `fn() -> fn(args...) -> ret` that runs
37+
/// at most once per process (once per racing thread in the worst case). It must return
38+
/// the chosen implementation as a function pointer; non-capturing closures coerce, so
39+
/// `unsafe` `#[target_feature]` kernels can be wrapped inline after detection proves
40+
/// them sound.
41+
///
42+
/// # Example
43+
///
44+
/// ```
45+
/// vortex_buffer::cpu_dispatch! {
46+
/// /// Sums a slice, using the best kernel for the current CPU.
47+
/// pub fn sum(values: &[u64]) -> u64;
48+
/// select {
49+
/// #[cfg(target_arch = "x86_64")]
50+
/// if std::arch::is_x86_feature_detected!("avx2") {
51+
/// // return |values| unsafe { sum_avx2(values) };
52+
/// }
53+
/// |values: &[u64]| values.iter().sum()
54+
/// }
55+
/// }
56+
/// assert_eq!(sum(&[1, 2, 3]), 6);
57+
/// ```
58+
#[macro_export]
59+
macro_rules! cpu_dispatch {
60+
(
61+
$(#[$meta:meta])*
62+
$vis:vis fn $name:ident($($arg:ident : $ty:ty),* $(,)?) $(-> $ret:ty)?;
63+
select $select_body:block
64+
) => {
65+
$(#[$meta])*
66+
#[inline]
67+
$vis fn $name($($arg: $ty),*) $(-> $ret)? {
68+
type Impl = fn($($ty),*) $(-> $ret)?;
69+
70+
fn select() -> Impl $select_body
71+
72+
fn select_then_call($($arg: $ty),*) $(-> $ret)? {
73+
let selected = select();
74+
SELECTED.store(
75+
selected as *mut (),
76+
::core::sync::atomic::Ordering::Relaxed,
77+
);
78+
selected($($arg),*)
79+
}
80+
81+
static SELECTED: ::core::sync::atomic::AtomicPtr<()> =
82+
::core::sync::atomic::AtomicPtr::new(select_then_call as Impl as *mut ());
83+
84+
let fn_ptr = SELECTED.load(::core::sync::atomic::Ordering::Relaxed);
85+
// SAFETY: `SELECTED` only ever holds values created by casting an `Impl`
86+
// function pointer to `*mut ()`, so casting back is sound.
87+
let selected = unsafe { ::core::mem::transmute::<*mut (), Impl>(fn_ptr) };
88+
selected($($arg),*)
89+
}
90+
};
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use std::sync::atomic::AtomicUsize;
96+
use std::sync::atomic::Ordering;
97+
98+
static SELECT_CALLS: AtomicUsize = AtomicUsize::new(0);
99+
100+
crate::cpu_dispatch! {
101+
fn add_one(x: u64) -> u64;
102+
select {
103+
SELECT_CALLS.fetch_add(1, Ordering::Relaxed);
104+
|x| x + 1
105+
}
106+
}
107+
108+
crate::cpu_dispatch! {
109+
fn xor_bytes(input: &[u8; 4], output: &mut [u8; 4]);
110+
select {
111+
fn xor_impl(input: &[u8; 4], output: &mut [u8; 4]) {
112+
for (o, i) in output.iter_mut().zip(input) {
113+
*o ^= *i;
114+
}
115+
}
116+
xor_impl
117+
}
118+
}
119+
120+
#[test]
121+
fn selects_once_then_dispatches() {
122+
assert_eq!(add_one(41), 42);
123+
assert_eq!(add_one(1), 2);
124+
assert_eq!(add_one(2), 3);
125+
assert_eq!(SELECT_CALLS.load(Ordering::Relaxed), 1);
126+
}
127+
128+
#[test]
129+
fn supports_reference_arguments() {
130+
let input = [1, 2, 3, 4];
131+
let mut output = [0, 0, 0, 0];
132+
xor_bytes(&input, &mut output);
133+
assert_eq!(output, input);
134+
}
135+
}

vortex-buffer/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ mod buffer_mut;
6262
mod bytes;
6363
mod r#const;
6464
mod debug;
65+
mod dispatch;
6566
mod macros;
6667
#[cfg(feature = "memmap2")]
6768
mod memmap2;

0 commit comments

Comments
 (0)