Skip to content

Commit d67d634

Browse files
joseph-isaacsclaude
andcommitted
feat: add CpuKernel for one-time CPU-feature kernel dispatch
Hot kernels re-ran cached CPU-feature checks on every call: each is_x86_feature_detected! is an atomic load + bit test per call, and sites like select_in_chunk chained three of them per 64-byte chunk. aarch64 paths were separate cfg blocks at every call site. Add vortex_buffer::CpuKernel<F>, a LazyLock-like slot for function pointers: the selector passed to CpuKernel::new runs once on the first get(), and every later call is a relaxed atomic load, a never-taken predicted branch, and an indirect call. No macros; the two transmute_copy calls between F and the atomic pointer are contained in the type, whose constructor statically asserts F is pointer-sized. Selectors model both dispatch dimensions in plain code: one cfg(target_arch) block per architecture that returns early (runtime feature probes as an if-chain inside), then the portable default as the plain tail - no cfg(not(any(...))) negation blocks anywhere. An arm that returns unconditionally (aarch64 NEON needs no probe) makes the tail unreachable there, silenced by an allow(unreachable_code) scoped to just the tail block. Kernel types are unsafe fn pointers so #[target_feature] kernels coerce directly by name with a single narrow unsafe call per site. Tiny inputs are gated before the dispatch so they keep the old fully inlined code path: count_ones_aligned calls the scalar kernel directly below 32 bytes (where SIMD never engaged anyway), and scan_chunks calls the per-architecture unconditional kernel directly for two chunks or fewer. Without these gates the dispatch indirection dominates sub-SIMD-threshold workloads: CodSpeed flagged true_count[128] (~-30%) and rank_single[(1024, 0.1)]; cachegrind measured the rank regression at +27 instructions per call and parity after the gates (37.70M vs 37.71M baseline instructions for 100k rank calls). Converted sites: fastlanes transpose_bits/untranspose_bits, vortex-buffer scan_chunks/select_in_chunk/select_in_word/count_ones_aligned, vortex-array filter_bitbuffer_by_mask, and vortex-mask intersect_bit_buffers_dispatch/intersect_rank_indices_dispatch. Intentionally left: primitive take (already one-time via LazyLock dyn kernel), intersect_mask_driven_dispatch (generic over an iterator type, so its instantiations cannot share one fn-pointer slot), and the compile-time IS_CONST_LANE_WIDTH constant. The count_ones any-length wrappers carry #[target_feature] so the SIMD kernels inline into them, avoiding an extra call level, and the vortex_bitbuffer and rank bench mains pre-resolve the kernel slots (matching the existing CPUID pre-warm precedent) so no single-shot benchmark measurement includes first-call selection. benches/cpu_dispatch.rs compares dispatch overhead per call against the same #[inline(never)] kernel. Fastest-of-3 per 1024 calls: direct 2.44us, CpuKernel 2.44us (indistinguishable, confirmed by disassembly: load + fused null test + indirect jmp), LazyLock<fn> 3.04us, and three cached feature checks per call 4.54us. All touched crates also cargo check clean on aarch64-unknown-linux-gnu. 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 d67d634

11 files changed

Lines changed: 556 additions & 104 deletions

File tree

encodings/fastlanes/src/bit_transpose/mod.rs

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ mod x86;
2828
mod validity;
2929

3030
pub use validity::*;
31+
use vortex_buffer::CpuKernel;
32+
33+
/// Signature shared by all 1024-bit transpose kernels.
34+
type TransposeKernel = unsafe fn(&[u8; 128], &mut [u8; 128]);
3135

3236
/// Base indices for the first 64 output bytes (lanes 0-7).
3337
/// Each entry indicates the starting input byte index for that output byte group.
@@ -45,56 +49,64 @@ const TRANSPOSE_8X8: u64 = 0x0000_0000_F0F0_F0F0;
4549

