Skip to content
112 changes: 65 additions & 47 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -195,24 +195,24 @@ These numbers show the pure computation time using Rust's ``criterion`` benchmar
================================================ ===========
Name Mean (ns)
================================================ ===========
hex_string (NEON) [16 chars] 2.4
hex_string (NEON) [64 chars] 8.3
hex_string (NEON) [128 chars] 16.2
hex_string (NEON) [254 chars] 30.2
bytes (native) [8 bytes] 1.7
bytes (native) [32 bytes] 2.4
bytes (native) [64 bytes] 3.2
bytes (native) [127 bytes] 8.4
bytes_within_dist [127 bytes] 2.4
array first [512×16, at start] 6.6
array first [512×16, at end] 1,397.0
array best [512×16, exact at start] 8.3
array best [512×16, exact at end] 1,599.2
array all [512×16] 1,610.1
array best [16384×64, match at mid] 71,121.0
array all [16384×64, match at mid] 79,365.0
array best [100000×128, parallel] 50,996.0
array all [100000×128, parallel] 144,800.0
hex_string (NEON) [16 chars] 1.6
hex_string (NEON) [64 chars] 5.4
hex_string (NEON) [128 chars] 10.5
hex_string (NEON) [254 chars] 19.7
bytes (native) [8 bytes] 1.1
bytes (native) [32 bytes] 1.5
bytes (native) [64 bytes] 2.1
bytes (native) [127 bytes] 5.5
bytes_within_dist [127 bytes] 1.6
array first [512×16, at start] 1.9
array first [512×16, at end] 402.4
array best [512×16, exact at start] 3.3
array best [512×16, exact at end] 526.4
array all [512×16] 449.0
array best [16384×64, match at mid] 10,986.0
array all [16384×64, match at mid] 20,350.0
array best [100000×128, parallel] 46,547.0
array all [100000×128, parallel] 99,266.0
================================================ ===========

On AArch64, LLVM's auto-vectorized native byte loop is faster than the
Expand All @@ -229,35 +229,53 @@ These numbers include Python wrapper and function-call overhead using
====================================================== ===========
Name Mean (ns)
====================================================== ===========
hamming_distance_string [3 chars, same] 56.3
hamming_distance_string [3 chars, diff] 105.8
hamming_distance_string [64 chars, diff] 60.0
hamming_distance_string [1024 chars, diff] 177.1
hamming_distance_bytes [3 bytes, same] 51.7
hamming_distance_bytes [3 bytes, diff] 51.8
hamming_distance_bytes [64 bytes, diff] 51.9
hamming_distance_bytes [1024 bytes, diff] 68.9
check_hexstrings_within_dist [1000 chars] 56.4
check_bytes_within_dist [16 bytes] 52.5
check_bytes_within_dist [64 bytes] 51.8
check_bytes_within_dist [127 bytes] 52.8
first_within_dist [512×16, at start] 58.5
first_within_dist [512×16, mid] 771.4
first_within_dist [512×16, at end] 1,475.1
first_within_dist [16384×64, at start] 160.3
first_within_dist [16384×64, mid] 23,031.5
first_within_dist [16384×64, at end] 45,801.2
best_within_dist [512×16, at start] 75.3
best_within_dist [512×16, at end] 1,703.5
best_within_dist [16384×64, mid] 93,212.8
all_within_dist [512×16, at start] 1,735.5
all_within_dist [512×16, at end] 1,747.3
all_within_dist [16384×64, mid] 93,056.3
hamming_distance_string [3 chars, same] 37.1
hamming_distance_string [3 chars, diff] 70.7
hamming_distance_string [64 chars, diff] 39.8
hamming_distance_string [1024 chars, diff] 116.4
hamming_distance_bytes [3 bytes, same] 33.4
hamming_distance_bytes [3 bytes, diff] 39.9
hamming_distance_bytes [64 bytes, diff] 33.9
hamming_distance_bytes [1024 bytes, diff] 44.6
hamming_distance_bytes [64-byte bytearray] 50.6
hamming_distance_bytes [64-byte memoryview] 51.5
check_hexstrings_within_dist [1000 chars] 37.3
check_bytes_within_dist [16 bytes] 34.3
check_bytes_within_dist [64 bytes] 33.7
check_bytes_within_dist [127 bytes] 34.7
first_within_dist [512×16, at start] 35.6
first_within_dist [512×16, mid] 240.7
first_within_dist [512×16, at end] 440.5
first_within_dist [16384×64, at start] 74.2
first_within_dist [16384×64, mid] 14,319.4
first_within_dist [16384×64, at end] 28,619.9
best_within_dist [512×16, at start] 47.0
best_within_dist [512×16, at end] 584.3
best_within_dist [16384×64, mid] 32,031.4
all_within_dist [512×16, at start] 530.4
all_within_dist [512×16, at end] 537.5
all_within_dist [16384×64, mid] 30,725.4
====================================================== ===========

