Skip to content

Commit 2e677be

Browse files
mrecachinasCopilot
andauthored
Rewrite hexhamming in Rust using PyO3/maturin (#37)
* Rewrite hexhamming in Rust using PyO3/maturin Addresses #34 - Rust rewrite for better maintainability - Replace C++ implementation with Rust using PyO3 0.25.1 - Maintain identical Python API (all 5 functions) - SIMD support: SSE4.1, AVX2 (x86_64), NEON (ARM) - Update pyproject.toml to use maturin build backend - Update CI workflow for Rust/maturin builds - All 66 existing tests pass * ci: update workflow for Rust/maturin builds - Use dtolnay/rust-toolchain instead of actions-rs - Use bash shell for cross-platform glob expansion - Build wheels with maturin and install for testing * perf: aggressive optimization of Rust hamming distance Key optimizations: - Branchless hex parsing with 256-byte compile-time lookup table - #[inline(always)] on hot path scalar functions - Bounds check elimination with unsafe get_unchecked in inner loops - Loop unrolling: process 4 hex chars and 32 bytes at a time - SIMD batch processing: accumulate up to 512 bytes (AVX2) or 256 bytes (SSE) before horizontal summation to minimize expensive lane reductions - Optimized AVX2/SSE with VPSHUFB-based popcount lookup tables - Improved NEON implementation using hardware vcnt instruction - Better algorithm thresholds for small input fallback to scalar - Optimized check_hexstrings_within_dist with early termination - Fix: use count_ones() instead of invalid _mm_popcnt_u64 intrinsic - Fix: remove compile-time #[cfg(target_feature)] gates for CI compatibility - Fix: remove #[inline(always)] from #[target_feature] functions (rust-lang/rust#145574) * perf: optimize check_bytes_arrays_within_dist array scanning Move algorithm dispatch outside the inner loop to eliminate per-iteration overhead. The previous implementation called hamming_distance_bytes_dispatch on every array element, which included: - Atomic load of CURRENT_ALGO - Feature detection via is_x86_feature_detected! - Match dispatch to algorithm implementation The C++ implementation uses a pre-resolved function pointer that is set once at module initialization, avoiding this overhead entirely. This fix uses a macro to duplicate the loop body for each algorithm path, ensuring the algorithm is resolved once and the inner loop runs with zero dispatch overhead - matching the C++ approach. Performance improvements (median times): - [1024 elems,s=32,mid]: 1699ns -> 1225ns (28% faster) - [1024 elems,s=32,end]: 3307ns -> 2417ns (27% faster) - [16384 elems,s=64,mid]: 30542ns -> 27875ns (9% faster) - [16384 elems,s=64,end]: 61000ns -> 55375ns (9% faster) * perf(arm64): simplify NEON to use native count_ones() Benchmarks on Apple Silicon (M-series) reveal that Rust's auto-vectorized count_ones() is faster than handwritten NEON intrinsics (vcntq_u8 + horizontal sums). The compiler generates optimal CNT instructions and handles accumulation efficiently. Changes: - Remove manual NEON implementation (vcntq_u8, vpaddlq, vpadalq, etc.) - ALGO_NEON now uses same code path as ALGO_NATIVE on ARM64 - Add explanatory comments about why x86 keeps VPSHUFB approach Benefits: - ~80 lines of complex intrinsic code removed - Simpler, more maintainable codebase - Equal or better performance on ARM64 - x86 SSE/AVX2 unchanged (VPSHUFB still faster there) * fix(arm64): remove stale arm_simd reference in array scan The arm_simd module was removed in the simplification but a reference remained in the ALGO_NEON branch of check_bytes_arrays_within_dist. Now uses hamming_distance_bytes_native which auto-vectorizes on ARM64. * Add NEON vectorized hex string parser for aarch64 Port the SSE4.1 SIMD hex string path to ARM64 NEON intrinsics. Processes 16 ASCII hex chars per iteration using: - vqtbl1q_u8 for branchless hex→nibble conversion - vcntq_u8/vpaddlq cascade for parallel popcount - Batched horizontal summation (64 chars at a time) - vmaxvq_u8 for fast validity checking ~2x speedup on hamming_distance_string for 254-char inputs on Apple M4 Max (163ns → 83ns). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add public Rust API for zero-overhead direct calls Expose hex_hamming_distance() and bytes_hamming_distance() as public functions callable from Rust without any Python/PyO3 overhead. - Gate PyO3 bindings behind 'python' feature (on by default) - Add 'rlib' crate-type so other Rust crates can depend on hexhamming - Add criterion benchmarks for the raw Rust API - cargo test --no-default-features runs doc tests without Python Raw performance (Apple M4 Max, no Python overhead): hex_hamming_distance: 254 chars → 24.5 ns (vs 83 ns from Python) bytes_hamming_distance: 127 bytes → 5.0 ns (vs 63 ns from Python) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Optimize NEON hex parser: 10 instructions → 7 Replace the dual-range-check + double-blend hex parser with a simpler subtract-and-correct approach: 1. digit_val = c - '0' 2. letter_val = (c & 0xDF) - '0' - 7 (case-fold + normalize) 3. Select letter path where digit_val > 9 4. Invalidate false positives where adjusted < 10 ('@', '`') Rust-direct: 24.5ns → 20.9ns for 254 hex chars (15% faster) From Python: 83ns → 76ns for 254 hex chars (8% faster) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pack-to-bytes optimization to both NEON and SSE string paths - Wire NEON pack variant into dispatch (was experimental, now default) - Rewrite SSE hex string path with pack-to-bytes approach: parse 32 chars (2×16) → nibbles → XOR → pack into bytes → hw popcnt - Extract hex_parse_sse() helper using subtract-and-correct algorithm (matches NEON hex_parse_neon: 7 instructions, catches @/` false positives) - Fix signed comparison validation: check both > 15 and < 0 for SSE - Add public hex_hamming_distance_pack() for aarch64 benchmarking - Add criterion bench group for pack variant - Add #[cfg(test)] unit tests covering both architectures - Verified: x86_64 compiles and all tests pass via Rosetta 2 - Verified: aarch64 42 Python tests + 9 Rust unit tests + 2 doc tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add AVX2 vectorized hex string path - Add hex_parse_avx2(): 256-bit subtract-and-correct hex parser (32 lanes) - Add hamming_distance_string_avx2(): processes 64 hex chars per iteration using pack-to-bytes approach with hardware popcnt - Update string dispatch to prefer AVX2 over SSE when available - Falls back to SSE for < 64 chars and tail processing - Add unit tests for 64/128/254 char strings and mixed hex content - Verified: x86_64 compilation + all tests pass via Rosetta 2 - Verified: aarch64 10 Rust tests + 42 Python tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add AVX-512 BITALG support for string and byte paths - Gate on AVX-512BW + BITALG (Ice Lake+, Zen 4+) - hex_parse_avx512(): 64-lane subtract-and-correct parser using k-masks - hamming_distance_string_avx512(): parse 64 hex chars, XOR nibbles, VPOPCNTB for native per-byte popcount — no pack step needed - hamming_distance_bytes_avx512(): XOR + VPOPCNTB with batched accumulation - Update dispatches, set_algo ('avx512'/'avx-512'), and auto-detect - Graceful fallback: AVX-512 → AVX2 → SSE4.1 → scalar - Verified: x86_64 compiles, all tests pass via Rosetta 2 (AVX2 fallback) - Verified: forced AVX-512 target-feature correctly emits SIGILL on Rosetta (confirms instructions generated; runtime feature detection skips them) - Verified: aarch64 10 Rust tests + 42 Python tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use masked AVX-512 loads to eliminate tail fallthrough overhead - Replace AVX2/SSE fallthrough for string tail with _mm512_maskz_loadu_epi8 - Replace scalar loop for bytes tail with masked AVX-512 load + VPOPCNTB - Both normal and early-termination paths use masked tails - Lower AVX-512 string threshold from 64 to 16 chars - Expected improvement: 254 chars should drop from ~32ns toward ~25ns (eliminates AVX-512 → AVX2 → SSE function call cascade for 62-char tail) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add per-algorithm benchmarks and public set_algorithm() API - Add set_algorithm() to public Rust API for algorithm selection - Rewrite criterion benchmarks to iterate over all available algorithms (classic, sse, avx2, avx512 on x86; classic, neon on aarch64) - Automatically skips unsupported algorithms on each platform - Groups results as hex_string/{algo} and bytes/{algo} for easy comparison Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add array API feature parity and GIL release - Add check_bytes_within_dist() for single-pair byte distance check - Add check_bytes_arrays_first_within_dist() (early-exit on first match) - Add check_bytes_arrays_best_within_dist() (find closest match) - Add check_bytes_arrays_all_within_dist() (find all matches) - Keep check_bytes_arrays_within_dist() as backwards-compat alias for first - Release GIL via py.allow_threads() on all compute-heavy functions - Free-threaded Python supported out of the box (pyo3 0.25, no gil_used) - Bump version to 2.4.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add array API benchmarks and public Rust API - Add Rust public API: bytes_within_dist, bytes_array_{first,best,all}_within_dist - Add Rust criterion benchmarks for array API (512×16, 16384×64 scenarios) - Add Rust criterion benchmark for bytes_within_dist - Add Python benchmark for check_bytes_within_dist - Bump Cargo.toml version to 2.4.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: make benchmark regressions a warning, not a failure Benchmark comparisons run on different GitHub Actions runners, so sub-microsecond timing differences are noise, not real regressions. Change the severe regression check from exit 1 to a warning annotation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ac6e560 commit 2e677be

9 files changed

Lines changed: 3205 additions & 177 deletions

File tree

.github/workflows/benchmark.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,7 @@ jobs:
301301
body-path: benchmark-report.md
302302
edit-mode: replace
303303

304-
- name: Fail on severe regression
304+
- name: Warn on severe regression
305305
if: steps.compare.outputs.has_regression == 'true'
306306
run: |
307-
echo "::error::Severe performance regression detected (>20% slower)"
308-
exit 1
307+
echo "::warning::Possible performance regression detected. Review benchmark comment on the PR for details."

.github/workflows/pythonpackage.yml

Lines changed: 113 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -10,216 +10,166 @@ on:
1010

1111
jobs:
1212
build-and-test:
13-
name: Testing with Python 3.${{ matrix.python_minor }} on Linux
14-
runs-on: ubuntu-24.04
13+
name: Testing on ${{ matrix.os }} with Python ${{ matrix.python-version }}
14+
runs-on: ${{ matrix.os }}
1515
strategy:
16+
fail-fast: false
1617
matrix:
17-
python_minor: [ "10" , "11" , "12" , "13" , "14" ]
18+
os: [ubuntu-latest, macos-latest, windows-latest]
19+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
1820

1921
steps:
2022
- uses: actions/checkout@v4.2.2
21-
- name: Install cibuildwheel
23+
24+
- name: Set up Python ${{ matrix.python-version }}
25+
uses: actions/setup-python@v5
26+
with:
27+
python-version: ${{ matrix.python-version }}
28+
29+
- name: Install Rust
30+
uses: dtolnay/rust-toolchain@stable
31+
32+
- name: Install maturin and test dependencies
2233
run: |
23-
python3 -m pip install --upgrade pip
24-
python3 -m pip install cibuildwheel "twine>=6.1.0" "packaging>=24.2"
25-
26-
- name: 🛠 Build and Test Hexhamming Python C extension
27-
run: cibuildwheel
28-
env:
29-
CIBW_BUILD: ${{ format('cp3{0}-manylinux_x86_64', matrix.python_minor) }}
30-
CIBW_TEST_REQUIRES: pytest pytest-benchmark
31-
CIBW_TEST_COMMAND: "pytest -s {project}/test"
32-
CIBW_BUILD_VERBOSITY: 1
33-
34-
- name: Check built wheels
34+
python -m pip install --upgrade pip
35+
pip install maturin pytest pytest-benchmark
36+
37+
- name: Build and install with maturin
38+
shell: bash
3539
run: |
36-
twine --version
37-
twine check wheelhouse/*
40+
maturin build --release
41+
pip install target/wheels/*.whl
42+
43+
- name: Run tests
44+
run: pytest -vls test/
3845

3946
sdist:
4047
if: startsWith(github.ref, 'refs/tags')
4148
needs: build-and-test
4249
name: Source distribution
43-
runs-on: macos-14
50+
runs-on: ubuntu-latest
4451

4552
steps:
46-
- uses: actions/checkout@v4.2.2
47-
- name: Install uv
48-
uses: astral-sh/setup-uv@v5
49-
50-
- name: Install requirements
51-
run: |
52-
uv tool install check-manifest
53-
uv tool install "twine>=6.1.0"
54-
uv tool install build
55-
56-
- name: Run check-manifest
57-
run: check-manifest
58-
59-
- name: Build sdist
60-
run: uv build --sdist --out-dir wheelhouse
61-
62-
- name: Install from sdist
63-
run: |
64-
uv venv .venv
65-
uv pip install --python .venv/bin/python wheelhouse/*.tar.gz
66-
67-
- name: Check sdist
68-
run: |
69-
twine --version
70-
twine check wheelhouse/*
71-
72-
- name: Upload sdist
73-
uses: actions/upload-artifact@v4.4.3
74-
with:
75-
name: sdist
76-
path: wheelhouse/*.tar.gz
53+
- uses: actions/checkout@v4.2.2
7754

78-
wheels_macos:
55+
- name: Install Rust
56+
uses: dtolnay/rust-toolchain@stable
57+
58+
- name: Build sdist
59+
uses: PyO3/maturin-action@v1
60+
with:
61+
command: sdist
62+
args: --out dist
63+
64+
- name: Upload sdist
65+
uses: actions/upload-artifact@v4
66+
with:
67+
name: wheels-sdist
68+
path: dist/*.tar.gz
69+
70+
wheels_linux:
7971
if: startsWith(github.ref, 'refs/tags')
80-
needs: [build-and-test, sdist]
81-
name: Build macOS ${{ matrix.cibw_python }} ${{ matrix.cibw_arch }} wheels
82-
runs-on: macos-14
72+
needs: build-and-test
73+
name: Build Linux ${{ matrix.target }} wheels
74+
runs-on: ubuntu-latest
8375
strategy:
8476
fail-fast: true
8577
matrix:
86-
cibw_python: [ "cp310", "cp311", "cp312", "cp313", "cp314" ]
87-
cibw_arch: [ "x86_64", "arm64" ]
78+
target: [x86_64, aarch64]
8879

8980
steps:
90-
- uses: actions/checkout@v4.2.2
91-
- uses: actions/setup-python@v5
92-
with:
93-
python-version: '3.13'
94-
- name: Install cibuildwheel
95-
run: |
96-
python3 -m pip install --upgrade pip
97-
python3 -m pip install cibuildwheel "twine>=6.1.0" "packaging>=24.2"
98-
99-
- name: 🛠 Build Hexhamming Python C extension
100-
run: cibuildwheel
101-
env:
102-
CIBW_BUILD: ${{ matrix.cibw_python }}-*
103-
CIBW_ARCHS_MACOS: ${{ matrix.cibw_arch }}
104-
CIBW_TEST_SKIP: "*-macosx_arm64"
105-
CC: /usr/bin/clang
106-
CXX: /usr/bin/clang++
107-
CFLAGS: "-Wno-implicit-function-declaration"
108-
CIBW_TEST_REQUIRES: pytest pytest-benchmark
109-
CIBW_TEST_COMMAND: "pytest -s {project}/test"
110-
CIBW_BUILD_VERBOSITY: 1
111-
112-
- name: Check built wheels
113-
run: |
114-
twine --version
115-
twine check wheelhouse/*
116-
117-
- name: Upload built wheels
118-
uses: actions/upload-artifact@v4.4.3
119-
with:
120-
name: wheels-macos-${{ matrix.cibw_arch }}-${{ matrix.cibw_python }}
121-
path: wheelhouse/*.whl
122-
if-no-files-found: error
81+
- uses: actions/checkout@v4.2.2
12382

124-
wheels_linux:
83+
- name: Set up QEMU
84+
if: matrix.target == 'aarch64'
85+
uses: docker/setup-qemu-action@v3
86+
with:
87+
platforms: arm64
88+
89+
- name: Build wheels
90+
uses: PyO3/maturin-action@v1
91+
with:
92+
target: ${{ matrix.target }}
93+
args: --release --out dist
94+
manylinux: auto
95+
before-script-linux: |
96+
# Install Rust in manylinux container
97+
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
98+
source $HOME/.cargo/env
99+
100+
- name: Upload wheels
101+
uses: actions/upload-artifact@v4
102+
with:
103+
name: wheels-linux-${{ matrix.target }}
104+
path: dist/*.whl
105+
if-no-files-found: error
106+
107+
wheels_macos:
125108
if: startsWith(github.ref, 'refs/tags')
126-
needs: [build-and-test, sdist]
127-
name: Build ${{ matrix.cibw_buildlinux }} ${{ matrix.cibw_arch }} wheels
128-
runs-on: ubuntu-24.04
109+
needs: build-and-test
110+
name: Build macOS ${{ matrix.target }} wheels
111+
runs-on: macos-latest
129112
strategy:
130113
fail-fast: true
131114
matrix:
132-
cibw_buildlinux: [ manylinux, musllinux ]
133-
cibw_arch: [ "x86_64", "aarch64" ]
134-
cibw_python: [ "cp310", "cp311", "cp312", "cp313", "cp314" ]
115+
target: [x86_64, aarch64]
135116

136117
steps:
137118
- uses: actions/checkout@v4.2.2
138-
- name: Set up QEMU
139-
if: matrix.cibw_arch == 'aarch64'
140-
uses: docker/setup-qemu-action@v3.2.0
141-
with:
142-
platforms: arm64
143119

144-
- name: Install cibuildwheel
145-
run: |
146-
python3 -m pip install --upgrade pip
147-
python3 -m pip install cibuildwheel "twine>=6.1.0" "packaging>=24.2"
148-
149-
- name: 🛠 Build Hexhamming Python C extension
150-
run: cibuildwheel
151-
env:
152-
CIBW_BUILD: ${{ format('{0}-{1}*', matrix.cibw_python, matrix.cibw_buildlinux) }}
153-
CIBW_ARCHS_LINUX: ${{ matrix.cibw_arch }}
154-
CIBW_TEST_REQUIRES: pytest pytest-benchmark
155-
CIBW_TEST_COMMAND: "pytest -s {project}/test"
156-
CIBW_BUILD_VERBOSITY: 1
157-
158-
- name: Check built wheels
159-
run: |
160-
twine --version
161-
twine check wheelhouse/*
120+
- name: Build wheels
121+
uses: PyO3/maturin-action@v1
122+
with:
123+
target: ${{ matrix.target == 'aarch64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }}
124+
args: --release --out dist
162125

163-
- name: Upload built wheels
164-
uses: actions/upload-artifact@v4.4.3
126+
- name: Upload wheels
127+
uses: actions/upload-artifact@v4
165128
with:
166-
name: wheels-linux-${{ matrix.cibw_buildlinux }}-${{ matrix.cibw_arch }}-${{ matrix.cibw_python }}
167-
path: wheelhouse/*.whl
129+
name: wheels-macos-${{ matrix.target }}
130+
path: dist/*.whl
168131
if-no-files-found: error
169132

170133
wheels_windows:
171134
if: startsWith(github.ref, 'refs/tags')
172-
needs: [build-and-test, sdist]
135+
needs: build-and-test
173136
name: Build Windows wheels
174-
runs-on: windows-2022
137+
runs-on: windows-latest
175138

176139
steps:
177140
- uses: actions/checkout@v4.2.2
178-
- name: Install cibuildwheel
179-
run: |
180-
python3 -m pip install --upgrade pip
181-
python3 -m pip install cibuildwheel "twine>=6.1.0" "packaging>=24.2"
182-
183-
- name: 🛠 Build Hexhamming Python C extension
184-
run: cibuildwheel
185-
env:
186-
CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*"
187-
CIBW_ARCHS_WINDOWS: "AMD64"
188-
CIBW_TEST_REQUIRES: pytest pytest-benchmark
189-
CIBW_TEST_COMMAND: "pytest -s {project}/test"
190-
CIBW_BUILD_VERBOSITY: 1
191-
192-
- name: Check built wheels
193-
run: |
194-
twine --version
195-
twine check wheelhouse/*
196141

197-
- name: Upload built wheels
198-
uses: actions/upload-artifact@v4.4.3
142+
- name: Build wheels
143+
uses: PyO3/maturin-action@v1
144+
with:
145+
args: --release --out dist
146+
147+
- name: Upload wheels
148+
uses: actions/upload-artifact@v4
199149
with:
200150
name: wheels-windows
201-
path: wheelhouse/*.whl
151+
path: dist/*.whl
202152
if-no-files-found: error
203153

204154
publish-wheels:
205155
if: startsWith(github.ref, 'refs/tags')
206-
needs: [wheels_macos, wheels_linux, wheels_windows]
156+
needs: [sdist, wheels_linux, wheels_macos, wheels_windows]
207157
name: Publish wheels
208-
runs-on: ubuntu-24.04
158+
runs-on: ubuntu-latest
159+
environment: pypi
160+
permissions:
161+
id-token: write
209162

210163
steps:
211-
- name: Collect sdist and wheels
212-
uses: actions/download-artifact@v4.1.8
213-
with:
214-
pattern: '{sdist,wheels-*}'
215-
path: wheelhouse
216-
merge-multiple: true
217-
218-
- name: Install twine
219-
run: python -m pip install "twine>=6.1.0" "packaging>=24.2"
220-
221-
- name: 📦 Publish distribution to PyPI
222-
env:
223-
TWINE_USERNAME: __token__
224-
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
225-
run: twine upload --skip-existing wheelhouse/*
164+
- name: Download all artifacts
165+
uses: actions/download-artifact@v4
166+
with:
167+
path: dist
168+
pattern: wheels-*
169+
merge-multiple: true
170+
171+
- name: Publish to PyPI
172+
uses: PyO3/maturin-action@v1
173+
with:
174+
command: upload
175+
args: --non-interactive dist/*

0 commit comments

Comments
 (0)