Skip to content

Commit ee17659

Browse files
committed
feat: model arch and CPU-feature dispatch dimensions in CpuKernel
Replace LazyCpuDispatch with CpuKernel, which models the two dispatch dimensions separately: the compile-time dimension (x86_64 vs aarch64) as cfg-selected const candidate tables and fallback kernels, and the runtime dimension (AVX-512 vs BMI2 vs scalar) as an ordered list of Candidate { available, kernel } probes, tried once on first call. The candidate table and fallback are passed at get() rather than stored in the static: the atomic slot gives the struct interior mutability, which places the whole static in writable memory where LLVM cannot const-fold fields. Passed as rodata consts instead, an empty candidate table (the aarch64 shape today) folds get() into a direct call — verified by disassembly, where LLVM merges the empty-table wrapper with the direct-call wrapper into identical code. Add a divan benchmark (benches/cpu_dispatch.rs) comparing dispatch overhead per call against the same #[inline(never)] kernel. Fastest-of-3 on 1024 calls/iter: direct, CpuKernel (empty and populated), one is_x86_feature_detected! per call, and branchless CpuDispatch are all equal within noise (~2.74 us); LazyLock<fn> and the previous three-feature-check-per-call pattern are both ~33% slower (~3.64 us). Dispatch call sites in vortex-buffer bit/select.rs and fastlanes bit_transpose now contain no cfg blocks in the function body: per-arch kernels live in the candidate/fallback consts. 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 35bd207 commit ee17659

5 files changed

Lines changed: 482 additions & 198 deletions

File tree

encodings/fastlanes/src/bit_transpose/mod.rs

Lines changed: 65 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,71 @@ mod x86;
2828
mod validity;
2929

3030
pub use validity::*;
31-
#[cfg(target_arch = "x86_64")]
32-
use vortex_buffer::LazyCpuDispatch;
31+
use vortex_buffer::Candidate;
32+
use vortex_buffer::CpuKernel;
3333

3434
/// Signature shared by all 1024-bit transpose kernels.
35-
#[cfg(target_arch = "x86_64")]
3635
type TransposeKernel = fn(&[u8; 128], &mut [u8; 128]);
3736

