Skip to content

Commit 9b5f819

Browse files
committed
performance improvements
1 parent 2d6cd40 commit 9b5f819

15 files changed

Lines changed: 269 additions & 178 deletions

File tree

.github/workflows/release.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,23 @@ jobs:
6363
- uses: PyO3/maturin-action@v1
6464
with:
6565
command: build
66+
# Nightly toolchain: the published wheels enable `nj/simd`, which uses
67+
# the unstable `portable_simd` feature for the explicit core::simd
68+
# distance kernel (~5-7x faster distance computation than scalar).
69+
# End users install the prebuilt wheel and never need nightly themselves.
70+
# Pinned for reproducible releases — keep in sync with the `simd-nightly`
71+
# job in test.yml; bump both together. The sdist (built on stable) keeps
72+
# the autovectorized scalar kernel, so source installs need no nightly.
73+
rust-toolchain: nightly-2026-06-02
74+
# `--features` is listed in full (not just the simd delta) so the build is
75+
# correct whether maturin merges with or replaces `[tool.maturin] features`.
6676
# The crate is built with pyo3's `abi3-py310` feature, so a single
6777
# stable-ABI wheel per platform covers CPython 3.10+ — no per-minor
6878
# interpreter list needed.
6979
args: >
7080
--release
7181
--out dist
82+
--features pyo3/extension-module,nj/parallel,nj/simd
7283
target: ${{ matrix.target }}
7384
manylinux: ${{ matrix.manylinux }}
7485
working-directory: python

.github/workflows/test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@ jobs:
3232
- run: make test
3333
working-directory: nj
3434

35+
simd-nightly:
36+
name: SIMD kernel (nightly)
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
# The published PyPI wheels enable `nj/simd` (the explicit core::simd
41+
# `portable_simd` kernel). This job guards that path against bit-rot and
42+
# confirms it stays bit-identical to the scalar kernel (same unit/proptest/
43+
# golden assertions). Pinned to match the wheel build in release.yml — bump
44+
# both together.
45+
- uses: dtolnay/rust-toolchain@master
46+
with:
47+
toolchain: nightly-2026-06-02
48+
- uses: Swatinem/rust-cache@v2
49+
with:
50+
key: nightly-simd
51+
- run: cargo test --features simd
52+
working-directory: nj
53+
3554
wasm:
3655
name: WASM tests
3756
runs-on: ubuntu-latest

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ cargo install nj --features cli
2020

2121
nj sequences.fasta
2222
nj --substitution-model kimura2-p --n-bootstrap-samples 100 sequences.fasta > tree.nwk
23+
24+
# Bootstrap and distance computation run in parallel by default (the `cli`
25+
# feature enables threading). Cap the worker count with -t/--num-threads:
26+
nj -t 4 --n-bootstrap-samples 1000 sequences.fasta > tree.nwk
2327
```
2428

2529
A progress bar is shown on stderr when bootstrapping.

nj/Cargo.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,15 @@ optional = true
5858
# default build only builds the library
5959
default = []
6060

61-
# feature to build CLI
62-
cli = ["clap", "indicatif", "serde_json"]
61+
# feature to build CLI. Implies `parallel` so the shipped binary
62+
# (`cargo install nj --features cli`) is multi-threaded by default; the bare
63+
# library stays lightweight and Rayon-free unless `parallel` is requested.
64+
cli = ["clap", "indicatif", "serde_json", "parallel"]
6365

6466
# feature to enable parallel bootstrap and distance matrix computation via Rayon
6567
parallel = ["rayon"]
68+
69+
# feature to enable the explicit core::simd distance kernel. Requires a nightly
70+
# toolchain (uses the unstable `portable_simd` feature). Without it, the kernel
71+
# uses the branchless scalar path, which the compiler still autovectorizes.
72+
simd = []

nj/benches/nj_bench.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ fn bench_distance_matrix(c: &mut Criterion) {
7373
group.finish();
7474
}
7575

76+
/// Wide-alignment regime (large L, modest n) where the O(n²·L) distance kernel
77+
/// dominates — the case explicit/auto SIMD on the kernel targets.
78+
fn bench_distance_matrix_wide(c: &mut Criterion) {
79+
let mut group = c.benchmark_group("distance_matrix_wide");
80+
let msa = synthetic_dna(100, 5000);
81+
group.bench_with_input(BenchmarkId::from_parameter(100), &msa, |b, msa| {
82+
b.iter(|| distance_matrix(black_box(dist_config(msa.clone()))).unwrap());
83+
});
84+
group.finish();
85+
}
86+
7687
fn bench_nj(c: &mut Criterion) {
7788
let mut group = c.benchmark_group("nj");
7889
for &n in &[25usize, 100, 250] {
@@ -95,5 +106,11 @@ fn bench_bootstrap(c: &mut Criterion) {
95106
group.finish();
96107
}
97108

98-
criterion_group!(benches, bench_distance_matrix, bench_nj, bench_bootstrap);
109+
criterion_group!(
110+
benches,
111+
bench_distance_matrix,
112+
bench_distance_matrix_wide,
113+
bench_nj,
114+
bench_bootstrap
115+
);
99116
criterion_main!(benches);

nj/src/alphabet.rs

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,15 @@ use serde::{Deserialize, Serialize};
1818
/// [`crate::msa::MSA`] can be parameterised over any alphabet without
1919
/// duplicating logic.
2020
pub trait AlphabetEncoding {
21+
/// Encoded symbol type. Implementors must be `#[repr(u8)]` (size 1, align 1)
22+
/// so that a `&[Self::Symbol]` can be reinterpreted as `&[u8]` by
23+
/// [`as_bytes`](AlphabetEncoding::as_bytes) for the byte-level distance kernel.
2124
type Symbol: Copy;
2225

