Skip to content

Commit 9c69195

Browse files
feat: arch intrinsic feature discovery pattern (#8750)
Currently we have a few places where we have arch/feature specific kernels, they current use slower feature detection for each call. This PR add a cache for the kernel to use and uses this pattern in the codebase. I think we would later want to register different kernels in the vortex session instead of this, but that can wait. Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8f75dbe commit 9c69195

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
@@ -49,6 +49,10 @@ harness = false
4949
name = "vortex_bitbuffer"
5050
harness = false
5151

52+
[[bench]]
53+
name = "cpu_dispatch"
54+
harness = false
55+
5256
[[bench]]
5357
name = "collect_bool"
5458
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)