For small inputs, Python call and wrapper overhead dominates (roughly 40–55 ns
on this machine). For large inputs
For random inputs, the direct APIs also avoid the temporary big integers used
by an equivalent standard-library implementation:

================ =============== ============== ========
Input hexhamming (ns) stdlib (ns) Speedup
================ =============== ============== ========
bytes [16] 33.5 158.3 4.72×
bytes [64] 37.4 233.1 6.24×
bytes [1024] 53.2 2,029.0 38.17×
hex [16 chars] 37.2 126.3 3.39×
hex [64 chars] 39.7 200.2 5.05×
hex [1024 chars] 116.5 1,708.1 14.66×
================ =============== ============== ========

For small exact ``str`` and ``bytes`` inputs, Python call and wrapper overhead
dominates (roughly 30–40 ns on this machine). For large inputs
(1024+ chars, 16384-element arrays), computation dominates and Python overhead
is negligible. Array APIs transparently parallelize with Rayon once the input
exceeds ~64 KiB; the ``first`` variant additionally short-circuits on the first
hit, so a match near the start is much faster than one near the end.
is negligible. Byte operations release the GIL at 16 KiB, while immutable
strings use a zero-copy detached path from 4 KiB. Array wrappers release the GIL
at 64 KiB and parallelize with Rayon at 5 MiB; the ``first`` variant additionally
short-circuits on the first hit, so a match near the start is much faster than
one near the end.
99 changes: 98 additions & 1 deletion benches/hamming_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ use hexhamming::hex_hamming_distance_pack;
const HEX_SIZES: [usize; 5] = [16, 32, 64, 128, 254];
const BYTE_SIZES: [usize; 5] = [8, 16, 32, 64, 127];

