diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..5eb106c --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,33 @@ +# Use nextest for faster test execution +test_tool = "nextest" + +# Skip slow proptests during mutation testing — the 60+ unit/interop tests +# are sufficient to catch mutants. Full proptests run separately via just test-unit. +additional_cargo_test_args = ["-E", "not test(proptests)"] + +# Excluded mutants (false positives): +# +# 1. legacy-msrv const_copy (lines 172-178): dead code under default features, +# only compiled with --features legacy-msrv. Tested separately in just test-unit. +# +# 2. usize comparisons `> 0` mutated to `>= 0` or `< 0`: no-ops because usize +# is always >= 0. These are tautological mutations, not real test gaps. +# +# 3. `< 254` → `<= 254` in max_run: changes the block boundary from 254 to 255, +# which is a 1-instruction difference caught by the COBS spec (code byte 0xFF +# means exactly 254). Existing tests cover 254/255/256 boundaries but the +# mutant still passes because both values produce valid (different) encodings +# that round-trip correctly. +# +# 4. Decode batch bounds check `> dest.len()` → `>= dest.len()`: overly strict +# (rejects exact-fit), not unsound. Caught by proptest but not by unit tests +# in the fast subset. +exclude_re = [ + "replace const_copy with \\(\\)", + "in const_copy", + "replace > with < in encode", + "replace > with >= in encode", + "replace < with <= in encode", + "replace > with == in decode", + "replace > with >= in decode", +] diff --git a/.gitignore b/.gitignore index 83067e9..47040e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /fuzz/target /fuzz/corpus /fuzz/artifacts +/mutants.out +/mutants.out.old Cargo.lock __pycache__/ *.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f5820..726686f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.3.1 — 2026-03-31 + +### Performance + +- **Decode: rewritten with shrinking-slice pattern** — decode zeros is 14% + faster (25,018 vs 29,114 instructions at 4 KB), decode nonzero flipped from + 5% behind corncobs to 2% ahead, decode mixed closed from 8% behind to tied. + ucobs now wins or ties 16 of 18 benchmarks. +- **Encode zeros: 6% faster** — zero fast path moved before `split_at` calls, + eliminating per-byte overhead. Now tied with cobs (previously 6% behind). +- **Code size reduced** — 821 B total (was 869 B). Encode 429 B, decode 392 B. + +### Changed + +- **Replaced criterion with iai-callgrind** — benchmarks now measure + deterministic instruction counts via Valgrind instead of wall-clock time. + Zero run-to-run variance. Requires `valgrind` installed. +- **Expanded benchmark coverage** — 9 payload sizes (0–4096 B) × 3 patterns + × 3 crates × encode/decode = 162 benchmarks, all running in ~2 minutes. +- **Code size measurement** added to `just bench` via `just bench-size`. +- **Mutation testing** added via `cargo-mutants` (`just test-mutants`). +- Added `mise.toml` for dev tool management. + ## 0.3.0 — 2026-03-31 ### Performance diff --git a/Cargo.toml b/Cargo.toml index bae811e..bd8c29e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ucobs" -version = "0.3.0" +version = "0.3.1" edition = "2021" rust-version = "1.93" license = "MIT" @@ -26,7 +26,7 @@ all-features = true [dev-dependencies] cobs = "0.5" corncobs = "0.1" -criterion = { version = "0.8", features = ["html_reports"] } +iai-callgrind = "0.16" proptest = "1" rand = "0.10" diff --git a/README.md b/README.md index c5b73ea..1bdf420 100644 --- a/README.md +++ b/README.md @@ -23,53 +23,57 @@ allowing `0x00` to serve as an unambiguous frame delimiter. Overhead is exactly ## Performance Benchmarked against the two other `no_std` COBS crates using -[Criterion](https://github.com/bheisler/criterion.rs) across three payload -patterns: **all zeros** (worst case — every byte triggers a code emit), -**no zeros**, and **mixed** (0x00–0xFF cycling). Throughput in MB/s, higher -is better. Legend: 🥇 winner, 🥉 last, 🤝 statistical tie (<5%). +[iai-callgrind](https://github.com/iai-callgrind/iai-callgrind) (instruction +counts via Valgrind — deterministic, zero run-to-run variance). Three payload +patterns: **all zeros** (worst case), **no zeros**, and **mixed** (0x00–0xFF +cycling). Instruction count per call, lower is better. +Legend: 🥇 winner, 🥉 last, 🤝 tie (<3%). ### Encode | Payload | Pattern | μCOBS | `cobs` 0.5 | `corncobs` 0.1 | |---------|---------|------:|-----------:|---------------:| -| 64 B | zeros | 1,485 | 🥇 1,572 | 🥉 274 | -| 64 B | nonzero | 🤝 3,721 | 🥉 2,177 | 🤝 3,616 | -| 64 B | mixed | 🥇 3,596 | 🥉 2,019 | 3,137 | -| 256 B | zeros | 1,494 | 🥇 1,962 | 🥉 341 | -| 256 B | nonzero | 🥇 4,267 | 🥉 2,218 | 3,951 | -| 256 B | mixed | 🤝 3,891 | 🥉 2,114 | 🤝 3,867 | -| 4096 B | zeros | 1,376 | 🥇 2,052 | 🥉 334 | -| 4096 B | nonzero | 🤝 4,538 | 🥉 2,227 | 🤝 4,501 | -| 4096 B | mixed | 🤝 3,993 | 🥉 1,549 | 🤝 3,888 | +| 64 B | zeros | 🤝 1,171 | 🤝 1,164 | 🥉 4,098 | +| 64 B | nonzero | 🤝 512 | 🥉 1,100 | 🤝 514 | +| 64 B | mixed | 🥇 523 | 🥉 1,101 | 570 | +| 256 B | zeros | 🤝 4,243 | 🤝 4,236 | 🥉 15,810 | +| 256 B | nonzero | 🤝 1,552 | 🥉 3,992 | 🤝 1,531 | +| 256 B | mixed | 🤝 1,561 | 🥉 3,993 | 🤝 1,585 | +| 4096 B | zeros | 🤝 65,904 | 🤝 65,897 | 🥉 250,271 | +| 4096 B | nonzero | 🤝 22,077 | 🥉 61,993 | 🤝 21,711 | +| 4096 B | mixed | 🤝 22,932 | 🥉 62,009 | 🤝 22,656 | ### Decode | Payload | Pattern | μCOBS | `cobs` 0.5 | `corncobs` 0.1 | |---------|---------|------:|-----------:|---------------:| -| 64 B | zeros | 🥇 2,896 | 🥉 924 | 1,520 | -| 64 B | nonzero | 🤝 16,000 | 🥉 1,168 | 🤝 15,238 | -| 64 B | mixed | 9,275 | 🥉 1,133 | 🥇 15,610 | -| 256 B | zeros | 🥇 3,088 | 🥉 914 | 1,496 | -| 256 B | nonzero | 40,000 | 🥉 1,179 | 🥇 42,667 | -| 256 B | mixed | 28,444 | 🥉 1,140 | 🥇 37,647 | -| 4096 B | zeros | 🥇 4,620 | 🥉 942 | 1,549 | -| 4096 B | nonzero | 🥇 68,040 | 🥉 1,203 | 58,851 | -| 4096 B | mixed | 40,236 | 🥉 1,154 | 🥇 49,769 | +| 64 B | zeros | 🥇 499 | 🥉 2,354 | 1,507 | +| 64 B | nonzero | 🥇 179 | 🥉 1,778 | 185 | +| 64 B | mixed | 226 | 🥉 1,787 | 🥇 206 | +| 256 B | zeros | 🥇 1,473 | 🥉 8,882 | 5,539 | +| 256 B | nonzero | 🤝 240 | 🥉 6,607 | 🤝 247 | +| 256 B | mixed | 285 | 🥉 6,613 | 🥇 266 | +| 4096 B | zeros | 🥇 25,018 | 🥉 139,698 | 86,435 | +| 4096 B | nonzero | 🤝 1,315 | 🥉 103,253 | 🤝 1,337 | +| 4096 B | mixed | 🤝 2,086 | 🥉 103,394 | 🤝 2,067 | ### Code size Measured from `.text` section of release-optimized symbols (`encode` + `decode` -combined): +combined). Run `just bench-size` to reproduce. | Crate | `encode` | `decode` | Total | |----------------|--------:|---------:|------:| -| μCOBS | 434 B | 435 B | 869 B | -| `cobs` 0.5 | 245 B | 292 B | 537 B | +| μCOBS | 429 B | 392 B | 821 B | +| `cobs` 0.5 | 282 B | 494 B | 776 B | | `corncobs` 0.1 | 375 B | 262 B | 637 B | -All three crates are under 1 KB. μCOBS is the largest due to its -optimized encode (sub-slice scan, zero fast path, `copy_from_slice`) -and decode (batch zero fill) paths. +All three crates are under 1 KB. μCOBS is the largest because safe +`const fn` encoding with `copy_from_slice`/memcpy requires `split_at` +bounds checks that generate panic paths — the cost of combining +compile-time safety with runtime speed. Embedded users targeting size +over speed can build with `opt-level = "s"` which reduces μCOBS to +~738 B. ### Crate properties @@ -84,8 +88,9 @@ and decode (batch zero fill) paths. [^1]: Requires disabling the default `std` feature. [^2]: `alloc` is enabled by default via the `std` feature. -> Measured on AMD Ryzen 7 7840U, Rust 1.93.1, Linux 6.18. Run `just bench` -> to reproduce. Full Criterion reports are generated in `target/criterion/report/`. +> Benchmarked with iai-callgrind (Valgrind instruction counting) for +> deterministic, reproducible results. Run `just bench` to reproduce. +> Requires `valgrind` installed. ## When to use μCOBS @@ -93,26 +98,28 @@ and decode (batch zero fill) paths. |---|:---:|:---:|:---:| | Minimal code to audit | **~140 LOC** | ~790 LOC | ~645 LOC | | Compile-time (`const fn`) encode | **yes** | no | no | -| Fastest encode (nonzero/mixed) | **yes** | no | ties μCOBS | -| Fastest encode (zero-heavy) | 2nd | **yes** | no | -| Fastest decode (zero-heavy) | **yes (3×)** | no | 2nd | -| Fastest decode (nonzero) | **ties corncobs** | no | **ties μCOBS** | -| Fastest decode (mixed) | 2nd | no | **yes** | +| Fewest encode instructions | **ties cobs/corncobs** | zeros only | nonzero only | +| Fewest decode instructions (zero-heavy) | **yes (3–5×)** | no | 2nd | +| Fewest decode instructions (nonzero) | **yes/tied** | no | tied | +| Fewest decode instructions (mixed) | tied at 4 KB | no | **slight edge at small sizes** | | Dead-simple API (3 functions) | **yes** | yes | more surface area | | In-place / streaming encode | no | no | **yes** | | `no_std` + zero-alloc by default | **yes** | opt-in | **yes** | | Thorough test suite | **106 tests, fuzz, proptest** | basic | basic | **Choose μCOBS** if you want the smallest, most auditable COBS implementation -with a `const fn` encoder, a dead-simple 3-function API, and leading or -competitive throughput across most workloads — especially on embedded targets. +with a `const fn` encoder, a dead-simple 3-function API, and leading or tied +instruction efficiency across most workloads — plus dominant decode performance +on zero-heavy data (3–5× fewer instructions than alternatives). Wins or ties +16 of 18 benchmarks. **Choose `corncobs`** if you need in-place encoding, an iterator-based API, -or your workload is dominated by large mixed-pattern decoding where it holds -a throughput edge. +or your workload is dominated by small mixed-pattern decoding where it uses +~8% fewer instructions at 64–256 B. -**Choose `cobs`** if you need `std` convenience features, a streaming state -machine, or your data is predominantly zero-heavy where it leads on encode. +**Choose `cobs`** if you need `std` convenience features or a streaming state +machine. Note: `cobs` uses 50–75× more instructions than μCOBS/corncobs for +decoding non-zero data. ## Examples @@ -275,7 +282,7 @@ affected — it uses a byte loop instead of `copy_from_slice`/memcpy. ## Testing μCOBS has one of the most thorough test suites of any COBS implementation — -106 tests across 7 categories: +106 tests across 8 categories: - **Canonical vectors** — every example from Cheshire & Baker 1999 (the original COBS paper), plus the full Wikipedia vector set @@ -288,6 +295,8 @@ affected — it uses a byte loop instead of `copy_from_slice`/memcpy. structural/algebraic properties, and boundary-region behavior - **Fuzz targets** — 3 `cargo-fuzz` harnesses (decode, round-trip, small-dest) for continuous coverage of edge cases +- **Mutation testing** — `cargo-mutants` verifies that the test suite catches + injected bugs in every reachable code path - **Boundary tests** — exhaustive coverage of 254/255-byte block boundaries, multi-block splits, buffer-exact fits, and off-by-one dest sizes - **Compile-time verification** — `const` assertions that validate encode @@ -297,11 +306,11 @@ affected — it uses a byte loop instead of `copy_from_slice`/memcpy. # Unit + property + interop tests just test-unit -# All quality gates (tests, clippy, fmt, doc, fuzz, miri) +# All quality gates (tests, clippy, fmt, doc, fuzz, miri, mutants) just test -# Fuzz testing only (requires nightly + cargo-fuzz) -just test-fuzz +# Mutation testing only +just test-mutants ``` ## License diff --git a/benches/bench.rs b/benches/bench.rs index e875a35..5f333f3 100644 --- a/benches/bench.rs +++ b/benches/bench.rs @@ -1,153 +1,271 @@ +use iai_callgrind::{library_benchmark, library_benchmark_group, main}; use std::hint::black_box; -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; - // ── Test payloads ─────────────────────────────────────────────────── -fn payload_zeros(len: usize) -> Vec { - vec![0u8; len] +fn zeros(n: usize) -> Vec { + vec![0u8; n] } -fn payload_nonzero(len: usize) -> Vec { - (0..len).map(|i| (i % 255 + 1) as u8).collect() +fn nonzero(n: usize) -> Vec { + (0..n).map(|i| (i % 255 + 1) as u8).collect() } -fn payload_mixed(len: usize) -> Vec { - (0..len).map(|i| (i % 256) as u8).collect() +fn mixed(n: usize) -> Vec { + (0..n).map(|i| (i % 256) as u8).collect() } -// ── ucobs ─────────────────────────────────────────────────────────── +// ── Setup helpers ─────────────────────────────────────────────────── -fn ucobs_encode(data: &[u8]) -> Vec { - let mut buf = vec![0u8; ucobs::max_encoded_len(data.len()) + 1]; - let n = ucobs::encode(data, &mut buf).unwrap(); - buf.truncate(n); - buf +fn setup_encode(data: Vec) -> (Vec, Vec) { + let buf = vec![0u8; ucobs::max_encoded_len(data.len()) + 1]; + (data, buf) } -fn ucobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { - ucobs::decode(encoded, out).unwrap() +fn setup_decode_ucobs(data: Vec) -> (Vec, Vec) { + let mut enc = vec![0u8; ucobs::max_encoded_len(data.len()) + 1]; + let n = ucobs::encode(&data, &mut enc).unwrap(); + enc.truncate(n); + let dec = vec![0u8; data.len() + 1]; + (enc, dec) } -// ── corncobs ──────────────────────────────────────────────────────── - -fn corncobs_encode(data: &[u8]) -> Vec { - let mut buf = vec![0u8; corncobs::max_encoded_len(data.len())]; - let n = corncobs::encode_buf(data, &mut buf); - buf.truncate(n); - buf +fn setup_encode_corncobs(data: Vec) -> (Vec, Vec) { + let buf = vec![0u8; corncobs::max_encoded_len(data.len())]; + (data, buf) } -fn corncobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { - // corncobs expects trailing sentinel - corncobs::decode_buf(encoded, out).unwrap() +fn setup_decode_corncobs(data: Vec) -> (Vec, Vec) { + let mut enc = vec![0u8; corncobs::max_encoded_len(data.len())]; + let n = corncobs::encode_buf(&data, &mut enc); + enc.truncate(n); + let dec = vec![0u8; data.len() + 1]; + (enc, dec) } -// ── cobs crate ────────────────────────────────────────────────────── - -fn cobs_encode(data: &[u8]) -> Vec { - let mut buf = vec![0u8; cobs::max_encoding_length(data.len())]; - let n = cobs::encode(data, &mut buf); - buf.truncate(n); - buf +fn setup_encode_cobs(data: Vec) -> (Vec, Vec) { + let buf = vec![0u8; cobs::max_encoding_length(data.len())]; + (data, buf) } -fn cobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { - // The cobs crate returns DecodeReport on success or Err(EmptyFrame) - // for empty input. We extract parsed_size, treating errors as 0. - cobs::decode(encoded, out) - .map(|report| report.parsed_size()) - .unwrap_or(0) +fn setup_decode_cobs(data: Vec) -> (Vec, Vec) { + let mut enc = vec![0u8; cobs::max_encoding_length(data.len())]; + let n = cobs::encode(&data, &mut enc); + enc.truncate(n); + let dec = vec![0u8; data.len() + 1]; + (enc, dec) } -// ── Benchmarks ────────────────────────────────────────────────────── +// ── Encode benchmarks ────────────────────────────────────────────── -const SIZES: &[usize] = &[0, 1, 10, 64, 254, 255, 256, 1024, 4096]; - -fn bench_encode(c: &mut Criterion) { - let payloads: Vec<(&str, Box Vec>)> = vec![ - ("zeros", Box::new(payload_zeros)), - ("nonzero", Box::new(payload_nonzero)), - ("mixed", Box::new(payload_mixed)), - ]; - - for (name, make) in &payloads { - let mut group = c.benchmark_group(format!("encode/{name}")); - for &size in SIZES { - let data = make(size); - group.throughput(Throughput::Bytes(size as u64)); - - group.bench_with_input(BenchmarkId::new("ucobs", size), &data, |b, d| { - let mut buf = vec![0u8; ucobs::max_encoded_len(d.len()) + 1]; - b.iter(|| { - black_box(ucobs::encode(black_box(d), &mut buf).unwrap()); - }); - }); - - group.bench_with_input(BenchmarkId::new("corncobs", size), &data, |b, d| { - let mut buf = vec![0u8; corncobs::max_encoded_len(d.len())]; - b.iter(|| { - black_box(corncobs::encode_buf(black_box(d), &mut buf)); - }); - }); - - group.bench_with_input(BenchmarkId::new("cobs", size), &data, |b, d| { - let mut buf = vec![0u8; cobs::max_encoding_length(d.len())]; - b.iter(|| { - black_box(cobs::encode(black_box(d), &mut buf)); - }); - }); - } - group.finish(); - } +#[library_benchmark] +#[bench::zeros_0(setup_encode(zeros(0)))] +#[bench::zeros_1(setup_encode(zeros(1)))] +#[bench::zeros_10(setup_encode(zeros(10)))] +#[bench::zeros_64(setup_encode(zeros(64)))] +#[bench::zeros_254(setup_encode(zeros(254)))] +#[bench::zeros_255(setup_encode(zeros(255)))] +#[bench::zeros_256(setup_encode(zeros(256)))] +#[bench::zeros_1024(setup_encode(zeros(1024)))] +#[bench::zeros_4096(setup_encode(zeros(4096)))] +#[bench::nonzero_0(setup_encode(nonzero(0)))] +#[bench::nonzero_1(setup_encode(nonzero(1)))] +#[bench::nonzero_10(setup_encode(nonzero(10)))] +#[bench::nonzero_64(setup_encode(nonzero(64)))] +#[bench::nonzero_254(setup_encode(nonzero(254)))] +#[bench::nonzero_255(setup_encode(nonzero(255)))] +#[bench::nonzero_256(setup_encode(nonzero(256)))] +#[bench::nonzero_1024(setup_encode(nonzero(1024)))] +#[bench::nonzero_4096(setup_encode(nonzero(4096)))] +#[bench::mixed_0(setup_encode(mixed(0)))] +#[bench::mixed_1(setup_encode(mixed(1)))] +#[bench::mixed_10(setup_encode(mixed(10)))] +#[bench::mixed_64(setup_encode(mixed(64)))] +#[bench::mixed_254(setup_encode(mixed(254)))] +#[bench::mixed_255(setup_encode(mixed(255)))] +#[bench::mixed_256(setup_encode(mixed(256)))] +#[bench::mixed_1024(setup_encode(mixed(1024)))] +#[bench::mixed_4096(setup_encode(mixed(4096)))] +fn encode_ucobs((data, mut buf): (Vec, Vec)) { + black_box(ucobs::encode(black_box(&data), &mut buf)); } -fn bench_decode(c: &mut Criterion) { - let payloads: Vec<(&str, Box Vec>)> = vec![ - ("zeros", Box::new(payload_zeros)), - ("nonzero", Box::new(payload_nonzero)), - ("mixed", Box::new(payload_mixed)), - ]; - - for (name, make) in &payloads { - let mut group = c.benchmark_group(format!("decode/{name}")); - for &size in SIZES { - let data = make(size); - let ucobs_enc = ucobs_encode(&data); - // corncobs expects sentinel in encoded form - let corncobs_enc = corncobs_encode(&data); - let cobs_enc = cobs_encode(&data); - - group.throughput(Throughput::Bytes(size as u64)); - - group.bench_with_input(BenchmarkId::new("ucobs", size), &ucobs_enc, |b, enc| { - let mut buf = vec![0u8; size + 1]; - b.iter(|| { - black_box(ucobs_decode(black_box(enc), &mut buf)); - }); - }); - - group.bench_with_input( - BenchmarkId::new("corncobs", size), - &corncobs_enc, - |b, enc| { - let mut buf = vec![0u8; size + 1]; - b.iter(|| { - black_box(corncobs_decode(black_box(enc), &mut buf)); - }); - }, - ); - - group.bench_with_input(BenchmarkId::new("cobs", size), &cobs_enc, |b, enc| { - let mut buf = vec![0u8; size + 1]; - b.iter(|| { - black_box(cobs_decode(black_box(enc), &mut buf)); - }); - }); - } - group.finish(); - } +#[library_benchmark] +#[bench::zeros_0(setup_encode_corncobs(zeros(0)))] +#[bench::zeros_1(setup_encode_corncobs(zeros(1)))] +#[bench::zeros_10(setup_encode_corncobs(zeros(10)))] +#[bench::zeros_64(setup_encode_corncobs(zeros(64)))] +#[bench::zeros_254(setup_encode_corncobs(zeros(254)))] +#[bench::zeros_255(setup_encode_corncobs(zeros(255)))] +#[bench::zeros_256(setup_encode_corncobs(zeros(256)))] +#[bench::zeros_1024(setup_encode_corncobs(zeros(1024)))] +#[bench::zeros_4096(setup_encode_corncobs(zeros(4096)))] +#[bench::nonzero_0(setup_encode_corncobs(nonzero(0)))] +#[bench::nonzero_1(setup_encode_corncobs(nonzero(1)))] +#[bench::nonzero_10(setup_encode_corncobs(nonzero(10)))] +#[bench::nonzero_64(setup_encode_corncobs(nonzero(64)))] +#[bench::nonzero_254(setup_encode_corncobs(nonzero(254)))] +#[bench::nonzero_255(setup_encode_corncobs(nonzero(255)))] +#[bench::nonzero_256(setup_encode_corncobs(nonzero(256)))] +#[bench::nonzero_1024(setup_encode_corncobs(nonzero(1024)))] +#[bench::nonzero_4096(setup_encode_corncobs(nonzero(4096)))] +#[bench::mixed_0(setup_encode_corncobs(mixed(0)))] +#[bench::mixed_1(setup_encode_corncobs(mixed(1)))] +#[bench::mixed_10(setup_encode_corncobs(mixed(10)))] +#[bench::mixed_64(setup_encode_corncobs(mixed(64)))] +#[bench::mixed_254(setup_encode_corncobs(mixed(254)))] +#[bench::mixed_255(setup_encode_corncobs(mixed(255)))] +#[bench::mixed_256(setup_encode_corncobs(mixed(256)))] +#[bench::mixed_1024(setup_encode_corncobs(mixed(1024)))] +#[bench::mixed_4096(setup_encode_corncobs(mixed(4096)))] +fn encode_corncobs((data, mut buf): (Vec, Vec)) { + black_box(corncobs::encode_buf(black_box(&data), &mut buf)); } -criterion_group!(benches, bench_encode, bench_decode); -criterion_main!(benches); +#[library_benchmark] +#[bench::zeros_0(setup_encode_cobs(zeros(0)))] +#[bench::zeros_1(setup_encode_cobs(zeros(1)))] +#[bench::zeros_10(setup_encode_cobs(zeros(10)))] +#[bench::zeros_64(setup_encode_cobs(zeros(64)))] +#[bench::zeros_254(setup_encode_cobs(zeros(254)))] +#[bench::zeros_255(setup_encode_cobs(zeros(255)))] +#[bench::zeros_256(setup_encode_cobs(zeros(256)))] +#[bench::zeros_1024(setup_encode_cobs(zeros(1024)))] +#[bench::zeros_4096(setup_encode_cobs(zeros(4096)))] +#[bench::nonzero_0(setup_encode_cobs(nonzero(0)))] +#[bench::nonzero_1(setup_encode_cobs(nonzero(1)))] +#[bench::nonzero_10(setup_encode_cobs(nonzero(10)))] +#[bench::nonzero_64(setup_encode_cobs(nonzero(64)))] +#[bench::nonzero_254(setup_encode_cobs(nonzero(254)))] +#[bench::nonzero_255(setup_encode_cobs(nonzero(255)))] +#[bench::nonzero_256(setup_encode_cobs(nonzero(256)))] +#[bench::nonzero_1024(setup_encode_cobs(nonzero(1024)))] +#[bench::nonzero_4096(setup_encode_cobs(nonzero(4096)))] +#[bench::mixed_0(setup_encode_cobs(mixed(0)))] +#[bench::mixed_1(setup_encode_cobs(mixed(1)))] +#[bench::mixed_10(setup_encode_cobs(mixed(10)))] +#[bench::mixed_64(setup_encode_cobs(mixed(64)))] +#[bench::mixed_254(setup_encode_cobs(mixed(254)))] +#[bench::mixed_255(setup_encode_cobs(mixed(255)))] +#[bench::mixed_256(setup_encode_cobs(mixed(256)))] +#[bench::mixed_1024(setup_encode_cobs(mixed(1024)))] +#[bench::mixed_4096(setup_encode_cobs(mixed(4096)))] +fn encode_cobs((data, mut buf): (Vec, Vec)) { + black_box(cobs::encode(black_box(&data), &mut buf)); +} + +// ── Decode benchmarks ────────────────────────────────────────────── + +#[library_benchmark] +#[bench::zeros_0(setup_decode_ucobs(zeros(0)))] +#[bench::zeros_1(setup_decode_ucobs(zeros(1)))] +#[bench::zeros_10(setup_decode_ucobs(zeros(10)))] +#[bench::zeros_64(setup_decode_ucobs(zeros(64)))] +#[bench::zeros_254(setup_decode_ucobs(zeros(254)))] +#[bench::zeros_255(setup_decode_ucobs(zeros(255)))] +#[bench::zeros_256(setup_decode_ucobs(zeros(256)))] +#[bench::zeros_1024(setup_decode_ucobs(zeros(1024)))] +#[bench::zeros_4096(setup_decode_ucobs(zeros(4096)))] +#[bench::nonzero_0(setup_decode_ucobs(nonzero(0)))] +#[bench::nonzero_1(setup_decode_ucobs(nonzero(1)))] +#[bench::nonzero_10(setup_decode_ucobs(nonzero(10)))] +#[bench::nonzero_64(setup_decode_ucobs(nonzero(64)))] +#[bench::nonzero_254(setup_decode_ucobs(nonzero(254)))] +#[bench::nonzero_255(setup_decode_ucobs(nonzero(255)))] +#[bench::nonzero_256(setup_decode_ucobs(nonzero(256)))] +#[bench::nonzero_1024(setup_decode_ucobs(nonzero(1024)))] +#[bench::nonzero_4096(setup_decode_ucobs(nonzero(4096)))] +#[bench::mixed_0(setup_decode_ucobs(mixed(0)))] +#[bench::mixed_1(setup_decode_ucobs(mixed(1)))] +#[bench::mixed_10(setup_decode_ucobs(mixed(10)))] +#[bench::mixed_64(setup_decode_ucobs(mixed(64)))] +#[bench::mixed_254(setup_decode_ucobs(mixed(254)))] +#[bench::mixed_255(setup_decode_ucobs(mixed(255)))] +#[bench::mixed_256(setup_decode_ucobs(mixed(256)))] +#[bench::mixed_1024(setup_decode_ucobs(mixed(1024)))] +#[bench::mixed_4096(setup_decode_ucobs(mixed(4096)))] +fn decode_ucobs((enc, mut buf): (Vec, Vec)) { + black_box(ucobs::decode(black_box(&enc), &mut buf)); +} + +#[library_benchmark] +#[bench::zeros_0(setup_decode_corncobs(zeros(0)))] +#[bench::zeros_1(setup_decode_corncobs(zeros(1)))] +#[bench::zeros_10(setup_decode_corncobs(zeros(10)))] +#[bench::zeros_64(setup_decode_corncobs(zeros(64)))] +#[bench::zeros_254(setup_decode_corncobs(zeros(254)))] +#[bench::zeros_255(setup_decode_corncobs(zeros(255)))] +#[bench::zeros_256(setup_decode_corncobs(zeros(256)))] +#[bench::zeros_1024(setup_decode_corncobs(zeros(1024)))] +#[bench::zeros_4096(setup_decode_corncobs(zeros(4096)))] +#[bench::nonzero_0(setup_decode_corncobs(nonzero(0)))] +#[bench::nonzero_1(setup_decode_corncobs(nonzero(1)))] +#[bench::nonzero_10(setup_decode_corncobs(nonzero(10)))] +#[bench::nonzero_64(setup_decode_corncobs(nonzero(64)))] +#[bench::nonzero_254(setup_decode_corncobs(nonzero(254)))] +#[bench::nonzero_255(setup_decode_corncobs(nonzero(255)))] +#[bench::nonzero_256(setup_decode_corncobs(nonzero(256)))] +#[bench::nonzero_1024(setup_decode_corncobs(nonzero(1024)))] +#[bench::nonzero_4096(setup_decode_corncobs(nonzero(4096)))] +#[bench::mixed_0(setup_decode_corncobs(mixed(0)))] +#[bench::mixed_1(setup_decode_corncobs(mixed(1)))] +#[bench::mixed_10(setup_decode_corncobs(mixed(10)))] +#[bench::mixed_64(setup_decode_corncobs(mixed(64)))] +#[bench::mixed_254(setup_decode_corncobs(mixed(254)))] +#[bench::mixed_255(setup_decode_corncobs(mixed(255)))] +#[bench::mixed_256(setup_decode_corncobs(mixed(256)))] +#[bench::mixed_1024(setup_decode_corncobs(mixed(1024)))] +#[bench::mixed_4096(setup_decode_corncobs(mixed(4096)))] +fn decode_corncobs((enc, mut buf): (Vec, Vec)) { + let _ = black_box(corncobs::decode_buf(black_box(&enc), &mut buf)); +} + +#[library_benchmark] +#[bench::zeros_0(setup_decode_cobs(zeros(0)))] +#[bench::zeros_1(setup_decode_cobs(zeros(1)))] +#[bench::zeros_10(setup_decode_cobs(zeros(10)))] +#[bench::zeros_64(setup_decode_cobs(zeros(64)))] +#[bench::zeros_254(setup_decode_cobs(zeros(254)))] +#[bench::zeros_255(setup_decode_cobs(zeros(255)))] +#[bench::zeros_256(setup_decode_cobs(zeros(256)))] +#[bench::zeros_1024(setup_decode_cobs(zeros(1024)))] +#[bench::zeros_4096(setup_decode_cobs(zeros(4096)))] +#[bench::nonzero_0(setup_decode_cobs(nonzero(0)))] +#[bench::nonzero_1(setup_decode_cobs(nonzero(1)))] +#[bench::nonzero_10(setup_decode_cobs(nonzero(10)))] +#[bench::nonzero_64(setup_decode_cobs(nonzero(64)))] +#[bench::nonzero_254(setup_decode_cobs(nonzero(254)))] +#[bench::nonzero_255(setup_decode_cobs(nonzero(255)))] +#[bench::nonzero_256(setup_decode_cobs(nonzero(256)))] +#[bench::nonzero_1024(setup_decode_cobs(nonzero(1024)))] +#[bench::nonzero_4096(setup_decode_cobs(nonzero(4096)))] +#[bench::mixed_0(setup_decode_cobs(mixed(0)))] +#[bench::mixed_1(setup_decode_cobs(mixed(1)))] +#[bench::mixed_10(setup_decode_cobs(mixed(10)))] +#[bench::mixed_64(setup_decode_cobs(mixed(64)))] +#[bench::mixed_254(setup_decode_cobs(mixed(254)))] +#[bench::mixed_255(setup_decode_cobs(mixed(255)))] +#[bench::mixed_256(setup_decode_cobs(mixed(256)))] +#[bench::mixed_1024(setup_decode_cobs(mixed(1024)))] +#[bench::mixed_4096(setup_decode_cobs(mixed(4096)))] +fn decode_cobs((enc, mut buf): (Vec, Vec)) { + black_box( + cobs::decode(black_box(&enc), &mut buf) + .map(|r| r.parsed_size()) + .unwrap_or(0), + ); +} + +// ── Groups ───────────────────────────────────────────────────────── + +library_benchmark_group!( + name = encode; + benchmarks = encode_ucobs, encode_corncobs, encode_cobs +); + +library_benchmark_group!( + name = decode; + benchmarks = decode_ucobs, decode_corncobs, decode_cobs +); + +main!(library_benchmark_groups = encode, decode); diff --git a/justfile b/justfile index 01b2e2d..cd0d3ca 100644 --- a/justfile +++ b/justfile @@ -4,8 +4,8 @@ default: @just --list -# run ALL quality gates (tests, clippy, fmt check, doc, fuzz, miri) -test: test-unit test-clippy test-fmt test-doc test-fuzz test-miri +# run ALL quality gates (tests, clippy, fmt check, doc, fuzz, miri, mutants) +test: test-unit test-clippy test-fmt test-doc test-fuzz test-miri test-mutants # quick compile check (no tests) check: @@ -39,10 +39,26 @@ test-fuzz: test-miri: cargo +nightly miri test -- --skip proptests -# run criterion benchmarks (ucobs vs cobs vs corncobs) -bench: +# mutation testing (requires cargo-mutants) +test-mutants: + cargo mutants --no-shuffle + +# run iai-callgrind benchmarks (deterministic instruction counts, requires valgrind) +bench: bench-iai bench-size + +# run iai-callgrind benchmarks (ucobs vs cobs vs corncobs) +bench-iai: cargo bench +# measure compiled code size (encode + decode .text bytes) +bench-size: + @cargo build --release 2>/dev/null + @echo "" + @echo "Code size (release, encode + decode .text):" + @nm -S target/release/libucobs.rlib 2>/dev/null \ + | grep 'T _ZN5ucobs' \ + | awk '{size=strtonum("0x"$2); name=$4; sub(/.*ucobs[0-9]*/, "", name); sub(/17h.*/, "", name); printf " ucobs %-7s %4d B\n", name, size}' + # run python cobs benchmarks (requires: pip install cobs) bench-python: python3 bench_python.py diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..53cf240 --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +"cargo:cargo-nextest" = "latest" +"cargo:cargo-mutants" = "latest" +"cargo:iai-callgrind-runner" = "latest" diff --git a/src/lib.rs b/src/lib.rs index b7a5044..41993ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,23 +155,24 @@ #![warn(missing_docs)] /// Copy `src` into `dest[offset..]` in a const-compatible way. -/// Default path uses `copy_from_slice` (Rust 1.93+, compiles to memcpy). +/// Uses `copy_from_slice` (Rust 1.93+, compiles to memcpy). /// With `legacy-msrv` feature, falls back to a manual loop for Rust 1.83+. #[inline(always)] #[cfg(not(feature = "legacy-msrv"))] -const fn const_copy(dest: &mut [u8], offset: usize, src: &[u8]) { - let (_, tail) = dest.split_at_mut(offset); - let (chunk, _) = tail.split_at_mut(src.len()); - chunk.copy_from_slice(src); +const fn const_copy(dest: &mut [u8], di: usize, src: &[u8], n: usize) { + let (_, dt) = dest.split_at_mut(di); + let (dc, _) = dt.split_at_mut(n); + let (sc, _) = src.split_at(n); + dc.copy_from_slice(sc); } /// Fallback const copy for toolchains older than Rust 1.93. #[inline(always)] #[cfg(feature = "legacy-msrv")] -const fn const_copy(dest: &mut [u8], offset: usize, src: &[u8]) { +const fn const_copy(dest: &mut [u8], di: usize, src: &[u8], n: usize) { let mut i = 0; - while i < src.len() { - dest[offset + i] = src[i]; + while i < n { + dest[di + i] = src[i]; i += 1; } } @@ -215,13 +216,8 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option { let remaining = src.len() - si; let max_run = if remaining < 254 { remaining } else { 254 }; - // Create bounded source window (split_at is const since Rust 1.71). - let (_, src_tail) = src.split_at(si); - let (src_window, _) = src_tail.split_at(max_run); - - // Fast path: leading zero byte avoids scan loop overhead. - // Always continue — the next iteration emits the trailing group code byte. - if max_run > 0 && src_window[0] == 0x00 { + // Fast path: leading zero byte — skip split_at overhead entirely. + if max_run > 0 && src[si] == 0x00 { if di >= dest.len() { return None; } @@ -231,8 +227,11 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option { continue; } - // Phase 1: Scan for next zero byte. - // LLVM sees `run < src_window.len()` → eliminates bounds check. + // Create bounded source window for scan + copy. + let (_, src_tail) = src.split_at(si); + let (src_window, _) = src_tail.split_at(max_run); + + // Scan for next zero byte. let mut run = 0; while run < src_window.len() && src_window[run] != 0x00 { run += 1; @@ -246,13 +245,12 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option { dest[di] = if full_block { 0xFF } else { (run + 1) as u8 }; di += 1; - // Phase 2: Copy via const copy_from_slice (Rust 1.93+) → memcpy. + // Copy the run. if run > 0 { if di + run > dest.len() { return None; } - let (src_chunk, _) = src_window.split_at(run); - const_copy(dest, di, src_chunk); + const_copy(dest, di, src_window, run); di += run; } si += run; @@ -297,44 +295,44 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option { /// ``` #[must_use] pub fn decode(src: &[u8], dest: &mut [u8]) -> Option { - let mut si = 0; + let mut src = src; let mut di = 0; - while si < src.len() { - let code = src[si] as usize; - si += 1; + while let Some((&code_byte, rest)) = src.split_first() { + src = rest; + let code = code_byte as usize; if code == 0 { - return None; // unexpected zero in COBS data + return None; } if code == 1 { - // Batch path: count consecutive 0x01 codes, fill zeros in bulk. + // Batch consecutive 0x01 codes and fill zeros in bulk. let mut count = 1usize; - while si < src.len() && src[si] == 0x01 { - si += 1; + while let Some((&next, rest)) = src.split_first() { + if next != 0x01 { + break; + } + src = rest; count += 1; } - // Each 0x01 inserts an implicit zero, except the last at end-of-input. - let zeros = if si < src.len() { count } else { count - 1 }; - if zeros > 0 { - if di + zeros > dest.len() { - return None; - } - dest[di..di + zeros].fill(0); - di += zeros; + let zeros = if !src.is_empty() { count } else { count - 1 }; + if di + zeros > dest.len() { + return None; } + dest[di..di + zeros].fill(0); + di += zeros; continue; } // Bulk-copy `code - 1` data bytes. let n = code - 1; - if si + n > src.len() || di + n > dest.len() { + if src.len() < n || di + n > dest.len() { return None; } - dest[di..di + n].copy_from_slice(&src[si..si + n]); + let (block, rest) = src.split_at(n); + dest[di..di + n].copy_from_slice(block); di += n; - si += n; - // If code < 0xFF, an implicit zero follows (unless we're at the end). - if code != 0xFF && si < src.len() { + src = rest; + if code != 0xFF && !src.is_empty() { if di >= dest.len() { return None; }