26+
/// Byte value of the gap symbol (`Self::Symbol::Gap as u8`). Used by the
27+
/// branchless / SIMD distance kernel for gap detection without enum matching.
28+
const GAP_BYTE: u8;
29+
2330
/// Encodes a byte into the corresponding symbol in the alphabet.
2431
fn encode(symbol: u8) -> Self::Symbol;
2532

@@ -28,6 +35,18 @@ pub trait AlphabetEncoding {
2835

2936
/// Returns `true` if `symbol` represents a gap (`-`).
3037
fn is_gap(symbol: Self::Symbol) -> bool;
38+
39+
/// Reinterprets an encoded sequence as a raw byte slice.
40+
///
41+
/// The default implementation relies on the trait invariant that
42+
/// `Self::Symbol` is `#[repr(u8)]`, so a slice of symbols has the exact same
43+
/// layout as a `&[u8]` of equal length. All built-in alphabets uphold this.
44+
fn as_bytes(seq: &[Self::Symbol]) -> &[u8] {
45+
debug_assert_eq!(core::mem::size_of::<Self::Symbol>(), 1);
46+
// SAFETY: `Self::Symbol` is `#[repr(u8)]` (1 byte, align 1), so the slice
47+
// has identical layout to `&[u8]` of the same length.
48+
unsafe { core::slice::from_raw_parts(seq.as_ptr() as *const u8, seq.len()) }
49+
}
3150
}
3251

