bitnuc
A library for efficient nucleotide sequence manipulation using 2-bit encoding.
Summary
This a SIMD-accelerated two-bit encoding library for nucleotide sequences. It is meant to have fast encode/decode routines for small and large sequences and to provide a fairly unstructured interface for working with nucleotide sequences in memory.
It provides:
- 2-bit nucleotide encoding (A=00, C=01, G=10, T=11) with SIMD dispatched at runtime via
fearless_simd - A little-endian-pinned
u64kmer boundary (as_2bit/from_2bit) for hashing and fixed-width integer storage of sequences up to 32 bases - Ambiguous base detection (
ambiguous_bases) for tracking non-ACGTacgtbases
Encoded Format
Sequences encode to plain bytes: byte k holds bases 4k..4k+4, base j at
bits 2*(j % 4) of its byte. A sequence of n bases occupies exactly
n.div_ceil(4) bytes and trailing pad bits are always zero.
Input Sequence: [A][C][G][T]
Output Buffer: [11 10 01 00]
[]: byte boundary
Bit pairs are MSB-first: base j occupies bits 2j..2j+1,
so the first base is the rightmost pair (A=00).
Note: Encoding is lossy for bytes outside
ACGTacgt: invalid bases map to an unspecified code rather than an error. If you need to preserve ambiguous bases, detect and track them separately (seeambiguous_bases).
Encoding and Decoding
The core functions operate on byte slices, with _resize variants that
manage the buffer length for you:
use ;
The slice-based variants write into caller-provided buffers, which lets consumers control allocation and padding (e.g. file formats that pad encoded sequences to 8-byte words):
use ;
u64 Kmer Packing
For hashing and fixed-width storage of short sequences (barcodes, UMIs,
k-mers up to 32 bases), as_2bit and from_2bit pack to and from a u64.
Note: These are pinned to little-endian internally.
use ;
use HashMap;
Packed kmers can be compared with hdist_scalar:
use ;
Identifying ambiguous bases
Ambiguous bases (non-ACGT) bases are unable to be represented with this two-bit encoding scheme.
The encoding algorithm also remaps lowercase to upper case (so acgt -> ACGT internally).
Use ambiguous_bases to track all positions of unrepresentable nucleotides.
The position buffer is generic over its element type (usize, u64, or u32):
use ambiguous_bases;
Note:
ambiguous_basesonly tracks non-ACGTacgtbases. It does not identify lowercase letters which are also not representable but which are remapped to their uppercase variants through encoding/decoding.
Memory Usage
The 2-bit encoding provides significant memory savings:
Standard encoding: 1 byte per base
ACGT = 4 bytes = 32 bits
2-bit encoding: 2 bits per base
ACGT = 1 byte = 8 bits
Performance
Throughput by sequence length, measured on an Apple M3 Pro with
target-cpu=native (criterion mean, 1 byte per base):
| bp | encode (GB/s) | decode (GB/s) |
|---|---|---|
| 10 | 2.3 | 2.2 |
| 100 | 12.5 | 13.8 |
| 1000 | 35.4 | 29.7 |
| 10000 | 38.2 | 34.0 |
| 100000 | 38.6 | 33.5 |
To regenerate the table on your machine:
RUSTFLAGS="-C target-cpu=native"
SIMD Acceleration
The 2-bit encode and decode are SIMD accelerated via
fearless_simd, with the instruction set
(NEON, SSE, AVX2, AVX-512) selected at runtime.
Related Work
I highly recommend checking out packed-seq. They are currently the highest performance 2-bit encoding library in Rust as far as I can tell. They follow a different bit-packing scheme than this library and they can shave off a few instructions in their SIMD routines.
If you're interested in 2-bit encoding in general make sure to check out cute-nucleotides which has an excellent overview of different algorithms and their performance characteristics.