Skip to content

Commit 392ba1b

Browse files
mrecachinasCopilot
andauthored
Speed up batch scans and Python calls (#53)
* perf(array): stop best scan at exact match — 99.5% on 512x16 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * perf(array): bound Rayon fan-out — 84.8% on 100000x128 best Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * perf(python): bypass PyBuffer for bytes — 64.2% on within-distance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * perf(python): keep small buffers attached — 22.7% on bytearray best Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * perf(rust): inline hex entrypoint — 21.8% on 16-char Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * perf(neon): check first hex block eagerly — 49.9% on tight 1024-char Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * perf(neon): scale eager blocks with cutoff — 47.3% on random max100 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * perf(aarch64): prefer native byte kernel — 24.0% on 64-byte Rust Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 * docs: refresh M4 Max performance benchmarks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7b02886-a2c5-43cd-bb97-bfa037d3b625 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 16e978b commit 392ba1b

5 files changed

Lines changed: 313 additions & 120 deletions

File tree

README.rst

Lines changed: 58 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -182,70 +182,81 @@ immutable that is a very slow operation. Use a ``bytearray`` instead, and cast i
182182
Benchmark
183183
---------
184184

185-
All benchmarks on Apple M-series (ARM64) with hexhamming v3.0.0, ``rustc`` 1.85, Python 3.14.
185+
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
187+
median of the means from three independent runs.
186188

187189
Raw Rust (no Python overhead)
188190
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
189191

190192
These numbers show the pure computation time using Rust's ``criterion`` benchmarks
191193
(``cargo bench --no-default-features``), with no Python/PyO3 overhead.
192194

193-
=========================================== ===========
194-
Name Mean (ns)
195-
=========================================== ===========
196-
hex_string (NEON) [16 chars] 2.0
197-
hex_string (NEON) [64 chars] 5.8
198-
hex_string (NEON) [128 chars] 11.2
199-
hex_string (NEON) [254 chars] 21.2
200-
bytes (NEON) [8 bytes] 1.8
201-
bytes (NEON) [32 bytes] 2.8
202-
bytes (NEON) [64 bytes] 2.6
203-
bytes (NEON) [127 bytes] 6.6
204-
bytes_within_dist [127 bytes] 1.6
205-
array first [512×16, at start] 2.3
206-
array first [512×16, at end] 671.4
207-
array best [512×16] 925.8
208-
array all [512×16] 799.4
209-
=========================================== ===========
210-
211-
Larger array workloads cross the parallel (Rayon) threshold; see the Python
212-
table below for representative end-to-end numbers.
195+
================================================ ===========
196+
Name Mean (ns)
197+
================================================ ===========
198+
hex_string (NEON) [16 chars] 2.4
199+
hex_string (NEON) [64 chars] 8.3
200+
hex_string (NEON) [128 chars] 16.2
201+
hex_string (NEON) [254 chars] 30.2
202+
bytes (native) [8 bytes] 1.7
203+
bytes (native) [32 bytes] 2.4
204+
bytes (native) [64 bytes] 3.2
205+
bytes (native) [127 bytes] 8.4
206+
bytes_within_dist [127 bytes] 2.4
207+
array first [512×16, at start] 6.6
208+
array first [512×16, at end] 1,397.0
209+
array best [512×16, exact at start] 8.3
210+
array best [512×16, exact at end] 1,599.2
211+
array all [512×16] 1,610.1
212+
array best [16384×64, match at mid] 71,121.0
213+
array all [16384×64, match at mid] 79,365.0
214+
array best [100000×128, parallel] 50,996.0
215+
array all [100000×128, parallel] 144,800.0
216+
================================================ ===========
217+
218+
On AArch64, LLVM's auto-vectorized native byte loop is faster than the
219+
hand-written NEON byte kernel for these sizes, while hexadecimal strings still
220+
use the packed NEON implementation. Large array workloads use four balanced
221+
Rayon jobs to avoid oversubscribing the memory-bound scan.
213222

214223
Python API (via PyO3)
215224
~~~~~~~~~~~~~~~~~~~~~
216225

217-
These numbers include Python function call overhead (~45 ns) using ``pytest-benchmark``.
226+
These numbers include Python wrapper and function-call overhead using
227+
``pytest-benchmark``.
218228

219229
====================================================== ===========
220230
Name Mean (ns)
221231
====================================================== ===========
222-
hamming_distance_string [3 chars, same] 84.6
223-
hamming_distance_string [3 chars, diff] 83.6
224-
hamming_distance_string [64 chars, diff] 91.3
225-
hamming_distance_string [1024 chars, diff] 207.4
226-
hamming_distance_bytes [3 bytes, same] 127.3
227-
hamming_distance_bytes [3 bytes, diff] 96.2
228-
hamming_distance_bytes [64 bytes, diff] 169.9
229-
hamming_distance_bytes [1024 bytes, diff] 175.2
230-
check_hexstrings_within_dist [1000 chars] 221.3
231-
check_bytes_within_dist [16 bytes] 146.2
232-
check_bytes_within_dist [64 bytes] 107.7
233-
check_bytes_within_dist [127 bytes] 100.6
234-
first_within_dist [512×16, at start] 98.7
235-
first_within_dist [512×16, mid] 570.8
236-
first_within_dist [512×16, at end] 1,030.1
237-
first_within_dist [16384×64, at start] 99.6
238-
first_within_dist [16384×64, mid] 20,838.8
239-
first_within_dist [16384×64, at end] 41,489.5
240-
best_within_dist [512×16, at start] 1,609.8
241-
best_within_dist [512×16, at end] 1,116.4
242-
best_within_dist [16384×64, mid] 46,826.1
243-
all_within_dist [512×16, at start] 1,342.9
244-
all_within_dist [512×16, at end] 1,365.9
245-
all_within_dist [16384×64, mid] 48,067.2
232+
hamming_distance_string [3 chars, same] 56.3
233+
hamming_distance_string [3 chars, diff] 105.8
234+
hamming_distance_string [64 chars, diff] 60.0
235+
hamming_distance_string [1024 chars, diff] 177.1
236+
hamming_distance_bytes [3 bytes, same] 51.7
237+
hamming_distance_bytes [3 bytes, diff] 51.8
238+
hamming_distance_bytes [64 bytes, diff] 51.9
239+
hamming_distance_bytes [1024 bytes, diff] 68.9
240+
check_hexstrings_within_dist [1000 chars] 56.4
241+
check_bytes_within_dist [16 bytes] 52.5
242+
check_bytes_within_dist [64 bytes] 51.8
243+
check_bytes_within_dist [127 bytes] 52.8
244+
first_within_dist [512×16, at start] 58.5
245+
first_within_dist [512×16, mid] 771.4
246+
first_within_dist [512×16, at end] 1,475.1
247+
first_within_dist [16384×64, at start] 160.3
248+
first_within_dist [16384×64, mid] 23,031.5
249+
first_within_dist [16384×64, at end] 45,801.2
250+
best_within_dist [512×16, at start] 75.3
251+
best_within_dist [512×16, at end] 1,703.5
252+
best_within_dist [16384×64, mid] 93,212.8
253+
all_within_dist [512×16, at start] 1,735.5
254+
all_within_dist [512×16, at end] 1,747.3
255+
all_within_dist [16384×64, mid] 93,056.3
246256
====================================================== ===========
247257

248-
For small inputs, Python call overhead dominates (~45 ns). For large inputs
258+
For small inputs, Python call and wrapper overhead dominates (roughly 40–55 ns
259+
on this machine). For large inputs
249260
(1024+ chars, 16384-element arrays), computation dominates and Python overhead
250261
is negligible. Array APIs transparently parallelize with Rayon once the input
251262
exceeds ~64 KiB; the ``first`` variant additionally short-circuits on the first

benches/hamming_bench.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ fn bench_bytes_by_algo(c: &mut Criterion) {
4747
let algos: &[&str] = if cfg!(target_arch = "x86_64") {
4848
&["classic", "sse", "avx2", "avx512"]
4949
} else if cfg!(target_arch = "aarch64") {
50-
&["classic", "neon"]
50+
&["classic", "native", "neon"]
5151
} else {
5252
&["classic", "native"]
5353
};

src/api.rs

Lines changed: 80 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,27 @@ use rayon::prelude::*;
99
use std::sync::atomic::Ordering;
1010

1111
/// Minimum total byte size of big_array before we use rayon parallel paths.
12-
const PAR_THRESHOLD_BYTES: usize = 64 * 1024;
12+
const PAR_THRESHOLD_BYTES: usize = 256 * 1024;
13+
/// Keep byte-array scans to a small number of coarse jobs. More workers spend
14+
/// more time scheduling these very small per-record calculations than running
15+
/// them on current many-core CPUs.
16+
const PAR_JOBS: usize = 4;
17+
18+
#[inline]
19+
fn partition_element_ranges(num_elements: usize) -> [(usize, usize); PAR_JOBS] {
20+
let base = num_elements / PAR_JOBS;
21+
let remainder = num_elements % PAR_JOBS;
22+
let mut ranges = [(0, 0); PAR_JOBS];
23+
let mut start = 0;
24+
25+
for (job, range) in ranges.iter_mut().enumerate() {
26+
let end = start + base + usize::from(job < remainder);
27+
*range = (start, end);
28+
start = end;
29+
}
30+
31+
ranges
32+
}
1333

1434
/// Calculate the bitwise hamming distance between two equal-length hex strings.
1535
///
@@ -23,6 +43,7 @@ const PAR_THRESHOLD_BYTES: usize = 64 * 1024;
2343
/// let dist = hexhamming::hex_hamming_distance("deadbeef", "00000000").unwrap();
2444
/// assert_eq!(dist, 24);
2545
/// ```
46+
#[inline]
2647
pub fn hex_hamming_distance(a: &str, b: &str) -> Result<u64, &'static str> {
2748
if a.len() != b.len() {
2849
return Err("strings are NOT the same length");
@@ -121,43 +142,27 @@ pub fn bytes_array_best_within_dist(
121142
return Ok(serial_best_within_dist(big_array, small_array, max_dist));
122143
}
123144
let elem_size = small_array.len();
124-
Ok(big_array
125-
.par_chunks_exact(elem_size)
126-
.enumerate()
127-
.fold(
128-
|| None::<(u64, usize)>,
129-
|acc, (i, chunk)| {
130-
let threshold = acc
131-
.map(|(d, _)| (d as i64).saturating_sub(1))
132-
.unwrap_or(max_dist);
133-
let d = hamming_distance_bytes_dispatch(chunk, small_array, threshold);
134-
if d == u64::MAX {
135-
return acc;
136-
}
137-
match acc {
138-
None => Some((d, i)),
139-
Some((best_d, _)) if d < best_d => Some((d, i)),
140-
_ => acc,
141-
}
142-
},
143-
)
144-
.reduce(
145-
|| None,
146-
|a, b| match (a, b) {
147-
(None, x) | (x, None) => x,
148-
(Some(x), Some(y)) => {
149-
if x.0 < y.0 {
150-
Some(x)
151-
} else if y.0 < x.0 {
152-
Some(y)
153-
} else if x.1 < y.1 {
154-
Some(x)
155-
} else {
156-
Some(y)
157-
}
158-
}
159-
},
160-
))
145+
let num_elements = big_array.len() / elem_size;
146+
let ranges = partition_element_ranges(num_elements);
147+
148+
Ok(ranges
149+
.par_iter()
150+
.with_max_len(1)
151+
.map(|&(start, end)| {
152+
let chunk = &big_array[start * elem_size..end * elem_size];
153+
serial_best_within_dist(chunk, small_array, max_dist)
154+
.map(|(distance, index)| (distance, index + start))
155+
})
156+
.reduce(|| None, merge_best))
157+
}
158+
159+
#[inline]
160+
fn merge_best(a: Option<(u64, usize)>, b: Option<(u64, usize)>) -> Option<(u64, usize)> {
161+
match (a, b) {
162+
(None, x) | (x, None) => x,
163+
(Some(x), Some(y)) if x.0 < y.0 || (x.0 == y.0 && x.1 <= y.1) => Some(x),
164+
(Some(_), Some(y)) => Some(y),
165+
}
161166
}
162167

163168
#[inline]
@@ -180,6 +185,9 @@ fn serial_best_within_dist(
180185
}
181186
if best.is_none() || d < best.unwrap().0 {
182187
best = Some((d, i));
188+
if d == 0 {
189+
return best;
190+
}
183191
}
184192
}
185193
best
@@ -203,19 +211,27 @@ pub fn bytes_array_all_within_dist(
203211
return Ok(serial_all_within_dist(big_array, small_array, max_dist));
204212
}
205213
let elem_size = small_array.len();
206-
let mut results: Vec<(u64, usize)> = big_array
207-
.par_chunks_exact(elem_size)
208-
.enumerate()
209-
.filter_map(|(i, chunk)| {
210-
let d = hamming_distance_bytes_dispatch(chunk, small_array, max_dist);
211-
if d == u64::MAX {
212-
None
213-
} else {
214-
Some((d, i))
215-
}
214+
let num_elements = big_array.len() / elem_size;
215+
let ranges = partition_element_ranges(num_elements);
216+
let per_job: Vec<Vec<(u64, usize)>> = ranges
217+
.par_iter()
218+
.with_max_len(1)
219+
.map(|&(start, end)| {
220+
let chunk = &big_array[start * elem_size..end * elem_size];
221+
serial_all_within_dist(chunk, small_array, max_dist)
222+
.into_iter()
223+
.map(|(distance, index)| (distance, index + start))
224+
.collect()
216225
})
217226
.collect();
218-
results.sort_unstable_by_key(|&(_, idx)| idx);
227+
228+
// The indexed parallel iterator preserves range order, and each serial
229+
// result is already ordered, so flattening preserves ascending indices.
230+
let result_count = per_job.iter().map(Vec::len).sum();
231+
let mut results = Vec::with_capacity(result_count);
232+
for job_results in per_job {
233+
results.extend(job_results);
234+
}
219235
Ok(results)
220236
}
221237

@@ -423,11 +439,25 @@ mod tests {
423439
big
424440
}
425441

442+
#[test]
443+
fn parallel_ranges_cover_elements_once() {
444+
for num_elements in [1, 3, 4, 5, 7, 8, 9, 100, 100_001] {
445+
let ranges = partition_element_ranges(num_elements);
446+
assert_eq!(ranges[0].0, 0);
447+
assert_eq!(ranges[PAR_JOBS - 1].1, num_elements);
448+
for pair in ranges.windows(2) {
449+
assert_eq!(pair[0].1, pair[1].0);
450+
}
451+
let lengths: Vec<usize> = ranges.iter().map(|&(start, end)| end - start).collect();
452+
assert!(lengths.iter().max().unwrap() - lengths.iter().min().unwrap() <= 1);
453+
}
454+
}
455+
426456
#[test]
427457
fn first_within_dist_small_batch() {
428458
// Below PAR_THRESHOLD_BYTES → serial path
429459
let elem_size = 4;
430-
let n = 100; // 400 bytes < 64KB
460+
let n = 100; // 400 bytes < parallel threshold
431461
let needle = vec![0x00u8; elem_size];
432462
let big = make_batch(elem_size, n, 0xFF, &[50], &needle);
433463
assert_eq!(

src/neon_simd.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -481,10 +481,40 @@ pub unsafe fn hamming_distance_string_neon_pack_with_max(
481481
let mut difference: u64 = 0;
482482
let mut bad_acc = zero;
483483

484-
// Process 32 hex chars at a time, batching cross-lane reductions and the
485-
// threshold check. Each packed byte popcount is ≤8, so BATCH iterations are
486-
// safe in u8 lanes (BATCH*8 < 256). Early exit is granular to one batch.
487-
const BATCH: usize = 16;
484+
// Check enough initial blocks to cross a tight threshold under typical
485+
// two-bits-per-nibble data. Larger thresholds go straight to batching.
486+
let eager_blocks = if max_dist < 256 {
487+
(max_dist as usize / 64) + 1
488+
} else {
489+
0
490+
};
491+
let mut eager = 0;
492+
while eager < eager_blocks && i + 32 <= length {
493+
let (packed, bad) = pack32_xor_neon(
494+
a.as_ptr().add(i),
495+
b.as_ptr().add(i),
496+
case_mask,
497+
ascii_0,
498+
seven,
499+
nine,
500+
ten,
501+
fifteen_u,
502+
);
503+
bad_acc = vorrq_u8(bad_acc, bad);
504+
if vmaxvq_u8(bad_acc) != 0 {
505+
return Err("hex string contains invalid char");
506+
}
507+
difference += vaddlvq_u8(vcntq_u8(packed)) as u64;
508+
if difference > max_dist {
509+
return Ok(u64::MAX);
510+
}
511+
i += 32;
512+
eager += 1;
513+
}
514+
515+
// Each packed byte popcount is at most 8, so 31 iterations fit safely in
516+
// u8 lanes (31 * 8 = 248) before one cross-lane reduction.
517+
const BATCH: usize = 31;
488518
while i + 32 <= length {
489519
let mut acc = zero;
490520
let mut n = 0;

0 commit comments

Comments
 (0)