Skip to content

Commit e4bea83

Browse files
authored
Merge pull request #636 from AdaWorldAPI/claude/v3-substrate-migration-review-o0yoxv
fix(onebrc lane B): dispatch SIMD width (U8x64 zmm / U8x32 ymm) instead of hardcoding 32
2 parents e1279cf + 45623c2 commit e4bea83

2 files changed

Lines changed: 84 additions & 47 deletions

File tree

crates/onebrc-probe/src/lane_b.rs

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,23 @@
55
//! parse + accumulate logic. The workspace's SIMD iron rule is "all SIMD from
66
//! `ndarray::simd`" (`simd-savant` agent,
77
//! `.claude/knowledge/ndarray-vertical-simd-alien-magic.md`) — this module
8-
//! uses `ndarray::simd::U8x32::cmpeq_mask` exclusively, never a raw
8+
//! uses `ndarray::simd::{U8x32, U8x64}::cmpeq_mask` exclusively, never a raw
99
//! `core::arch` intrinsic, `pulp`, `wide`, or `memchr`.
1010
//!
11-
//! `U8x32` is the AVX2-native byte width (one `__m256i` = 32 bytes; see
12-
//! `ndarray/src/simd_avx2.rs` module doc). The corpus is scanned in 32-byte
13-
//! strides: for each block, `cmpeq_mask(U8x32::splat(b'\n'))` and
14-
//! `cmpeq_mask(U8x32::splat(b';'))` produce 32-bit masks with bit `i` set
15-
//! iff `block[i]` matches. The set bits of the combined mask (newline and
16-
//! semicolon bytes never coincide, so `nl_mask | semi_mask` has no lost
17-
//! information) are walked in ascending order via the classic
18-
//! `mask & (mask - 1)` "clear lowest set bit" trick, recovering the ordered
19-
//! sequence of delimiter events for the block.
11+
//! **Width is compile-time-dispatched** (`SimdByte`): under AVX-512
12+
//! (`target-cpu=x86-64-v4` / `native`) the scan strides 64-byte `zmm`
13+
//! blocks via `U8x64` (`cmpeq_mask -> u64`); under the AVX2 default
14+
//! (`x86-64-v3`, the CI baseline) it strides 32-byte `ymm` blocks via
15+
//! `U8x32` (`cmpeq_mask -> u32`; one `__m256i`, see
16+
//! `ndarray/src/simd_avx2.rs`). For each block,
17+
//! `cmpeq_mask(SimdByte::splat(b'\n'))` and `cmpeq_mask(SimdByte::splat(b';'))`
18+
//! produce a `SimdByte::LANES`-bit mask with bit `i` set iff `block[i]`
19+
//! matches. The set bits of the combined mask (newline and semicolon bytes
20+
//! never coincide, so `nl_mask | semi_mask` has no lost information) are
21+
//! walked in ascending order via the classic `mask & (mask - 1)` "clear
22+
//! lowest set bit" trick, recovering the ordered sequence of delimiter
23+
//! events for the block. The walk is generic over the `u32`/`u64` mask
24+
//! width, so the same body serves both dispatched widths.
2025
//!
2126
//! **Parse remains scalar** (SWAR/branchless parse deliberately deferred —
2227
//! see `README.md` §1 "NOT reimplemented here"): `parse_temp_tenths` is the
@@ -27,27 +32,49 @@
2732
//! ## Cross-block record state
2833
//!
2934
//! A record's `;` and its `\n` are not guaranteed to land in the same
30-
//! 32-byte block (short station names put the `;` near a block's end and
31-
//! the `\n` in the next block, or vice versa). Two scalars carry state
32-
//! across block boundaries:
35+
//! block (short station names put the `;` near a block's end and the `\n`
36+
//! in the next block, or vice versa). Two scalars carry state across block
37+
//! boundaries:
3338
//!
3439
//! - `line_start: usize` — the byte offset where the current (in-progress)
3540
//! station name begins.
3641
//! - `pending_semi: Option<usize>` — `Some(offset)` once this record's `;`
3742
//! has been seen but its `\n` has not yet arrived; `None` while still
3843
//! scanning for the `;`.
3944
//!
40-
//! The tail (fewer than 32 bytes remaining after the last full block) is
41-
//! finished with a plain byte-wise scalar loop — the same station-name /
42-
//! temp-field extraction shape as `lane_a_scalar`, continuing from whatever
43-
//! `line_start` / `pending_semi` state the SIMD pass left behind.
45+
//! The tail (fewer than one full `SimdByte`-wide block remaining after the
46+
//! last full block) is finished with a plain byte-wise scalar loop — the
47+
//! same station-name / temp-field extraction shape as `lane_a_scalar`,
48+
//! continuing from whatever `line_start` / `pending_semi` state the SIMD
49+
//! pass left behind.
4450
4551
use crate::{parse_temp_tenths, Stats};
46-
use ndarray::simd::{array_chunks, U8x32};
52+
use ndarray::simd::array_chunks;
4753
use std::collections::BTreeMap;
4854