4650
/// Transpose 1024-bits into FastLanes layout.
4751
///
48-
/// Dispatch to the best available implementation at runtime.
52+
/// Dispatch to the best available implementation, selected once on first call.
4953
#[inline]
5054
pub fn transpose_bits(input: &[u8; 128], output: &mut [u8; 128]) {
51-
#[cfg(target_arch = "x86_64")]
52-
{
53-
// VBMI is fastest
54-
if x86::has_vbmi() {
55-
unsafe { x86::transpose_bits_vbmi(input, output) };
56-
return;
55+
static KERNEL: CpuKernel<TransposeKernel> = CpuKernel::new(|| {
56+
#[cfg(target_arch = "x86_64")]
57+
{
58+
// VBMI is fastest
59+
if x86::has_vbmi() {
60+
return x86::transpose_bits_vbmi;
61+
}
62+
if x86::has_bmi2() {
63+
return x86::transpose_bits_bmi2;
64+
}
5765
}
58-
if x86::has_bmi2() {
59-
unsafe { x86::transpose_bits_bmi2(input, output) };
60-
return;
66+
// NEON is architecturally guaranteed on aarch64, so it needs no probe.
67+
#[cfg(target_arch = "aarch64")]
68+
return aarch64::transpose_bits_neon;
69+
// The aarch64 arm above returns unconditionally, making this portable default
70+
// unreachable there.
71+
#[allow(unreachable_code)]
72+
{
73+
scalar::transpose_bits_scalar
6174
}
62-
// Fall back to scalar
63-
scalar::transpose_bits_scalar(input, output);
64-
}
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);
75+
});
76+
// SAFETY: the selector only returns kernels that are safe or whose required CPU
77+
// features were probed before selection.
78+
unsafe { KERNEL.get()(input, output) }
7179
}
7280

7381
/// Untranspose 1024-bits from FastLanes layout.
7482
///
75-
/// Dispatch untranspose to the best available implementation at runtime.
83+
/// Dispatch untranspose to the best available implementation, selected once on first call.
7684
#[inline]
7785
pub fn untranspose_bits(input: &[u8; 128], output: &mut [u8; 128]) {
78-
#[cfg(target_arch = "x86_64")]
79-
{
80-
// VBMI is fastest
81-
if x86::has_vbmi() {
82-
unsafe { x86::untranspose_bits_vbmi(input, output) };
83-
return;
86+
static KERNEL: CpuKernel<TransposeKernel> = CpuKernel::new(|| {
87+
#[cfg(target_arch = "x86_64")]
88+
{
89+
// VBMI is fastest
90+
if x86::has_vbmi() {
91+
return x86::untranspose_bits_vbmi;
92+
}
93+
if x86::has_bmi2() {
94+
return x86::untranspose_bits_bmi2;
95+
}
8496
}
85-
if x86::has_bmi2() {
86-
unsafe { x86::untranspose_bits_bmi2(input, output) };
87-
return;
97+
// NEON is architecturally guaranteed on aarch64, so it needs no probe.
98+
#[cfg(target_arch = "aarch64")]
99+
return aarch64::untranspose_bits_neon;
100+
// The aarch64 arm above returns unconditionally, making this portable default
101+
// unreachable there.
102+
#[allow(unreachable_code)]
103+
{
104+
scalar::untranspose_bits_scalar
88105
}
89-
// Fall back to scalar
90-
scalar::untranspose_bits_scalar(input, output);
91-
}
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);
106+
});
107+
// SAFETY: the selector only returns kernels that are safe or whose required CPU
108+
// features were probed before selection.
109+
unsafe { KERNEL.get()(input, output) }
98110
}
99111

100112
#[cfg(test)]

vortex-array/src/arrays/bool/compute/filter.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
use vortex_buffer::BitBuffer;
55
use vortex_buffer::BitBufferMut;
6+
use vortex_buffer::CpuKernel;
67
use vortex_buffer::get_bit;
78
use vortex_error::VortexExpect;
89
use vortex_error::VortexResult;
@@ -90,14 +91,19 @@ pub fn filter_bitbuffer_by_mask(
9091
mask_buf: &BitBuffer,
9192
true_count: usize,
9293
) -> BitBuffer {
93-
#[cfg(target_arch = "x86_64")]
94-
{
95-
if std::arch::is_x86_feature_detected!("bmi2") {
96-
// SAFETY: BMI2 confirmed available; the inner function is compiled with BMI2.
97-
return unsafe { filter_pext_bmi2(src, mask_buf, true_count) };
94+
type FilterKernel = unsafe fn(&BitBuffer, &BitBuffer, usize) -> BitBuffer;
95+
static KERNEL: CpuKernel<FilterKernel> = CpuKernel::new(|| {
96+
#[cfg(target_arch = "x86_64")]
97+
{
98+
if std::arch::is_x86_feature_detected!("bmi2") {
99+
return filter_pext_bmi2;
100+
}
98101
}
99-
}
100-
filter_pext_fallback(src, mask_buf, true_count)
102+
filter_pext_fallback
103+
});
104+
// SAFETY: the selector only returns kernels that are safe or whose required CPU
105+
// features were probed before selection.
106+
unsafe { KERNEL.get()(src, mask_buf, true_count) }
101107
}
102108

103109
/// BMI2-native filter: entire function compiled with BMI2+POPCNT enabled.

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: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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`: [`CpuKernel`] — relaxed load + null check + indirect call.
11+
//! * `cpu_kernel_unconditional`: a selector with no probes (the aarch64 shape) —
12+
//! same steady state as `cpu_kernel`.
13+
//! * `lazy_lock`: `std::sync::LazyLock<fn>` — Once-state check + value load +
14+
//! indirect call.
15+
//! * `feature_detect_each_call_1/_3`: the pre-`CpuKernel` pattern — 1 or 3
16+
//! `is_x86_feature_detected!` cached lookups per call, then a direct call.
17+
18+
use std::sync::LazyLock;
19+
20+
use divan::Bencher;
21+
use divan::black_box;
22+
use divan::counter::ItemsCount;
23+
use vortex_buffer::CpuKernel;
24+
25+
fn main() {
26+
// Resolve every dispatcher and warm the std feature-detection cache so no
27+
// benchmark iteration pays a one-time probe.
28+
let seed = black_box(7);
29+
let _ = via_direct(1, seed)
30+
+ via_cpu_kernel(1, seed)
31+
+ via_cpu_kernel_unconditional(1, seed)
32+
+ via_lazy_lock(1, seed);
33+
#[cfg(target_arch = "x86_64")]
34+
let _ = via_feature_detect_1(1, seed) + via_feature_detect_3(1, seed);
35+
36+
divan::main();
37+
}
38+
39+
type Kernel = fn(u64, u64) -> u64;
40+
41+
#[inline(never)]
42+
fn kernel_a(x: u64, seed: u64) -> u64 {
43+
x.wrapping_mul(31).wrapping_add(seed)
44+
}
45+
46+
#[inline(never)]
47+
fn kernel_b(x: u64, seed: u64) -> u64 {
48+
x.wrapping_mul(33).wrapping_add(seed)
49+
}
50+
51+
fn has_bmi2() -> bool {
52+
#[cfg(target_arch = "x86_64")]
53+
{
54+
std::arch::is_x86_feature_detected!("bmi2")
55+
}
56+
#[cfg(not(target_arch = "x86_64"))]
57+
{
58+
false
59+
}
60+
}
61+
62+
// ── dispatch variants ───────────────────────────────────────────────────
63+
64+
#[inline(never)]
65+
fn via_direct(x: u64, seed: u64) -> u64 {
66+
kernel_a(x, seed)
67+
}
68+
69+
#[cfg(target_arch = "x86_64")]
70+
#[inline(never)]
71+
fn via_feature_detect_1(x: u64, seed: u64) -> u64 {
72+
if std::arch::is_x86_feature_detected!("bmi2") {
73+
kernel_a(x, seed)
74+
} else {
75+
kernel_b(x, seed)
76+
}
77+
}
78+
79+
#[cfg(target_arch = "x86_64")]
80+
#[inline(never)]
81+
fn via_feature_detect_3(x: u64, seed: u64) -> u64 {
82+
// Non-baseline features that are present on any recent x86_64 part, so all
83+
// three cached lookups actually execute (mirrors the old select_in_chunk).
84+
if std::arch::is_x86_feature_detected!("avx")
85+
&& std::arch::is_x86_feature_detected!("avx2")
86+
&& std::arch::is_x86_feature_detected!("bmi2")
87+
{
88+
kernel_a(x, seed)
89+
} else {
90+
kernel_b(x, seed)
91+
}
92+
}
93+
94+
static LAZY: LazyLock<Kernel> = LazyLock::new(|| if has_bmi2() { kernel_a } else { kernel_b });
95+
96+
#[inline(never)]
97+
fn via_lazy_lock(x: u64, seed: u64) -> u64 {
98+
(*LAZY)(x, seed)
99+
}
100+
101+
static CPU_KERNEL: CpuKernel<Kernel> =
102+
CpuKernel::new(|| if has_bmi2() { kernel_a } else { kernel_b });
103+
104+
#[inline(never)]
105+
fn via_cpu_kernel(x: u64, seed: u64) -> u64 {
106+
CPU_KERNEL.get()(x, seed)
107+
}
108+
109+
static CPU_KERNEL_UNCONDITIONAL: CpuKernel<Kernel> = CpuKernel::new(|| kernel_a);
110+
111+
#[inline(never)]
112+
fn via_cpu_kernel_unconditional(x: u64, seed: u64) -> u64 {
113+
CPU_KERNEL_UNCONDITIONAL.get()(x, seed)
114+
}
115+
116+
// ── benches ─────────────────────────────────────────────────────────────
117+
118+
const CALLS: u64 = 1024;
119+
120+
fn bench_via(bencher: Bencher, via: fn(u64, u64) -> u64) {
121+
bencher.counter(ItemsCount::new(CALLS)).bench(|| {
122+
let mut acc = 0u64;
123+
for i in 0..CALLS {
124+
acc = acc.wrapping_add(via(black_box(i), black_box(7)));
125+
}
126+
acc
127+
})
128+
}
129+
130+
#[divan::bench]
131+
fn direct(bencher: Bencher) {
132+
bench_via(bencher, via_direct);
133+
}
134+
135+
#[divan::bench]
136+
fn cpu_kernel_unconditional(bencher: Bencher) {
137+
bench_via(bencher, via_cpu_kernel_unconditional);
138+
}
139+
140+
#[divan::bench]
141+
fn cpu_kernel(bencher: Bencher) {
142+
bench_via(bencher, via_cpu_kernel);
143+
}
144+
145+
#[divan::bench]
146+
fn lazy_lock(bencher: Bencher) {
147+
bench_via(bencher, via_lazy_lock);
148+
}
149+
150+
#[cfg(target_arch = "x86_64")]
151+
#[divan::bench]
152+
fn feature_detect_each_call_1(bencher: Bencher) {
153+
bench_via(bencher, via_feature_detect_1);
154+
}
155+
156+
#[cfg(target_arch = "x86_64")]
157+
#[divan::bench]
158+
fn feature_detect_each_call_3(bencher: Bencher) {
159+
bench_via(bencher, via_feature_detect_3);
160+
}

vortex-buffer/benches/vortex_bitbuffer.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ fn main() {
1919
let _ = is_x86_feature_detected!("avx512vpopcntdq");
2020
}
2121

22+
// Pre-resolve the one-time CpuKernel selections for the count/select paths so
23+
// no benchmark iteration pays the first-call cost.
24+
let warm = BitBuffer::from_iter((0..512).map(|i| i % 3 == 0));
25+
let _ = warm.true_count();
26+
let _ = warm.select(1);
27+
2228
divan::main();
2329
}
2430

0 commit comments

Comments
 (0)