37+
// Runtime dimension: kernels that need a CPU probe, best first.
38+
#[cfg(target_arch = "x86_64")]
39+
const TRANSPOSE_CANDIDATES: &[Candidate<TransposeKernel>] = &[
40+
Candidate {
41+
available: x86::has_vbmi,
42+
kernel: |input, output| {
43+
// SAFETY: `available` confirms VBMI.
44+
unsafe { x86::transpose_bits_vbmi(input, output) }
45+
},
46+
},
47+
Candidate {
48+
available: x86::has_bmi2,
49+
kernel: |input, output| {
50+
// SAFETY: `available` confirms BMI2.
51+
unsafe { x86::transpose_bits_bmi2(input, output) }
52+
},
53+
},
54+
];
55+
#[cfg(not(target_arch = "x86_64"))]
56+
const TRANSPOSE_CANDIDATES: &[Candidate<TransposeKernel>] = &[];
57+
58+
#[cfg(target_arch = "x86_64")]
59+
const UNTRANSPOSE_CANDIDATES: &[Candidate<TransposeKernel>] = &[
60+
Candidate {
61+
available: x86::has_vbmi,
62+
kernel: |input, output| {
63+
// SAFETY: `available` confirms VBMI.
64+
unsafe { x86::untranspose_bits_vbmi(input, output) }
65+
},
66+
},
67+
Candidate {
68+
available: x86::has_bmi2,
69+
kernel: |input, output| {
70+
// SAFETY: `available` confirms BMI2.
71+
unsafe { x86::untranspose_bits_bmi2(input, output) }
72+
},
73+
},
74+
];
75+
#[cfg(not(target_arch = "x86_64"))]
76+
const UNTRANSPOSE_CANDIDATES: &[Candidate<TransposeKernel>] = &[];
77+
78+
// Compile-time dimension: the per-architecture unconditional kernel. NEON is
79+
// architecturally guaranteed on aarch64, so it needs no probe.
80+
#[cfg(target_arch = "aarch64")]
81+
const TRANSPOSE_FALLBACK: TransposeKernel = |input, output| {
82+
// SAFETY: NEON is architecturally guaranteed on aarch64.
83+
unsafe { aarch64::transpose_bits_neon(input, output) }
84+
};
85+
#[cfg(not(target_arch = "aarch64"))]
86+
const TRANSPOSE_FALLBACK: TransposeKernel = scalar::transpose_bits_scalar;
87+
88+
#[cfg(target_arch = "aarch64")]
89+
const UNTRANSPOSE_FALLBACK: TransposeKernel = |input, output| {
90+
// SAFETY: NEON is architecturally guaranteed on aarch64.
91+
unsafe { aarch64::untranspose_bits_neon(input, output) }
92+
};
93+
#[cfg(not(target_arch = "aarch64"))]
94+
const UNTRANSPOSE_FALLBACK: TransposeKernel = scalar::untranspose_bits_scalar;
95+
3896
/// Base indices for the first 64 output bytes (lanes 0-7).
3997
/// Each entry indicates the starting input byte index for that output byte group.
4098
/// Pattern: [0*2, 4*2, 2*2, 6*2, 1*2, 5*2, 3*2, 7*2] = [0, 8, 4, 12, 2, 10, 6, 14]
@@ -54,63 +112,17 @@ const TRANSPOSE_8X8: u64 = 0x0000_0000_F0F0_F0F0;
54112
/// Dispatch to the best available implementation, selected once on first call.
55113
#[inline]
56114
pub fn transpose_bits(input: &[u8; 128], output: &mut [u8; 128]) {
57-
#[cfg(target_arch = "x86_64")]
58-
{
59-
static SELECTED: LazyCpuDispatch<TransposeKernel> = LazyCpuDispatch::new(|| {
60-
// VBMI is fastest
61-
if x86::has_vbmi() {
62-
// SAFETY: VBMI support was just detected.
63-
|input, output| unsafe { x86::transpose_bits_vbmi(input, output) }
64-
} else if x86::has_bmi2() {
65-
// SAFETY: BMI2 support was just detected.
66-
|input, output| unsafe { x86::transpose_bits_bmi2(input, output) }
67-
} else {
68-
scalar::transpose_bits_scalar
69-
}
70-
});
71-
SELECTED.get()(input, output)
72-
}
73-
#[cfg(target_arch = "aarch64")]
74-
{
75-
// SAFETY: NEON is architecturally guaranteed on aarch64.
76-
unsafe { aarch64::transpose_bits_neon(input, output) }
77-
}
78-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
79-
{
80-
scalar::transpose_bits_scalar(input, output)
81-
}
115+
static KERNEL: CpuKernel<TransposeKernel> = CpuKernel::new();
116+
KERNEL.get(TRANSPOSE_CANDIDATES, TRANSPOSE_FALLBACK)(input, output)
82117
}
83118

84119
/// Untranspose 1024-bits from FastLanes layout.
85120
///
86121
/// Dispatch untranspose to the best available implementation, selected once on first call.
87122
#[inline]
88123
pub fn untranspose_bits(input: &[u8; 128], output: &mut [u8; 128]) {
89-
#[cfg(target_arch = "x86_64")]
90-
{
91-
static SELECTED: LazyCpuDispatch<TransposeKernel> = LazyCpuDispatch::new(|| {
92-
// VBMI is fastest
93-
if x86::has_vbmi() {
94-
// SAFETY: VBMI support was just detected.
95-
|input, output| unsafe { x86::untranspose_bits_vbmi(input, output) }
96-
} else if x86::has_bmi2() {
97-
// SAFETY: BMI2 support was just detected.
98-
|input, output| unsafe { x86::untranspose_bits_bmi2(input, output) }
99-
} else {
100-
scalar::untranspose_bits_scalar
101-
}
102-
});
103-
SELECTED.get()(input, output)
104-
}
105-
#[cfg(target_arch = "aarch64")]
106-
{
107-
// SAFETY: NEON is architecturally guaranteed on aarch64.
108-
unsafe { aarch64::untranspose_bits_neon(input, output) }
109-
}
110-
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
111-
{
112-
scalar::untranspose_bits_scalar(input, output)
113-
}
124+
static KERNEL: CpuKernel<TransposeKernel> = CpuKernel::new();
125+
KERNEL.get(UNTRANSPOSE_CANDIDATES, UNTRANSPOSE_FALLBACK)(input, output)
114126
}
115127

