Typed combinator algebra over machine words. A register is an 8- to 128-element container; an instruction (POPCNT, TZCNT, the carry chain, PEXT/PDEP, PCLMULQDQ) is a combinator; a kernel is a composition. The crate names the combinators, restricts them to the domains where they are lawful, and exports the laws they obey so downstream code can property-test its own carriers and backends.
Named after HAKMEM (MIT AI Memo 239, 1972), items 161–180 of which are the first catalogue of these sentences.
use ;
// Runs: bit p set iff bits p..p+3 are all set (Hacker's Delight 6-5).
let x: u64 = 0b0111_0110;
assert_eq!;
// Rank / select / positions: succinct-structure primitives, O(1).
assert_eq!;
assert_eq!;
assert_eq!;
// Scans: prefix XOR turns quote toggles into an inside-string mask;
// Kogge–Stone fills slide along a stride through a propagation mask.
assert_eq!;
assert_eq!;
// SWAR byte lanes: exact zero/equal/less-than tests, eight at a time.
assert_eq!;
assert!;
// Compact / expand: PEXT / PDEP with a portable fallback.
assert_eq!;
// Dilated integers and Morton codes for 2D addressing.
let m = encode;
assert_eq!;
// 8×8 bit matrices: three delta swaps per transpose.
use transpose;
assert_eq!;
use ;
let rook = 1u64; // a1, attacking north up to the blocker on a5
assert_eq!;
// Hacker's Delight ch. 2 and HAKMEM 175, named and lawful.
assert_eq!;
assert_eq!;
assert_eq!; // Gosper's hack
assert_eq!;
// simdjson's find_escaped: quotes preceded by an odd run of backslashes.
let = 0b01_1101_1010u16.find_escaped;
assert_eq!;
// The carry chain as a dynamic-programming column (Myers 1999).
use ;
assert_eq!;
assert_eq!; // carrier by length
let hits: = .unwrap.collect;
assert_eq!;
// Patterns wider than a register: `Wide<N>` is `[u64; N]` as one word,
// and the same Myers code runs on it unchanged.
use Wide;
let long = ;
assert_eq!;
// 2D runs: a w×h block in a bitmap of rows, by two halving chains
// (binary erosion by a rectangle; first-fit for tile allocators).
let rows = ;
let mut scratch = ;
assert_eq!;
// Slices of words, no index needed.
assert_eq!;
// A rank/select directory over any `&[u64]`, storage you provide.
use Rank9;
let bits = ;
let mut counts = vec!;
let mut select = vec!;
build;
let dir = new;
assert_eq!;
Status
This is a learning project, one person's, and far from production.
I am working through the broadword literature by giving each trick a
type, a law and a test; the crate is what that leaves behind. Expect
the API to change between minor versions, and do not expect every
definition to be the best one available: the portable select loses
to a plain loop for small k, the portable compact to one over sparse
masks, and definitions were chosen for clarity and lawfulness before
speed.
What I do take seriously is finding bugs. Every combinator ships
with laws; the laws run on every carrier, with and without the
hardware paths, exhaustively at 8 and 16 bits, and under Miri for
the intrinsics; CI builds all of it as sandboxed Nix derivations on
x86_64 and aarch64. A wrong result is a bug and I want to hear about
it. A slow one may be known; the design notes linked at the end list
what is.
If you need a stable dependency today, take the two or three lines you need from Hacker's Delight; that is where this crate started. If you want to see them named, typed and checked, read on.
Checks
Every cell is one Nix derivation built in a sandbox without network,
on every push to main. The badges are written by CI after each run
(.github/status.sh) and read from the site.
Benchmarks against the crates people use
Criterion, AMD Zen 5, one thread, cargo bench --bench incumbents. Numbers are from a sandboxed run; rerun on your machine
before believing them. What the table does not hide: without BMI2 the
select is slower than the broadword crate's, and Wide<N> pays N
limb operations per step.
The same benches run in CI on a shared GitHub runner after every change
to src/, benches/ or the Cargo files, and the
benchmarks page keeps
every run. Absolute times there move with the runner; the ratio to the
best incumbent inside one run does not, and that ratio is what the page
tracks.
select in a u64 (1024 words) |
portable | +bmi2 |
|---|---|---|
hakmem Bits::select |
5.24 µs | 1.21 µs |
broadword::select1_raw |
4.60 µs | 4.54 µs |
compact in a u64 (1024 words) |
portable | +bmi2,+pclmulqdq |
|---|---|---|
hakmem Bits::compact |
11.2 µs | 1.30 µs |
| broadword definition, dense mask (about 32 set bits) | 11.1 µs | 8.00 µs |
| loop over the set bits, dense mask | 21.3 µs | 19.5 µs |
| loop over the set bits, sparse mask (about 8 set bits) | 4.36 µs | 3.75 µs |
| rank / select over 2^20 bits, 1024 queries | rank dense |
rank sparse |
select dense |
select sparse |
|---|---|---|---|---|
hakmem Rank9 |
1.33 µs | 1.33 µs | 5.26 µs | 1.7 µs |
sux rank9 / select9 |
2.02 µs | 2.03 µs | 5.52 µs | 1.44 µs |
sucds Rank9Sel |
1.90 µs | 1.89 µs | 8.83 µs | 11.3 µs |
vers-vecs RsVec |
2.83 µs | 2.82 µs | 7.63 µs | 10.5 µs |
Dense is half the bits set, sparse one in 64. Rank is the same rank9
directory everywhere; the differences are bounds checks and layout.
Select keeps an inventory entry per 512 set bits and, by how many
blocks those 512 span, stores either packed 16-bit block counts (one or
two SWAR compares) or the positions themselves (no search at all), the
shape of Vigna's select9 with the cases cut at block boundaries. The
remaining gap to sux on the sparse slice is within run-to-run noise of
the bounds checks this crate keeps: it has no unsafe outside the
intrinsics, sux indexes unchecked. The price of the inventory is three
words per eight words of bits on top of rank9's two.
The portable compact is constant time: a loop over the set bits wins
below about 17 of them (12 when a PCLMULQDQ scan is available) and loses
above, the same trade as the portable select.
| Levenshtein, pattern m × text n | hakmem u64 |
hakmem u128 |
triple_accel |
triple_accel_exp |
strsim |
|---|---|---|---|---|---|
| 16 × 64 | 153 ns | 224 ns | 852 ns | 590 ns | 711 ns |
| 32 × 256 | 602 ns | 829 ns | 19.6 µs | 6.10 µs | 5.74 µs |
| 64 × 256 | 614 ns | 860 ns | 21.4 µs | 6.80 µs | 11.4 µs |
| 64 × 1024 | 2.37 µs | 3.26 µs | 235 µs | 221 µs | 44.1 µs |
| 128 × 1024 | n/a | 3.24 µs | 249 µs | 231 µs | 90.0 µs |
Longer patterns, Wide<N> |
hakmem Wide<4> |
hakmem Wide<8> |
triple_accel |
triple_accel_exp |
strsim |
|---|---|---|---|---|---|
| 128 × 1024 | 7.41 µs | 19.3 µs | 245 µs | 231 µs | 109 µs |
| 256 × 1024 | 8.06 µs | 20.4 µs | 274 µs | 257 µs | 176 µs |
| 512 × 1024 | n/a | 22.3 µs | 326 µs | 307 µs | 352 µs |
Why the gap: Myers' bit-parallel column costs ~12 word operations per
text byte for the whole pattern (64 cells per word), the state lives in
registers, nothing is allocated and no UTF-8 is decoded; the others pay
per cell, and triple_accel's full-distance API searches upward over
the band width, which is a poor fit when the lengths differ a lot.
Longer patterns use Wide<N> (N × 64 bits as one word): each step
then costs N limb operations, still well below per-cell DP.
triple_accel's own design point is two strings of similar length that
differ in a few edits, with a known bound k (levenshtein_simd_k;
below, k is twice the edits). There it catches up and, past a couple
of words, wins:
| same length, few edits | hakmem |
triple_accel_simd_k |
triple_accel_exp |
strsim |
|---|---|---|---|---|
| 64, 4 edits (u64) | 165 ns | 980 ns | 1.03 µs | 2.74 µs |
| 128, 8 edits (u128) | 494 ns | 1.86 µs | 1.94 µs | 11.7 µs |
512, 8 edits (Wide<8>) |
13.5 µs | 7.13 µs | 7.39 µs | 177 µs |
512, 32 edits (Wide<8>) |
13.8 µs | 10.1 µs | 7.39 µs | 178 µs |
hakmem always computes the full distance; a banded variant (Ukkonen
cut-off over the active blocks) is the obvious next step for long
similar strings and is not implemented.
Cookbook
hakmem::cookbook explains how the shipped kernels were composed:
the simdjson inside-string mask, first-fit runs across words, the
Myers column step, Morton neighbours, strlen in one word, sliding
attacks, each as problem, decomposition, the laws that justify it,
and cost, with doctests. The last recipe is the one deliberately left
unbuilt (banded Myers over Wide<N>), sketched so you can.
Hardware paths
Chosen at compile time, never at run time: build with
-C target-feature=+bmi2,+pclmulqdq (or -C target-cpu=native) and
pext/pdep/select/prefix_xor become single instructions; without
them every combinator has a portable definition with the same
contract. pext and pdep fall back to Hacker's Delight's
parallel-suffix compress and expand (7-4, 7-5), log₂ w rounds of one
prefix-XOR scan each, constant time. The portable cargo feature turns
the hardware paths off even when the target feature is present, for the
microarchitectures where the instruction exists but is microcoded
(PDEP/PEXT on AMD Zen 1 and 2).
Laws
Every combinator ships with the laws it obeys (hakmem::laws), checked
by property tests on all carriers with and without the hardware paths,
and exhaustively for widths up to 16 bits. A backend that fails a law
is a bug, never a documented caveat.
Every combinator is total over its carrier where a total definition
exists: rank(i) past the width counts every bit, run_starts(0)
starts everywhere and run_starts(k) above the width nowhere, a fill
with a stride of zero or past the width is the identity, low_ones at
the width is all ones. Where an argument has a domain the definition
cannot absorb (shift amounts, run lengths above the width in
slice::find_run, overlapping delta_swap masks, bytes_ge above
128, grid sizes), the domain is in the method's docs, checked with
debug_assert! in debug builds, and unspecified in release. Run your
tests in debug once.
no_std, zero dependencies, stable Rust.
Design: docs/design.md,
the decisions behind the API, the hardware policy, how the laws are
verified, and where every combinator comes from.
Changes: CHANGELOG.md.