49-
/// Lane B — SIMD delimiter scan. One pass over `data` in 32-byte strides
50-
/// using `ndarray::simd::U8x32::cmpeq_mask` to locate `;` and `\n` bytes;
55+
// Compile-time SIMD byte-width dispatch — both widths are `ndarray::simd`
56+
// types (the iron rule; never a raw `core::arch` intrinsic). AVX-512
57+
// targets (`target-cpu=x86-64-v4` / `native`) scan in 64-byte `zmm`
58+
// strides via `U8x64`; the AVX2 default (`x86-64-v3`, the CI baseline)
59+
// scans in 32-byte `ymm` strides via `U8x32`. The stride, the needle
60+
// width, and the `array_chunks` const-generic all key off
61+
// `SimdByte::LANES`, so the same body strides the widest lane the target
62+
// actually provides. `cmpeq_mask` returns a `u64` (avx512) / `u32`
63+
// (avx2) mask; the ascending set-bit walk below is generic over both.
64+
#[cfg(not(target_feature = "avx512f"))]
65+
use ndarray::simd::U8x32 as SimdByte;
66+
#[cfg(target_feature = "avx512f")]
67+
use ndarray::simd::U8x64 as SimdByte;
68+
69+
/// The SIMD block width lane B actually strides, in bytes — the dispatched
70+
/// `SimdByte::LANES` (64 under avx512, 32 under avx2). Exposed so tests can
71+
/// assert against the real block boundary instead of a hardcoded width.
72+
#[cfg(test)]
73+
pub(crate) const SIMD_LANES: usize = SimdByte::LANES;
74+
75+
/// Lane B — SIMD delimiter scan. One pass over `data` in `SimdByte::LANES`-wide
76+
/// strides (64-byte `zmm` under avx512, 32-byte `ymm` under avx2) using
77+
/// `ndarray::simd::{U8x64, U8x32}::cmpeq_mask` to locate `;` and `\n` bytes;
5178
/// scalar integer temp parse (see module doc); `BTreeMap<String, Stats>`
5279
/// accumulation identical in shape to `lane_a_scalar`.
5380
pub fn lane_b_simd(data: &[u8]) -> BTreeMap<String, Stats> {
@@ -60,18 +87,22 @@ pub fn lane_b_simd(data: &[u8]) -> BTreeMap<String, Stats> {
6087
let mut line_start = 0usize;
6188
let mut pending_semi: Option<usize> = None;
6289

63-
let nl_needle = U8x32::splat(b'\n');
64-
let semi_needle = U8x32::splat(b';');
90+
let nl_needle = SimdByte::splat(b'\n');
91+
let semi_needle = SimdByte::splat(b';');
6592

66-
// The non-overlapping 32-byte stride walk routes through
93+
// The non-overlapping `SimdByte::LANES`-wide stride walk routes through
6794
// `ndarray::simd::array_chunks` (simd_ops.rs) — the W1a batch-walk
6895
// primitive; `array_windows` is its OVERLAPPING sibling (GEMM-style
6996
// row windows) and is deliberately NOT used here: delimiter scanning
70-
// never re-reads bytes.
71-
let aligned_end = (len / U8x32::LANES) * U8x32::LANES;
72-
for (chunk_idx, chunk) in array_chunks::<u8, 32>(&data[..aligned_end]).enumerate() {
73-
let pos = chunk_idx * U8x32::LANES;
74-
let block = U8x32::from_slice(chunk);
97+
// never re-reads bytes. The const-generic is `{ SimdByte::LANES }`, so
98+
// the chunk width tracks the dispatched lane width (64 under avx512,
99+
// 32 under avx2) with no hardcoded stride.
100+
let aligned_end = (len / SimdByte::LANES) * SimdByte::LANES;
101+
for (chunk_idx, chunk) in
102+
array_chunks::<u8, { SimdByte::LANES }>(&data[..aligned_end]).enumerate()
103+
{
104+
let pos = chunk_idx * SimdByte::LANES;
105+
let block = SimdByte::from_slice(chunk);
75106
let nl_mask = block.cmpeq_mask(nl_needle);
76107
let semi_mask = block.cmpeq_mask(semi_needle);
77108
// `;` and `\n` never occupy the same byte, so OR-ing loses no
@@ -105,9 +136,10 @@ pub fn lane_b_simd(data: &[u8]) -> BTreeMap<String, Stats> {
105136
}
106137
}
107138

108-
// Tail — fewer than 32 bytes remain. Finish with a plain scalar scan,
109-
// continuing from whatever `line_start` / `pending_semi` state the SIMD
110-
// pass left behind (mirrors `lane_a_scalar`'s per-record shape).
139+
// Tail — fewer than `SimdByte::LANES` bytes remain. Finish with a plain
140+
// scalar scan, continuing from whatever `line_start` / `pending_semi`
141+
// state the SIMD pass left behind (mirrors `lane_a_scalar`'s per-record
142+
// shape).
111143
let mut i = aligned_end;
112144
while i < len {
113145
match pending_semi {

crates/onebrc-probe/src/lib.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -404,31 +404,36 @@ mod tests {
404404
}
405405

406406
/// Hand-built corpus, crafted so at least one record's `;`/`\n` land in
407-
/// DIFFERENT 32-byte SIMD blocks (block0=[0,32), block1=[32,64),
408-
/// tail=[64,..)) — exercises the cross-iteration `line_start` /
409-
/// `pending_semi` carry in `lane_b_simd`, not just the common in-block
410-
/// case a random generated corpus would mostly hit.
407+
/// DIFFERENT SIMD blocks at BOTH dispatched widths — the 68-byte corpus
408+
/// has a record straddling a 32-byte boundary (`long_name`, `;`@29 /
409+
/// `\n`@33) AND one straddling a 64-byte boundary (`Vv`, `;`@63 /
410+
/// `\n`@67), so the cross-iteration `line_start` / `pending_semi` carry
411+
/// in `lane_b_simd` is exercised whether the build strides 32-byte `ymm`
412+
/// (avx2) or 64-byte `zmm` (avx512), not just the common in-block case a
413+
/// random generated corpus would mostly hit.
411414
#[cfg(feature = "lane-b")]
412415
#[test]
413-
fn lane_b_handles_records_that_straddle_32_byte_block_boundaries() {
416+
fn lane_b_handles_records_that_straddle_block_boundaries() {
417+
let lanes = crate::lane_b::SIMD_LANES; // dispatched block width (32 or 64)
414418
let long_name = "N".repeat(22);
415419
let mut corpus = String::new();
416420
corpus.push_str("Ab;1.0\n"); // fully inside block0
417-
corpus.push_str(&format!("{long_name};9.9\n")); // straddles block0/block1
418-
corpus.push_str("Zz;3.3\n"); // fully inside block1
419-
corpus.push_str("QqRrSsTt;2.2\n"); // fully inside block1
420-
corpus.push_str("Uu;4.4\n"); // fully inside block1
421-
corpus.push_str("Vv;5.5\n"); // straddles block1/tail
421+
corpus.push_str(&format!("{long_name};9.9\n")); // straddles a 32-byte boundary
422+
corpus.push_str("Zz;3.3\n");
423+
corpus.push_str("QqRrSsTt;2.2\n");
424+
corpus.push_str("Uu;4.4\n");
425+
corpus.push_str("Vv;5.5\n"); // straddles the 64-byte boundary (block0/tail)
422426

423427
let data = corpus.as_bytes();
424428
assert!(
425-
data.len() > 64,
426-
"test corpus must span block0, block1, AND a tail region"
429+
data.len() > lanes,
430+
"test corpus must span at least one full SIMD block AND a tail region"
427431
);
428432

429433
// Confirm (rather than assume) that at least one record's `;` and
430-
// `\n` land in different 32-byte blocks — otherwise this test
431-
// would silently degrade into testing only the non-crossing case.
434+
// `\n` land in different SIMD blocks AT THE DISPATCHED WIDTH —
435+
// otherwise this test would silently degrade into testing only the
436+
// non-crossing case.
432437
let find_all = |needle: u8| -> Vec<usize> {
433438
data.iter()
434439
.enumerate()
@@ -442,10 +447,10 @@ mod tests {
442447
let crosses_a_block = semis
443448
.iter()
444449
.zip(newlines.iter())
445-
.any(|(&s, &n)| s / 32 != n / 32);
450+
.any(|(&s, &n)| s / lanes != n / lanes);
446451
assert!(
447452
crosses_a_block,
448-
"test corpus must contain a record whose `;`/`\\n` land in different 32-byte blocks"
453+
"test corpus must contain a record whose `;`/`\\n` land in different {lanes}-byte blocks"
449454
);
450455

451456
let a = lane_a_scalar(data);

0 commit comments

Comments
 (0)