116128
#[cfg(test)]

vortex-buffer/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,7 @@ harness = false
4848
[[bench]]
4949
name = "vortex_bitbuffer"
5050
harness = false
51+
52+
[[bench]]
53+
name = "cpu_dispatch"
54+
harness = false
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Overhead comparison of one-time CPU-feature dispatch mechanisms.
5+
//!
6+
//! Every variant ends in a call to the same `#[inline(never)]` kernel, so the
7+
//! difference between rows is pure dispatch overhead:
8+
//!
9+
//! * `direct`: plain call — the floor.
10+
//! * `cpu_kernel_empty_candidates`: [`CpuKernel`] with an empty candidate table
11+
//! (the aarch64 shape) — should const-fold to `direct`.
12+
//! * `cpu_dispatch_branchless`: [`CpuDispatch`] — relaxed load + indirect call.
13+
//! * `cpu_kernel`: [`CpuKernel`] — relaxed load + null check + indirect call.
14+
//! * `lazy_lock`: `std::sync::LazyLock<fn>` — Once-state check + value load +
15+
//! indirect call.
16+
//! * `feature_detect_each_call_1/_3`: the pre-`CpuKernel` pattern — 1 or 3
17+
//! `is_x86_feature_detected!` cached lookups per call, then a direct call.
18+
19+
use std::sync::LazyLock;
20+
21+
use divan::Bencher;
22+
use divan::black_box;
23+
use divan::counter::ItemsCount;
24+
use vortex_buffer::Candidate;
25+
use vortex_buffer::CpuDispatch;
26+
use vortex_buffer::CpuKernel;
27+
28+
fn main() {
29+
// Resolve every dispatcher and warm the std feature-detection cache so no
30+
// benchmark iteration pays a one-time probe.
31+
let seed = black_box(7);
32+
let _ = via_direct(1, seed)
33+
+ via_cpu_kernel(1, seed)
34+
+ via_cpu_kernel_empty_candidates(1, seed)
35+
+ via_cpu_dispatch(1, seed)
36+
+ via_lazy_lock(1, seed);
37+
#[cfg(target_arch = "x86_64")]
38+
let _ = via_feature_detect_1(1, seed) + via_feature_detect_3(1, seed);
39+
40+
divan::main();
41+
}
42+
43+
type Kernel = fn(u64, u64) -> u64;
44+
45+
#[inline(never)]
46+
fn kernel_a(x: u64, seed: u64) -> u64 {
47+
x.wrapping_mul(31).wrapping_add(seed)
48+
}
49+
50+
#[inline(never)]
51+
fn kernel_b(x: u64, seed: u64) -> u64 {
52+
x.wrapping_mul(33).wrapping_add(seed)
53+
}
54+
55+
fn has_bmi2() -> bool {
56+
#[cfg(target_arch = "x86_64")]
57+
{
58+
std::arch::is_x86_feature_detected!("bmi2")
59+
}
60+
#[cfg(not(target_arch = "x86_64"))]
61+
{
62+
false
63+
}
64+
}
65+
66+
// ── dispatch variants ───────────────────────────────────────────────────
67+
68+
#[inline(never)]
69+
fn via_direct(x: u64, seed: u64) -> u64 {
70+
kernel_a(x, seed)
71+
}
72+
73+
#[cfg(target_arch = "x86_64")]
74+
#[inline(never)]
75+
fn via_feature_detect_1(x: u64, seed: u64) -> u64 {
76+
if std::arch::is_x86_feature_detected!("bmi2") {
77+
kernel_a(x, seed)
78+
} else {
79+
kernel_b(x, seed)
80+
}
81+
}
82+
83+
#[cfg(target_arch = "x86_64")]
84+
#[inline(never)]
85+
fn via_feature_detect_3(x: u64, seed: u64) -> u64 {
86+
// Non-baseline features that are present on any recent x86_64 part, so all
87+
// three cached lookups actually execute (mirrors the old select_in_chunk).
88+
if std::arch::is_x86_feature_detected!("avx")
89+
&& std::arch::is_x86_feature_detected!("avx2")
90+
&& std::arch::is_x86_feature_detected!("bmi2")
91+
{
92+
kernel_a(x, seed)
93+
} else {
94+
kernel_b(x, seed)
95+
}
96+
}
97+
98+
static LAZY: LazyLock<Kernel> = LazyLock::new(|| if has_bmi2() { kernel_a } else { kernel_b });
99+
100+
#[inline(never)]
101+
fn via_lazy_lock(x: u64, seed: u64) -> u64 {
102+
(*LAZY)(x, seed)
103+
}
104+
105+
const CPU_KERNEL_CANDIDATES: &[Candidate<Kernel>] = &[Candidate {
106+
available: has_bmi2,
107+
kernel: kernel_a,
108+
}];
109+
110+
static CPU_KERNEL: CpuKernel<Kernel> = CpuKernel::new();
111+
112+
#[inline(never)]
113+
fn via_cpu_kernel(x: u64, seed: u64) -> u64 {
114+
CPU_KERNEL.get(CPU_KERNEL_CANDIDATES, kernel_b)(x, seed)
115+
}
116+
117+
static CPU_KERNEL_EMPTY: CpuKernel<Kernel> = CpuKernel::new();
118+
119+
#[inline(never)]
120+
fn via_cpu_kernel_empty_candidates(x: u64, seed: u64) -> u64 {
121+
CPU_KERNEL_EMPTY.get(&[], kernel_a)(x, seed)
122+
}
123+
124+
static CPU_DISPATCH: CpuDispatch<Kernel> = CpuDispatch::new(resolve);
125+
126+
#[cold]
127+
fn resolve(x: u64, seed: u64) -> u64 {
128+
let kernel: Kernel = if has_bmi2() { kernel_a } else { kernel_b };
129+
CPU_DISPATCH.set(kernel);
130+
kernel(x, seed)
131+
}
132+
133+
#[inline(never)]
134+
fn via_cpu_dispatch(x: u64, seed: u64) -> u64 {
135+
CPU_DISPATCH.get()(x, seed)
136+
}
137+
138+
// ── benches ─────────────────────────────────────────────────────────────
139+
140+
const CALLS: u64 = 1024;
141+
142+
fn bench_via(bencher: Bencher, via: fn(u64, u64) -> u64) {
143+
bencher.counter(ItemsCount::new(CALLS)).bench(|| {
144+
let mut acc = 0u64;
145+
for i in 0..CALLS {
146+
acc = acc.wrapping_add(via(black_box(i), black_box(7)));
147+
}
148+
acc
149+
})
150+
}
151+
152+
#[divan::bench]
153+
fn direct(bencher: Bencher) {
154+
bench_via(bencher, via_direct);
155+
}
156+
157+
#[divan::bench]
158+
fn cpu_kernel_empty_candidates(bencher: Bencher) {
159+
bench_via(bencher, via_cpu_kernel_empty_candidates);
160+
}
161+
162+
#[divan::bench]
163+
fn cpu_dispatch_branchless(bencher: Bencher) {
164+
bench_via(bencher, via_cpu_dispatch);
165+
}
166+
167+
#[divan::bench]
168+
fn cpu_kernel(bencher: Bencher) {
169+
bench_via(bencher, via_cpu_kernel);
170+
}
171+
172+
#[divan::bench]
173+
fn lazy_lock(bencher: Bencher) {
174+
bench_via(bencher, via_lazy_lock);
175+
}
176+
177+
#[cfg(target_arch = "x86_64")]
178+
#[divan::bench]
179+
fn feature_detect_each_call_1(bencher: Bencher) {
180+
bench_via(bencher, via_feature_detect_1);
181+
}
182+
183+
#[cfg(target_arch = "x86_64")]
184+
#[divan::bench]
185+
fn feature_detect_each_call_3(bencher: Bencher) {
186+
bench_via(bencher, via_feature_detect_3);
187+
}

0 commit comments

Comments
 (0)