fn pseudo_random_bytes(len: usize, seed: u64) -> Vec<u8> {
let mut state = seed;
(0..len)
.map(|_| {
state = state.wrapping_add(0x9E3779B97F4A7C15);
let mut value = state;
value = (value ^ (value >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
value = (value ^ (value >> 27)).wrapping_mul(0x94D049BB133111EB);
((value ^ (value >> 31)) >> 56) as u8
})
.collect()
}

/// Benchmark hex hamming distance across all available algorithms
fn bench_hex_by_algo(c: &mut Criterion) {
let algos: &[&str] = if cfg!(target_arch = "x86_64") {
Expand Down Expand Up @@ -192,6 +205,88 @@ fn bench_array_api(c: &mut Criterion) {
group.finish();
}

fn bench_array_random_and_boundaries(c: &mut Criterion) {
let small_16 = pseudo_random_bytes(16, 1);
let big_512x16 = pseudo_random_bytes(512 * 16, 2);
let mut group = c.benchmark_group("array_api/512x16_random_no_match");
group.bench_function("first", |bencher| {
bencher.iter(|| {
bytes_array_first_within_dist(
black_box(&big_512x16),
black_box(&small_16),
black_box(0),
)
})
});
group.bench_function("best", |bencher| {
bencher.iter(|| {
bytes_array_best_within_dist(black_box(&big_512x16), black_box(&small_16), black_box(0))
})
});
group.bench_function("all", |bencher| {
bencher.iter(|| {
bytes_array_all_within_dist(black_box(&big_512x16), black_box(&small_16), black_box(0))
})
});
group.finish();

let small_64 = pseudo_random_bytes(64, 3);
let big_16384x64 = pseudo_random_bytes(16_384 * 64, 4);
let mut group = c.benchmark_group("array_api/16384x64_random_no_match");
group.sample_size(30);
group.bench_function("first", |bencher| {
bencher.iter(|| {
bytes_array_first_within_dist(
black_box(&big_16384x64),
black_box(&small_64),
black_box(0),
)
})
});
group.bench_function("best", |bencher| {
bencher.iter(|| {
bytes_array_best_within_dist(
black_box(&big_16384x64),
black_box(&small_64),
black_box(0),
)
})
});
group.bench_function("all", |bencher| {
bencher.iter(|| {
bytes_array_all_within_dist(
black_box(&big_16384x64),
black_box(&small_64),
black_box(0),
)
})
});
group.finish();

let mut group = c.benchmark_group("array_api/parallel_boundary_64");
group.sample_size(30);
for num_elements in [4095usize, 4096, 65_536, 81_920, 131_072] {
let big = pseudo_random_bytes(num_elements * 64, 10 + num_elements as u64);
group.bench_function(format!("{num_elements} elements/all"), |bencher| {
bencher.iter(|| {
bytes_array_all_within_dist(black_box(&big), black_box(&small_64), black_box(0))
})
});
if num_elements >= 65_536 {
group.bench_function(format!("{num_elements} elements/best"), |bencher| {
bencher.iter(|| {
bytes_array_best_within_dist(
black_box(&big),
black_box(&small_64),
black_box(0),
)
})
});
}
}
group.finish();
}

#[cfg(target_arch = "aarch64")]
fn bench_hex_string_pack(c: &mut Criterion) {
// AArch64-only group for the packed NEON hex-string path.
Expand All @@ -213,6 +308,7 @@ criterion_group!(
bench_bytes_by_algo,
bench_bytes_within_dist,
bench_array_api,
bench_array_random_and_boundaries,
bench_hex_string_pack
);
#[cfg(not(target_arch = "aarch64"))]
Expand All @@ -221,6 +317,7 @@ criterion_group!(
bench_hex_by_algo,
bench_bytes_by_algo,
bench_bytes_within_dist,
bench_array_api
bench_array_api,
bench_array_random_and_boundaries
);
criterion_main!(benches);
48 changes: 36 additions & 12 deletions src/api.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
hamming_distance_bytes_dispatch, hamming_distance_string_dispatch, ALGO_CLASSIC, ALGO_NATIVE,
CURRENT_ALGO,
hamming_distance_bytes_dispatch, hamming_distance_string_dispatch,
select_bytes_kernel_for_width, BytesKernel, ALGO_CLASSIC, ALGO_NATIVE, CURRENT_ALGO,
};
#[cfg(target_arch = "x86_64")]
use crate::{ALGO_AVX2, ALGO_AVX512, ALGO_SSE41};
Expand All @@ -9,7 +9,7 @@ use rayon::prelude::*;
use std::sync::atomic::Ordering;

/// Minimum total byte size of big_array before we use rayon parallel paths.
const PAR_THRESHOLD_BYTES: usize = 256 * 1024;
const PAR_THRESHOLD_BYTES: usize = 5 * 1024 * 1024;
/// Keep byte-array scans to a small number of coarse jobs. More workers spend
/// more time scheduling these very small per-record calculations than running
/// them on current many-core CPUs.
Expand Down Expand Up @@ -108,16 +108,26 @@ pub fn bytes_array_first_within_dist(
// Parallelizing this requires a full non-short-circuiting scan to compute
// the minimum matching index, which is dramatically slower for early/mid
// matches and cannot beat serial for a match at index 0. Always go serial.
Ok(serial_first_within_dist(big_array, small_array, max_dist))
Ok(serial_first_within_dist(
big_array,
small_array,
max_dist,
select_bytes_kernel_for_width(small_array.len()),
))
}

#[inline]
fn serial_first_within_dist(big_array: &[u8], small_array: &[u8], max_dist: i64) -> Option<usize> {
fn serial_first_within_dist(
big_array: &[u8],
small_array: &[u8],
max_dist: i64,
kernel: BytesKernel,
) -> Option<usize> {
let elem_size = small_array.len();
let num_elements = big_array.len() / elem_size;
for i in 0..num_elements {
let chunk = &big_array[i * elem_size..(i + 1) * elem_size];
if hamming_distance_bytes_dispatch(chunk, small_array, max_dist) != u64::MAX {
if kernel(chunk, small_array, max_dist) != u64::MAX {
return Some(i);
}
}
Expand All @@ -138,8 +148,14 @@ pub fn bytes_array_best_within_dist(
if big_array.len() % small_array.len() != 0 {
return Err("array_of_elems size must be multiplier of elem_to_compare");
}
let kernel = select_bytes_kernel_for_width(small_array.len());
if big_array.len() < PAR_THRESHOLD_BYTES {
return Ok(serial_best_within_dist(big_array, small_array, max_dist));
return Ok(serial_best_within_dist(
big_array,
small_array,
max_dist,
kernel,
));
}
let elem_size = small_array.len();
let num_elements = big_array.len() / elem_size;
Expand All @@ -150,7 +166,7 @@ pub fn bytes_array_best_within_dist(
.with_max_len(1)
.map(|&(start, end)| {
let chunk = &big_array[start * elem_size..end * elem_size];
serial_best_within_dist(chunk, small_array, max_dist)
serial_best_within_dist(chunk, small_array, max_dist, kernel)
.map(|(distance, index)| (distance, index + start))
})
.reduce(|| None, merge_best))
Expand All @@ -170,6 +186,7 @@ fn serial_best_within_dist(
big_array: &[u8],
small_array: &[u8],
max_dist: i64,
kernel: BytesKernel,
) -> Option<(u64, usize)> {
let elem_size = small_array.len();
let num_elements = big_array.len() / elem_size;
Expand All @@ -179,7 +196,7 @@ fn serial_best_within_dist(
let threshold = best
.map(|(d, _)| (d as i64).saturating_sub(1))
.unwrap_or(max_dist);
let d = hamming_distance_bytes_dispatch(chunk, small_array, threshold);
let d = kernel(chunk, small_array, threshold);
if d == u64::MAX {
continue;
}
Expand Down Expand Up @@ -207,8 +224,14 @@ pub fn bytes_array_all_within_dist(
if big_array.len() % small_array.len() != 0 {
return Err("array_of_elems size must be multiplier of elem_to_compare");
}
let kernel = select_bytes_kernel_for_width(small_array.len());
if big_array.len() < PAR_THRESHOLD_BYTES {
return Ok(serial_all_within_dist(big_array, small_array, max_dist));
return Ok(serial_all_within_dist(
big_array,
small_array,
max_dist,
kernel,
));
}
let elem_size = small_array.len();
let num_elements = big_array.len() / elem_size;
Expand All @@ -218,7 +241,7 @@ pub fn bytes_array_all_within_dist(
.with_max_len(1)
.map(|&(start, end)| {
let chunk = &big_array[start * elem_size..end * elem_size];
serial_all_within_dist(chunk, small_array, max_dist)
serial_all_within_dist(chunk, small_array, max_dist, kernel)
.into_iter()
.map(|(distance, index)| (distance, index + start))
.collect()
Expand All @@ -240,13 +263,14 @@ fn serial_all_within_dist(
big_array: &[u8],
small_array: &[u8],
max_dist: i64,
kernel: BytesKernel,
) -> Vec<(u64, usize)> {
let elem_size = small_array.len();
let num_elements = big_array.len() / elem_size;
let mut results = Vec::new();
for i in 0..num_elements {
let chunk = &big_array[i * elem_size..(i + 1) * elem_size];
let d = hamming_distance_bytes_dispatch(chunk, small_array, max_dist);
let d = kernel(chunk, small_array, max_dist);
if d != u64::MAX {
results.push((d, i));
}
Expand Down
Loading
Loading