Skip to content

Commit e0859ef

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 scalar path: count_ones_aligned calls the scalar kernel directly below 32 bytes (where SIMD never engaged anyway), and scan_chunks returns immediately for an empty chunk slice. Without these gates the dispatch indirection dominates sub-SIMD-threshold workloads (CodSpeed flagged true_count[128] at about -30% instructions). 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. 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 e0859ef

9 files changed

Lines changed: 534 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/src/bit/count_ones.rs

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
#[cfg(target_arch = "x86_64")]
55
use vortex_error::VortexExpect;
66

7+
use crate::dispatch::CpuKernel;
8+
79
#[inline]
810
pub fn count_ones(bytes: &[u8], offset: usize, len: usize) -> usize {
911
if bytes.is_empty() {
@@ -70,24 +72,61 @@ fn mask_byte(byte: u8, bit_offset: usize, bit_len: usize) -> u8 {
7072
shifted & mask
7173
}
7274

75+
type CountOnes = unsafe fn(&[u8]) -> usize;
76+
7377
#[inline]
7478
fn count_ones_aligned(bytes: &[u8]) -> usize {
75-
#[cfg(target_arch = "x86_64")]
76-
{
77-
if bytes.len() >= 64
78-
&& is_x86_feature_detected!("avx512f")
79-
&& is_x86_feature_detected!("avx512vpopcntdq")
79+
// SIMD kernels only pay off from 32 bytes. Below that, call the scalar kernel
80+
// directly: it stays inlinable and skips the dispatch indirection, which would
81+
// otherwise dominate the couple of word popcounts.
82+
if bytes.len() < 32 {
83+
return count_ones_aligned_scalar(bytes);
84+
}
85+
86+
static KERNEL: CpuKernel<CountOnes> = CpuKernel::new(|| {
87+
#[cfg(target_arch = "x86_64")]
8088
{
81-
// SAFETY: Runtime detection guarantees the required target features.
82-
return unsafe { count_ones_aligned_avx512(bytes) };
89+
if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vpopcntdq") {
90+
return count_ones_aligned_avx512_any_len;
91+
}
92+
if is_x86_feature_detected!("avx2") {
93+
return count_ones_aligned_avx2_any_len;
94+
}
8395
}
96+
count_ones_aligned_scalar
97+
});
98+
// SAFETY: the selector only returns kernels that are safe or whose required CPU
99+
// features were probed before selection.
100+
unsafe { KERNEL.get()(bytes) }
101+
}
84102

85-
if bytes.len() >= 32 && is_x86_feature_detected!("avx2") {
86-
// SAFETY: Runtime detection guarantees the required target features.
87-
return unsafe { count_ones_aligned_avx2(bytes) };
88-
}
103+
/// Length-aware AVX-512 entry: SIMD only pays off from 64 (AVX-512) / 32 (AVX2) bytes.
104+
///
105+
/// # Safety
106+
/// Requires AVX-512F and AVX-512VPOPCNTDQ (which imply AVX2).
107+
#[cfg(target_arch = "x86_64")]
108+
unsafe fn count_ones_aligned_avx512_any_len(bytes: &[u8]) -> usize {
109+
if bytes.len() >= 64 {
110+
// SAFETY: the caller guarantees the required target features.
111+
return unsafe { count_ones_aligned_avx512(bytes) };
112+
}
113+
if bytes.len() >= 32 {
114+
// SAFETY: every AVX-512F CPU also supports AVX2.
115+
return unsafe { count_ones_aligned_avx2(bytes) };
89116
}
117+
count_ones_aligned_scalar(bytes)
118+
}
90119

120+
/// Length-aware AVX2 entry: SIMD only pays off from 32 bytes.
121+
///
122+
/// # Safety
123+
/// Requires AVX2.
124+
#[cfg(target_arch = "x86_64")]
125+
unsafe fn count_ones_aligned_avx2_any_len(bytes: &[u8]) -> usize {
126+
if bytes.len() >= 32 {
127+
// SAFETY: the caller guarantees AVX2.
128+
return unsafe { count_ones_aligned_avx2(bytes) };
129+
}
91130
count_ones_aligned_scalar(bytes)
92131
}
93132

0 commit comments

Comments
 (0)