3352
/// A single nucleotide in the DNA alphabet.
@@ -36,13 +55,14 @@ pub trait AlphabetEncoding {
3655
/// character in aligned sequences. Any byte not matching `A/C/G/T/N/-`
3756
/// (case-insensitive) is mapped to `N`.
3857
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58+
#[repr(u8)]
3959
pub enum DnaSymbol {
40-
A,
41-
C,
42-
G,
43-
T,
44-
N,
45-
Gap,
60+
A = 0,
61+
C = 1,
62+
G = 2,
63+
T = 3,
64+
N = 4,
65+
Gap = 5,
4666
}
4767

4868
/// Marker struct for the DNA alphabet.
@@ -54,6 +74,8 @@ pub struct DNA;
5474
impl AlphabetEncoding for DNA {
5575
type Symbol = DnaSymbol;
5676

77+
const GAP_BYTE: u8 = DnaSymbol::Gap as u8;
78+
5779
fn encode(symbol: u8) -> Self::Symbol {
5880
match symbol {
5981
b'A' | b'a' => DnaSymbol::A,
@@ -88,29 +110,30 @@ impl AlphabetEncoding for DNA {
88110
/// (the `-` alignment character). Encoding is case-insensitive; any byte that
89111
/// does not match a known amino acid letter is mapped to `X`.
90112
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113+
#[repr(u8)]
91114
pub enum ProteinSymbol {
92-
A,
93-
R,
94-
N,
95-
D,
96-
C,
97-
Q,
98-
E,
99-
G,
100-
H,
101-
I,
102-
L,
103-
K,
104-
M,
105-
F,
106-
P,
107-
S,
108-
T,
109-
W,
110-
Y,
111-
V,
112-
X,
113-
Gap,
115+
A = 0,
116+
R = 1,
117+
N = 2,
118+
D = 3,
119+
C = 4,
120+
Q = 5,
121+
E = 6,
122+
G = 7,
123+
H = 8,
124+
I = 9,
125+
L = 10,
126+
K = 11,
127+
M = 12,
128+
F = 13,
129+
P = 14,
130+
S = 15,
131+
T = 16,
132+
W = 17,
133+
Y = 18,
134+
V = 19,
135+
X = 20,
136+
Gap = 21,
114137
}
115138

116139
/// Marker struct for the protein alphabet.
@@ -122,6 +145,8 @@ pub struct Protein;
122145
impl AlphabetEncoding for Protein {
123146
type Symbol = ProteinSymbol;
124147

148+
const GAP_BYTE: u8 = ProteinSymbol::Gap as u8;
149+
125150
fn encode(symbol: u8) -> Self::Symbol {
126151
// Match on the uppercased byte so lowercase residues encode identically
127152
// (consistent with `DNA::encode` and `detect_alphabet`, which both

nj/src/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ impl SequenceObject {
3232
pub fn len(&self) -> usize {
3333
self.sequence.len()
3434
}
35+
36+
/// Returns `true` if the sequence is empty.
37+
pub fn is_empty(&self) -> bool {
38+
self.sequence.is_empty()
39+
}
3540
}
3641

3742
/// Configuration for distance-only computation (no NJ, no bootstrap).

nj/src/distance_matrix.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ mod tests {
260260
fn test_dist_from_msa_no_overlap() {
261261
// no non-gap mismatches are counted, so the distance remains 0.0
262262
let seqs = vec![("X".into(), "A--".into()), ("Y".into(), "--A".into())];
263-
let msa = MSA::<DNA>::from_iter(seqs.into_iter());
263+
let msa = MSA::<DNA>::from_iter(seqs);
264264
let mat = msa.into_dist::<PDiff>();
265265
assert_eq!(mat.names, vec!["X", "Y"]);
266266
assert!((mat.get(0, 1) - 0.0).abs() < 1e-12);
@@ -329,7 +329,7 @@ mod tests {
329329
// ensure non-negative branch lengths in the produced tree (clamping is applied in implementation)
330330
fn check_nonneg(node: &TreeNode) {
331331
match &node.children {
332-
None => return,
332+
None => (),
333333
Some(children) => {
334334
assert!(children[0].len.unwrap() >= -1e-12, "left_len negative");
335335
assert!(children[1].len.unwrap() >= -1e-12, "right_len negative");

nj/src/lib.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// The explicit `core::simd` distance kernel needs the nightly `portable_simd`
2+
// feature. Gated on the `simd` Cargo feature so default (stable) builds are
3+
// unaffected — the kernel falls back to the autovectorizable scalar path.
4+
#![cfg_attr(feature = "simd", feature(portable_simd))]
15
//! Neighbor-Joining phylogenetic tree inference library.
26
//!
37
//! # Data flow
@@ -313,11 +317,12 @@ fn add_bootstrap_to_tree(
313317
bitset_of(node, idx, &mut bv)?;
314318

315319
let n = bv.count_ones();
316-
if n > 1 && n < n_taxa {
317-
if let Some(c) = counts.get(&bv.as_raw_slice().to_vec()) {
318-
let pct = c * 100 / n_bootstrap_samples;
319-
node.label = Some(NameOrSupport::Support(pct));
320-
}
320+
if n > 1
321+
&& n < n_taxa
322+
&& let Some(c) = counts.get(&bv.as_raw_slice().to_vec())
323+
{
324+
let pct = c * 100 / n_bootstrap_samples;
325+
node.label = Some(NameOrSupport::Support(pct));
321326
}
322327

323328
if let Some([l, r]) = &mut node.children {

nj/src/main.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ mod cli {
6262
#[arg(long, default_value_t = false)]
6363
pub average_distance: bool,
6464

65-
/// Number of threads to use for parallel computation (default: all available).
66-
/// Only effective when built with the `parallel` feature.
65+
/// Number of threads for parallel bootstrap and distance computation
66+
/// (default: all available cores). The CLI is always built with
67+
/// threading enabled, so this flag is always effective.
6768
#[arg(short = 't', long, value_name = "N")]
6869
pub num_threads: Option<usize>,
6970

0 commit comments

Comments
 (0)