Skip to content

Commit fa1a087

Browse files
authored
Speed up ARM fixed-width array scans (#54)
Add NEON cross-record scanners for 16- and 32-byte records, expand semantic and benchmark coverage, and retune the parallel crossover based on three-run measurements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01071e57-12fd-4f42-bc67-e2b274d0b007
1 parent dee199a commit fa1a087

7 files changed

Lines changed: 661 additions & 18 deletions

File tree

README.rst

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ Benchmark
183183
---------
184184

185185
All benchmarks were run on an Apple M4 Max (ARM64, 16 logical cores, 64 GiB)
186-
with hexhamming v3.0.0, ``rustc`` 1.96.1, and Python 3.14.6. Values are the
186+
with hexhamming v3.0.0, ``rustc`` 1.97.1, and Python 3.14.6. Values are the
187187
median of the means from three independent runs.
188188

189189
Raw Rust (no Python overhead)
@@ -192,6 +192,24 @@ Raw Rust (no Python overhead)
192192
These numbers show the pure computation time using Rust's ``criterion`` benchmarks
193193
(``cargo bench --no-default-features``), with no Python/PyO3 overhead.
194194

195+
Issue #51 fixed-width array matrix (1024 records; median of three run medians)
196+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
197+
198+
The matrix uses deterministic random no-match and exact-midpoint cases for
199+
16-byte and 32-byte records. Each run uses
200+
``--warm-up-time 1 --measurement-time 1 --sample-size 20``.
201+
202+
==================================== =========== ===========
203+
Case 16-byte (ns) 32-byte (ns)
204+
==================================== =========== ===========
205+
random no-match / first 397.5 797.8
206+
random no-match / best 407.0 992.2
207+
random no-match / all 523.7 1047.7
208+
exact midpoint / first 216.3 422.7
209+
exact midpoint / best 216.6 414.0
210+
exact midpoint / all 540.9 822.0
211+
==================================== =========== ===========
212+
195213
================================================ ===========
196214
Name Mean (ns)
197215
================================================ ===========
@@ -257,6 +275,23 @@ all_within_dist [512×16, at end] 537.5
257275
all_within_dist [16384×64, mid] 30,725.4
258276
====================================================== ===========
259277

278+
Issue #51 Python buffer matrix (1024 records; median of three run medians)
279+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
280+
281+
These end-to-end timings use ``timeit.repeat`` with 10,000 calls per sample,
282+
including the PyO3 wrapper and buffer-protocol path.
283+
284+
==================================== =========== ===========
285+
Case 16-byte (ns) 32-byte (ns)
286+
==================================== =========== ===========
287+
random no-match / first 443.4 836.8
288+
random no-match / best 487.0 850.2
289+
random no-match / all 564.8 851.7
290+
exact midpoint / first 272.2 468.1
291+
exact midpoint / best 295.0 483.8
292+
exact midpoint / all 638.8 921.0
293+
==================================== =========== ===========
294+
260295
For random inputs, the direct APIs also avoid the temporary big integers used
261296
by an equivalent standard-library implementation:
262297

@@ -276,6 +311,7 @@ dominates (roughly 30–40 ns on this machine). For large inputs
276311
(1024+ chars, 16384-element arrays), computation dominates and Python overhead
277312
is negligible. Byte operations release the GIL at 16 KiB, while immutable
278313
strings use a zero-copy detached path from 4 KiB. Array wrappers release the GIL
279-
at 64 KiB and parallelize with Rayon at 5 MiB; the ``first`` variant additionally
280-
short-circuits on the first hit, so a match near the start is much faster than
281-
one near the end.
314+
at 64 KiB; generic scans parallelize with Rayon at 5 MiB, while the optimized
315+
16/32-byte NEON scanners use a measured 16 MiB crossover. The ``first`` variant
316+
additionally short-circuits on the first hit, so a match near the start is much
317+
faster than one near the end.

benches/hamming_bench.rs

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use hexhamming::hex_hamming_distance_pack;
99

1010
// Hex sizes are character counts; byte sizes are the corresponding decoded lengths.
1111
const HEX_SIZES: [usize; 5] = [16, 32, 64, 128, 254];
12-
const BYTE_SIZES: [usize; 5] = [8, 16, 32, 64, 127];
12+
const BYTE_SIZES: [usize; 8] = [8, 16, 32, 64, 127, 128, 256, 512];
1313

1414
fn pseudo_random_bytes(len: usize, seed: u64) -> Vec<u8> {
1515
let mut state = seed;
@@ -287,6 +287,124 @@ fn bench_array_random_and_boundaries(c: &mut Criterion) {
287287
group.finish();
288288
}
289289

290+
fn fixed_width_array_case(width: usize, scenario: &str) -> (Vec<u8>, Vec<u8>, i64) {
291+
const NUM_ELEMENTS: usize = 1024;
292+
let small = pseudo_random_bytes(width, 0x51 + width as u64);
293+
let mut big = pseudo_random_bytes(NUM_ELEMENTS * width, 0xA1 + width as u64);
294+
let (match_index, max_dist) = match scenario {
295+
"random_no_match" => (None, 0),
296+
"exact_early" => (Some(0), 0),
297+
"exact_mid" => (Some(NUM_ELEMENTS / 2), 0),
298+
"exact_late" => (Some(NUM_ELEMENTS - 1), 0),
299+
"threshold_d_minus_1" => (Some(NUM_ELEMENTS / 2), 3),
300+
"threshold_d" => (Some(NUM_ELEMENTS / 2), 4),
301+
"threshold_d_plus_1" => (Some(NUM_ELEMENTS / 2), 5),
302+
_ => unreachable!("unknown fixed-width benchmark scenario"),
303+
};
304+
if let Some(index) = match_index {
305+
let record = &mut big[index * width..(index + 1) * width];
306+
record.copy_from_slice(&small);
307+
if scenario.starts_with("threshold_") {
308+
record[0] ^= 0x0F;
309+
}
310+
}
311+
(big, small, max_dist)
312+
}
313+
314+
fn bench_fixed_width_array_matrix(c: &mut Criterion) {
315+
let scenarios = [
316+
"random_no_match",
317+
"exact_early",
318+
"exact_mid",
319+
"exact_late",
320+
"threshold_d_minus_1",
321+
"threshold_d",
322+
"threshold_d_plus_1",
323+
];
324+
325+
for width in [16usize, 32] {
326+
let mut group = c.benchmark_group(format!("array_matrix/{width}byte_records"));
327+
for scenario in scenarios {
328+
let (big, small, max_dist) = fixed_width_array_case(width, scenario);
329+
group.bench_function(format!("{scenario}/first"), |bencher| {
330+
bencher.iter(|| {
331+
bytes_array_first_within_dist(
332+
black_box(&big),
333+
black_box(&small),
334+
black_box(max_dist),
335+
)
336+
})
337+
});
338+
group.bench_function(format!("{scenario}/best"), |bencher| {
339+
bencher.iter(|| {
340+
bytes_array_best_within_dist(
341+
black_box(&big),
342+
black_box(&small),
343+
black_box(max_dist),
344+
)
345+
})
346+
});
347+
group.bench_function(format!("{scenario}/all"), |bencher| {
348+
bencher.iter(|| {
349+
bytes_array_all_within_dist(
350+
black_box(&big),
351+
black_box(&small),
352+
black_box(max_dist),
353+
)
354+
})
355+
});
356+
}
357+
group.finish();
358+
}
359+
}
360+
361+
fn bench_fixed_width_parallel_crossover(c: &mut Criterion) {
362+
const PAR_THRESHOLDS: [(&str, usize); 2] = [
363+
("legacy", 5 * 1024 * 1024),
364+
("fixed_width", 16 * 1024 * 1024),
365+
];
366+
let mut group = c.benchmark_group("array_matrix/parallel_crossover");
367+
group.sample_size(10);
368+
for width in [16usize, 32] {
369+
let small = pseudo_random_bytes(width, 0xC1 + width as u64);
370+
for &(threshold_name, threshold_bytes) in &PAR_THRESHOLDS {
371+
let threshold_elements = threshold_bytes / width;
372+
for count in [
373+
threshold_elements - 1,
374+
threshold_elements,
375+
threshold_elements + 1,
376+
] {
377+
let big = pseudo_random_bytes(count * width, 0xD1 + count as u64);
378+
group.bench_function(
379+
format!("{threshold_name}/{width}byte/{count}elements/best"),
380+
|bencher| {
381+
bencher.iter(|| {
382+
bytes_array_best_within_dist(
383+
black_box(&big),
384+
black_box(&small),
385+
black_box(0),
386+
)
387+
})
388+
},
389+
);
390+
group.bench_function(
391+
format!("{threshold_name}/{width}byte/{count}elements/all"),
392+
|bencher| {
393+
bencher.iter(|| {
394+
bytes_array_all_within_dist(
395+
black_box(&big),
396+
black_box(&small),
397+
black_box(0),
398+
)
399+
})
400+
},
401+
);
402+
}
403+
}
404+
}
405+
group.finish();
406+
}
407+
290408
#[cfg(target_arch = "aarch64")]
291409
fn bench_hex_string_pack(c: &mut Criterion) {
292410
// AArch64-only group for the packed NEON hex-string path.
@@ -309,6 +427,8 @@ criterion_group!(
309427
bench_bytes_within_dist,
310428
bench_array_api,
311429
bench_array_random_and_boundaries,
430+
bench_fixed_width_array_matrix,
431+
bench_fixed_width_parallel_crossover,
312432
bench_hex_string_pack
313433
);
314434
#[cfg(not(target_arch = "aarch64"))]
@@ -318,6 +438,8 @@ criterion_group!(
318438
bench_bytes_by_algo,
319439
bench_bytes_within_dist,
320440
bench_array_api,
321-
bench_array_random_and_boundaries
441+
bench_array_random_and_boundaries,
442+
bench_fixed_width_array_matrix,
443+
bench_fixed_width_parallel_crossover
322444
);
323445
criterion_main!(benches);

src/api.rs

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#[cfg(target_arch = "aarch64")]
2+
use crate::ALGO_NEON;
13
use crate::{
24
hamming_distance_bytes_dispatch, hamming_distance_string_dispatch,
35
select_bytes_kernel_for_width, BytesKernel, ALGO_CLASSIC, ALGO_NATIVE, CURRENT_ALGO,
@@ -10,11 +12,55 @@ use std::sync::atomic::Ordering;
1012

1113
/// Minimum total byte size of big_array before we use rayon parallel paths.
1214
const PAR_THRESHOLD_BYTES: usize = 5 * 1024 * 1024;
15+
/// The fixed-width NEON scanners make serial scans substantially cheaper, so
16+
/// use a larger crossover before paying Rayon scheduling and partition costs.
17+
const FIXED_WIDTH_PAR_THRESHOLD_BYTES: usize = 16 * 1024 * 1024;
1318
/// Keep byte-array scans to a small number of coarse jobs. More workers spend
1419
/// more time scheduling these very small per-record calculations than running
1520
/// them on current many-core CPUs.
1621
const PAR_JOBS: usize = 4;
1722

23+
type ArrayFirstScanner = fn(&[u8], &[u8], i64) -> Option<usize>;
24+
type ArrayBestScanner = fn(&[u8], &[u8], i64) -> Option<(u64, usize)>;
25+
type ArrayAllScanner = fn(&[u8], &[u8], i64) -> Vec<(u64, usize)>;
26+
27+
#[derive(Clone, Copy)]
28+
struct ArrayScanner {
29+
first: ArrayFirstScanner,
30+
best: ArrayBestScanner,
31+
all: ArrayAllScanner,
32+
}
33+
34+
#[inline]
35+
fn select_array_scanner_for_width(width: usize) -> Option<ArrayScanner> {
36+
#[cfg(target_arch = "aarch64")]
37+
{
38+
let algo = CURRENT_ALGO.load(Ordering::Relaxed);
39+
if algo != ALGO_NATIVE && algo != ALGO_NEON {
40+
return None;
41+
}
42+
return match width {
43+
16 => Some(ArrayScanner {
44+
first: crate::neon_simd::array_first_neon_16,
45+
best: crate::neon_simd::array_best_neon_16,
46+
all: crate::neon_simd::array_all_neon_16,
47+
}),
48+
32 => Some(ArrayScanner {
49+
first: crate::neon_simd::array_first_neon_32,
50+
best: crate::neon_simd::array_best_neon_32,
51+
all: crate::neon_simd::array_all_neon_32,
52+
}),
53+
_ => None,
54+
};
55+
}
56+
57+
#[cfg(not(target_arch = "aarch64"))]
58+
{
59+
let _ = width;
60+
None
61+
}
62+
}
63+
1864
#[inline]
1965
fn partition_element_ranges(num_elements: usize) -> [(usize, usize); PAR_JOBS] {
2066
let base = num_elements / PAR_JOBS;
@@ -103,6 +149,9 @@ pub fn bytes_array_first_within_dist(
103149
if big_array.len() % small_array.len() != 0 {
104150
return Err("array_of_elems size must be multiplier of elem_to_compare");
105151
}
152+
if let Some(scanner) = select_array_scanner_for_width(small_array.len()) {
153+
return Ok((scanner.first)(big_array, small_array, max_dist));
154+
}
106155
// `first` has early-exit semantics: the serial scan returns as soon as the
107156
// first match is found, which is essentially free for early/common matches.
108157
// Parallelizing this requires a full non-short-circuiting scan to compute
@@ -149,7 +198,16 @@ pub fn bytes_array_best_within_dist(
149198
return Err("array_of_elems size must be multiplier of elem_to_compare");
150199
}
151200
let kernel = select_bytes_kernel_for_width(small_array.len());
152-
if big_array.len() < PAR_THRESHOLD_BYTES {
201+
let scanner = select_array_scanner_for_width(small_array.len());
202+
let parallel_threshold = if scanner.is_some() {
203+
FIXED_WIDTH_PAR_THRESHOLD_BYTES
204+
} else {
205+
PAR_THRESHOLD_BYTES
206+
};
207+
if big_array.len() < parallel_threshold {
208+
if let Some(scanner) = scanner {
209+
return Ok((scanner.best)(big_array, small_array, max_dist));
210+
}
153211
return Ok(serial_best_within_dist(
154212
big_array,
155213
small_array,
@@ -166,8 +224,11 @@ pub fn bytes_array_best_within_dist(
166224
.with_max_len(1)
167225
.map(|&(start, end)| {
168226
let chunk = &big_array[start * elem_size..end * elem_size];
169-
serial_best_within_dist(chunk, small_array, max_dist, kernel)
170-
.map(|(distance, index)| (distance, index + start))
227+
let best = match scanner {
228+
Some(scanner) => (scanner.best)(chunk, small_array, max_dist),
229+
None => serial_best_within_dist(chunk, small_array, max_dist, kernel),
230+
};
231+
best.map(|(distance, index)| (distance, index + start))
171232
})
172233
.reduce(|| None, merge_best))
173234
}
@@ -225,7 +286,16 @@ pub fn bytes_array_all_within_dist(
225286
return Err("array_of_elems size must be multiplier of elem_to_compare");
226287
}
227288
let kernel = select_bytes_kernel_for_width(small_array.len());
228-
if big_array.len() < PAR_THRESHOLD_BYTES {
289+
let scanner = select_array_scanner_for_width(small_array.len());
290+
let parallel_threshold = if scanner.is_some() {
291+
FIXED_WIDTH_PAR_THRESHOLD_BYTES
292+
} else {
293+
PAR_THRESHOLD_BYTES
294+
};
295+
if big_array.len() < parallel_threshold {
296+
if let Some(scanner) = scanner {
297+
return Ok((scanner.all)(big_array, small_array, max_dist));
298+
}
229299
return Ok(serial_all_within_dist(
230300
big_array,
231301
small_array,
@@ -241,7 +311,11 @@ pub fn bytes_array_all_within_dist(
241311
.with_max_len(1)
242312
.map(|&(start, end)| {
243313
let chunk = &big_array[start * elem_size..end * elem_size];
244-
serial_all_within_dist(chunk, small_array, max_dist, kernel)
314+
let matches = match scanner {
315+
Some(scanner) => (scanner.all)(chunk, small_array, max_dist),
316+
None => serial_all_within_dist(chunk, small_array, max_dist, kernel),
317+
};
318+
matches
245319
.into_iter()
246320
.map(|(distance, index)| (distance, index + start))
247321
.collect()

0 commit comments

Comments
 (0)