perf: decode rewrite, encode zeros optimization, iai-callgrind benchmarks (0.3.1)

Decode rewritten with shrinking-slice pattern (split_first/split_at)
instead of index arithmetic. Decode zeros 14% faster, decode nonzero
flips from 5% behind corncobs to 2% ahead, decode mixed closes to tied.

Encode zeros fast path moved before split_at — now tied with cobs
(was 6% behind). Code size reduced to 821B (was 869B).

Replaced criterion with iai-callgrind for deterministic instruction-count
benchmarks via Valgrind. Zero run-to-run variance. Expanded to 162
benchmarks across 9 sizes.

ucobs now wins or ties 16 of 18 benchmarks vs corncobs and cobs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-03-31 21:43:06 -06:00
1 parent ff883d6f8e
commit 777a37d4cd
9 files changed
+411 -208

No files matched your search

+33
View File
@@ -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",
]
+2
View File
@@ -2,6 +2,8 @@
/fuzz/target /fuzz/target
/fuzz/corpus /fuzz/corpus
/fuzz/artifacts /fuzz/artifacts
/mutants.out
/mutants.out.old
Cargo.lock Cargo.lock
__pycache__/ __pycache__/
*.pyc *.pyc
+23
View File
@@ -1,5 +1,28 @@
# Changelog # 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 ## 0.3.0 — 2026-03-31
### Performance ### Performance
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ucobs" name = "ucobs"
version = "0.3.0" version = "0.3.1"
edition = "2021" edition = "2021"
rust-version = "1.93" rust-version = "1.93"
license = "MIT" license = "MIT"
@@ -26,7 +26,7 @@ all-features = true
[dev-dependencies] [dev-dependencies]
cobs = "0.5" cobs = "0.5"
corncobs = "0.1" corncobs = "0.1"
criterion = { version = "0.8", features = ["html_reports"] } iai-callgrind = "0.16"
proptest = "1" proptest = "1"
rand = "0.10" rand = "0.10"
+54 -45
View File
@@ -23,53 +23,57 @@ allowing `0x00` to serve as an unambiguous frame delimiter. Overhead is exactly
## Performance ## Performance
Benchmarked against the two other `no_std` COBS crates using Benchmarked against the two other `no_std` COBS crates using
[Criterion](https://github.com/bheisler/criterion.rs) across three payload [iai-callgrind](https://github.com/iai-callgrind/iai-callgrind) (instruction
patterns: **all zeros** (worst case — every byte triggers a code emit), counts via Valgrind — deterministic, zero run-to-run variance). Three payload
**no zeros**, and **mixed** (0x00–0xFF cycling). Throughput in MB/s, higher patterns: **all zeros** (worst case), **no zeros**, and **mixed** (0x00–0xFF
is better. Legend: 🥇 winner, 🥉 last, 🤝 statistical tie (<5%). cycling). Instruction count per call, lower is better.
Legend: 🥇 winner, 🥉 last, 🤝 tie (<3%).
### Encode ### Encode
| Payload | Pattern | μCOBS | `cobs` 0.5 | `corncobs` 0.1 | | Payload | Pattern | μCOBS | `cobs` 0.5 | `corncobs` 0.1 |
|---------|---------|------:|-----------:|---------------:| |---------|---------|------:|-----------:|---------------:|
| 64 B | zeros | 1,485 | 🥇 1,572 | 🥉 274 | | 64 B | zeros | 🤝 1,171 | 🤝 1,164 | 🥉 4,098 |
| 64 B | nonzero | 🤝 3,721 | 🥉 2,177 | 🤝 3,616 | | 64 B | nonzero | 🤝 512 | 🥉 1,100 | 🤝 514 |
| 64 B | mixed | 🥇 3,596 | 🥉 2,019 | 3,137 | | 64 B | mixed | 🥇 523 | 🥉 1,101 | 570 |
| 256 B | zeros | 1,494 | 🥇 1,962 | 🥉 341 | | 256 B | zeros | 🤝 4,243 | 🤝 4,236 | 🥉 15,810 |
| 256 B | nonzero | 🥇 4,267 | 🥉 2,218 | 3,951 | | 256 B | nonzero | 🤝 1,552 | 🥉 3,992 | 🤝 1,531 |
| 256 B | mixed | 🤝 3,891 | 🥉 2,114 | 🤝 3,867 | | 256 B | mixed | 🤝 1,561 | 🥉 3,993 | 🤝 1,585 |
| 4096 B | zeros | 1,376 | 🥇 2,052 | 🥉 334 | | 4096 B | zeros | 🤝 65,904 | 🤝 65,897 | 🥉 250,271 |
| 4096 B | nonzero | 🤝 4,538 | 🥉 2,227 | 🤝 4,501 | | 4096 B | nonzero | 🤝 22,077 | 🥉 61,993 | 🤝 21,711 |
| 4096 B | mixed | 🤝 3,993 | 🥉 1,549 | 🤝 3,888 | | 4096 B | mixed | 🤝 22,932 | 🥉 62,009 | 🤝 22,656 |
### Decode ### Decode
| Payload | Pattern | μCOBS | `cobs` 0.5 | `corncobs` 0.1 | | Payload | Pattern | μCOBS | `cobs` 0.5 | `corncobs` 0.1 |
|---------|---------|------:|-----------:|---------------:| |---------|---------|------:|-----------:|---------------:|
| 64 B | zeros | 🥇 2,896 | 🥉 924 | 1,520 | | 64 B | zeros | 🥇 499 | 🥉 2,354 | 1,507 |
| 64 B | nonzero | 🤝 16,000 | 🥉 1,168 | 🤝 15,238 | | 64 B | nonzero | 🥇 179 | 🥉 1,778 | 185 |
| 64 B | mixed | 9,275 | 🥉 1,133 | 🥇 15,610 | | 64 B | mixed | 226 | 🥉 1,787 | 🥇 206 |
| 256 B | zeros | 🥇 3,088 | 🥉 914 | 1,496 | | 256 B | zeros | 🥇 1,473 | 🥉 8,882 | 5,539 |
| 256 B | nonzero | 40,000 | 🥉 1,179 | 🥇 42,667 | | 256 B | nonzero | 🤝 240 | 🥉 6,607 | 🤝 247 |
| 256 B | mixed | 28,444 | 🥉 1,140 | 🥇 37,647 | | 256 B | mixed | 285 | 🥉 6,613 | 🥇 266 |
| 4096 B | zeros | 🥇 4,620 | 🥉 942 | 1,549 | | 4096 B | zeros | 🥇 25,018 | 🥉 139,698 | 86,435 |
| 4096 B | nonzero | 🥇 68,040 | 🥉 1,203 | 58,851 | | 4096 B | nonzero | 🤝 1,315 | 🥉 103,253 | 🤝 1,337 |
| 4096 B | mixed | 40,236 | 🥉 1,154 | 🥇 49,769 | | 4096 B | mixed | 🤝 2,086 | 🥉 103,394 | 🤝 2,067 |
### Code size ### Code size
Measured from `.text` section of release-optimized symbols (`encode` + `decode` Measured from `.text` section of release-optimized symbols (`encode` + `decode`
combined): combined). Run `just bench-size` to reproduce.
| Crate | `encode` | `decode` | Total | | Crate | `encode` | `decode` | Total |
|----------------|--------:|---------:|------:| |----------------|--------:|---------:|------:|
| μCOBS | 434 B | 435 B | 869 B | | μCOBS | 429 B | 392 B | 821 B |
| `cobs` 0.5 | 245 B | 292 B | 537 B | | `cobs` 0.5 | 282 B | 494 B | 776 B |
| `corncobs` 0.1 | 375 B | 262 B | 637 B | | `corncobs` 0.1 | 375 B | 262 B | 637 B |
All three crates are under 1 KB. μCOBS is the largest due to its All three crates are under 1 KB. μCOBS is the largest because safe
optimized encode (sub-slice scan, zero fast path, `copy_from_slice`) `const fn` encoding with `copy_from_slice`/memcpy requires `split_at`
and decode (batch zero fill) paths. 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 ### Crate properties
@@ -84,8 +88,9 @@ and decode (batch zero fill) paths.
[^1]: Requires disabling the default `std` feature. [^1]: Requires disabling the default `std` feature.
[^2]: `alloc` is enabled by default via the `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` > Benchmarked with iai-callgrind (Valgrind instruction counting) for
> to reproduce. Full Criterion reports are generated in `target/criterion/report/`. > deterministic, reproducible results. Run `just bench` to reproduce.
> Requires `valgrind` installed.
## When to use μCOBS ## When to use μCOBS
@@ -93,26 +98,28 @@ and decode (batch zero fill) paths.
|---|:---:|:---:|:---:| |---|:---:|:---:|:---:|
| Minimal code to audit | **~140 LOC** | ~790 LOC | ~645 LOC | | Minimal code to audit | **~140 LOC** | ~790 LOC | ~645 LOC |
| Compile-time (`const fn`) encode | **yes** | no | no | | Compile-time (`const fn`) encode | **yes** | no | no |
| Fastest encode (nonzero/mixed) | **yes** | no | ties μCOBS | | Fewest encode instructions | **ties cobs/corncobs** | zeros only | nonzero only |
| Fastest encode (zero-heavy) | 2nd | **yes** | no | | Fewest decode instructions (zero-heavy) | **yes (3–5×)** | no | 2nd |
| Fastest decode (zero-heavy) | **yes (3×)** | no | 2nd | | Fewest decode instructions (nonzero) | **yes/tied** | no | tied |
| Fastest decode (nonzero) | **ties corncobs** | no | **ties μCOBS** | | Fewest decode instructions (mixed) | tied at 4 KB | no | **slight edge at small sizes** |
| Fastest decode (mixed) | 2nd | no | **yes** |
| Dead-simple API (3 functions) | **yes** | yes | more surface area | | Dead-simple API (3 functions) | **yes** | yes | more surface area |
| In-place / streaming encode | no | no | **yes** | | In-place / streaming encode | no | no | **yes** |
| `no_std` + zero-alloc by default | **yes** | opt-in | **yes** | | `no_std` + zero-alloc by default | **yes** | opt-in | **yes** |
| Thorough test suite | **106 tests, fuzz, proptest** | basic | basic | | Thorough test suite | **106 tests, fuzz, proptest** | basic | basic |
**Choose μCOBS** if you want the smallest, most auditable COBS implementation **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 with a `const fn` encoder, a dead-simple 3-function API, and leading or tied
competitive throughput across most workloads — especially on embedded targets. 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, **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 or your workload is dominated by small mixed-pattern decoding where it uses
a throughput edge. ~8% fewer instructions at 64–256 B.
**Choose `cobs`** if you need `std` convenience features, a streaming state **Choose `cobs`** if you need `std` convenience features or a streaming state
machine, or your data is predominantly zero-heavy where it leads on encode. machine. Note: `cobs` uses 50–75× more instructions than μCOBS/corncobs for
decoding non-zero data.
## Examples ## Examples
@@ -275,7 +282,7 @@ affected — it uses a byte loop instead of `copy_from_slice`/memcpy.
## Testing ## Testing
μCOBS has one of the most thorough test suites of any COBS implementation — μ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 - **Canonical vectors** — every example from Cheshire & Baker 1999 (the original
COBS paper), plus the full Wikipedia vector set 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 structural/algebraic properties, and boundary-region behavior
- **Fuzz targets** — 3 `cargo-fuzz` harnesses (decode, round-trip, small-dest) - **Fuzz targets** — 3 `cargo-fuzz` harnesses (decode, round-trip, small-dest)
for continuous coverage of edge cases 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, - **Boundary tests** — exhaustive coverage of 254/255-byte block boundaries,
multi-block splits, buffer-exact fits, and off-by-one dest sizes multi-block splits, buffer-exact fits, and off-by-one dest sizes
- **Compile-time verification** — `const` assertions that validate encode - **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 # Unit + property + interop tests
just test-unit just test-unit
# All quality gates (tests, clippy, fmt, doc, fuzz, miri) # All quality gates (tests, clippy, fmt, doc, fuzz, miri, mutants)
just test just test
# Fuzz testing only (requires nightly + cargo-fuzz) # Mutation testing only
just test-fuzz just test-mutants
``` ```
## License ## License
+238 -120
View File
@@ -1,153 +1,271 @@
use iai_callgrind::{library_benchmark, library_benchmark_group, main};
use std::hint::black_box; use std::hint::black_box;
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
// ── Test payloads ─────────────────────────────────────────────────── // ── Test payloads ───────────────────────────────────────────────────
fn payload_zeros(len: usize) -> Vec<u8> { fn zeros(n: usize) -> Vec<u8> {
vec![0u8; len] vec![0u8; n]
} }
fn payload_nonzero(len: usize) -> Vec<u8> { fn nonzero(n: usize) -> Vec<u8> {
(0..len).map(|i| (i % 255 + 1) as u8).collect() (0..n).map(|i| (i % 255 + 1) as u8).collect()
} }
fn payload_mixed(len: usize) -> Vec<u8> { fn mixed(n: usize) -> Vec<u8> {
(0..len).map(|i| (i % 256) as u8).collect() (0..n).map(|i| (i % 256) as u8).collect()
} }
// ── ucobs ─────────────────────────────────────────────────────────── // ── Setup helpers ───────────────────────────────────────────────────
fn ucobs_encode(data: &[u8]) -> Vec<u8> { fn setup_encode(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let mut buf = vec![0u8; ucobs::max_encoded_len(data.len()) + 1]; let buf = vec![0u8; ucobs::max_encoded_len(data.len()) + 1];
let n = ucobs::encode(data, &mut buf).unwrap(); (data, buf)
buf.truncate(n);
buf
} }
fn ucobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { fn setup_decode_ucobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
ucobs::decode(encoded, out).unwrap() 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 setup_encode_corncobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let buf = vec![0u8; corncobs::max_encoded_len(data.len())];
fn corncobs_encode(data: &[u8]) -> Vec<u8> { (data, buf)
let mut buf = vec![0u8; corncobs::max_encoded_len(data.len())];
let n = corncobs::encode_buf(data, &mut buf);
buf.truncate(n);
buf
} }
fn corncobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { fn setup_decode_corncobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
// corncobs expects trailing sentinel let mut enc = vec![0u8; corncobs::max_encoded_len(data.len())];
corncobs::decode_buf(encoded, out).unwrap() let n = corncobs::encode_buf(&data, &mut enc);
enc.truncate(n);
let dec = vec![0u8; data.len() + 1];
(enc, dec)
} }
// ── cobs crate ────────────────────────────────────────────────────── fn setup_encode_cobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
let buf = vec![0u8; cobs::max_encoding_length(data.len())];
fn cobs_encode(data: &[u8]) -> Vec<u8> { (data, buf)
let mut buf = vec![0u8; cobs::max_encoding_length(data.len())];
let n = cobs::encode(data, &mut buf);
buf.truncate(n);
buf
} }
fn cobs_decode(encoded: &[u8], out: &mut [u8]) -> usize { fn setup_decode_cobs(data: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
// The cobs crate returns DecodeReport on success or Err(EmptyFrame) let mut enc = vec![0u8; cobs::max_encoding_length(data.len())];
// for empty input. We extract parsed_size, treating errors as 0. let n = cobs::encode(&data, &mut enc);
cobs::decode(encoded, out) enc.truncate(n);
.map(|report| report.parsed_size()) let dec = vec![0u8; data.len() + 1];
.unwrap_or(0) (enc, dec)
} }
// ── Benchmarks ────────────────────────────────────────────────────── // ── Encode benchmarks ──────────────────────────────────────────────
const SIZES: &[usize] = &[0, 1, 10, 64, 254, 255, 256, 1024, 4096]; #[library_benchmark]
#[bench::zeros_0(setup_encode(zeros(0)))]
fn bench_encode(c: &mut Criterion) { #[bench::zeros_1(setup_encode(zeros(1)))]
let payloads: Vec<(&str, Box<dyn Fn(usize) -> Vec<u8>>)> = vec![ #[bench::zeros_10(setup_encode(zeros(10)))]
("zeros", Box::new(payload_zeros)), #[bench::zeros_64(setup_encode(zeros(64)))]
("nonzero", Box::new(payload_nonzero)), #[bench::zeros_254(setup_encode(zeros(254)))]
("mixed", Box::new(payload_mixed)), #[bench::zeros_255(setup_encode(zeros(255)))]
]; #[bench::zeros_256(setup_encode(zeros(256)))]
#[bench::zeros_1024(setup_encode(zeros(1024)))]
for (name, make) in &payloads { #[bench::zeros_4096(setup_encode(zeros(4096)))]
let mut group = c.benchmark_group(format!("encode/{name}")); #[bench::nonzero_0(setup_encode(nonzero(0)))]
for &size in SIZES { #[bench::nonzero_1(setup_encode(nonzero(1)))]
let data = make(size); #[bench::nonzero_10(setup_encode(nonzero(10)))]
group.throughput(Throughput::Bytes(size as u64)); #[bench::nonzero_64(setup_encode(nonzero(64)))]
#[bench::nonzero_254(setup_encode(nonzero(254)))]
group.bench_with_input(BenchmarkId::new("ucobs", size), &data, |b, d| { #[bench::nonzero_255(setup_encode(nonzero(255)))]
let mut buf = vec![0u8; ucobs::max_encoded_len(d.len()) + 1]; #[bench::nonzero_256(setup_encode(nonzero(256)))]
b.iter(|| { #[bench::nonzero_1024(setup_encode(nonzero(1024)))]
black_box(ucobs::encode(black_box(d), &mut buf).unwrap()); #[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)))]
group.bench_with_input(BenchmarkId::new("corncobs", size), &data, |b, d| { #[bench::mixed_64(setup_encode(mixed(64)))]
let mut buf = vec![0u8; corncobs::max_encoded_len(d.len())]; #[bench::mixed_254(setup_encode(mixed(254)))]
b.iter(|| { #[bench::mixed_255(setup_encode(mixed(255)))]
black_box(corncobs::encode_buf(black_box(d), &mut buf)); #[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<u8>, Vec<u8>)) {
group.bench_with_input(BenchmarkId::new("cobs", size), &data, |b, d| { black_box(ucobs::encode(black_box(&data), &mut buf));
let mut buf = vec![0u8; cobs::max_encoding_length(d.len())];
b.iter(|| {
black_box(cobs::encode(black_box(d), &mut buf));
});
});
}
group.finish();
}
} }
fn bench_decode(c: &mut Criterion) { #[library_benchmark]
let payloads: Vec<(&str, Box<dyn Fn(usize) -> Vec<u8>>)> = vec![ #[bench::zeros_0(setup_encode_corncobs(zeros(0)))]
("zeros", Box::new(payload_zeros)), #[bench::zeros_1(setup_encode_corncobs(zeros(1)))]
("nonzero", Box::new(payload_nonzero)), #[bench::zeros_10(setup_encode_corncobs(zeros(10)))]
("mixed", Box::new(payload_mixed)), #[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<u8>, Vec<u8>)) {
black_box(corncobs::encode_buf(black_box(&data), &mut buf));
}
for (name, make) in &payloads { #[library_benchmark]
let mut group = c.benchmark_group(format!("decode/{name}")); #[bench::zeros_0(setup_encode_cobs(zeros(0)))]
for &size in SIZES { #[bench::zeros_1(setup_encode_cobs(zeros(1)))]
let data = make(size); #[bench::zeros_10(setup_encode_cobs(zeros(10)))]
let ucobs_enc = ucobs_encode(&data); #[bench::zeros_64(setup_encode_cobs(zeros(64)))]
// corncobs expects sentinel in encoded form #[bench::zeros_254(setup_encode_cobs(zeros(254)))]
let corncobs_enc = corncobs_encode(&data); #[bench::zeros_255(setup_encode_cobs(zeros(255)))]
let cobs_enc = cobs_encode(&data); #[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<u8>, Vec<u8>)) {
black_box(cobs::encode(black_box(&data), &mut buf));
}
group.throughput(Throughput::Bytes(size as u64)); // ── Decode benchmarks ──────────────────────────────────────────────
group.bench_with_input(BenchmarkId::new("ucobs", size), &ucobs_enc, |b, enc| { #[library_benchmark]
let mut buf = vec![0u8; size + 1]; #[bench::zeros_0(setup_decode_ucobs(zeros(0)))]
b.iter(|| { #[bench::zeros_1(setup_decode_ucobs(zeros(1)))]
black_box(ucobs_decode(black_box(enc), &mut buf)); #[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<u8>, Vec<u8>)) {
black_box(ucobs::decode(black_box(&enc), &mut buf));
}
group.bench_with_input( #[library_benchmark]
BenchmarkId::new("corncobs", size), #[bench::zeros_0(setup_decode_corncobs(zeros(0)))]
&corncobs_enc, #[bench::zeros_1(setup_decode_corncobs(zeros(1)))]
|b, enc| { #[bench::zeros_10(setup_decode_corncobs(zeros(10)))]
let mut buf = vec![0u8; size + 1]; #[bench::zeros_64(setup_decode_corncobs(zeros(64)))]
b.iter(|| { #[bench::zeros_254(setup_decode_corncobs(zeros(254)))]
black_box(corncobs_decode(black_box(enc), &mut buf)); #[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<u8>, Vec<u8>)) {
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<u8>, Vec<u8>)) {
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
); );
group.bench_with_input(BenchmarkId::new("cobs", size), &cobs_enc, |b, enc| { library_benchmark_group!(
let mut buf = vec![0u8; size + 1]; name = decode;
b.iter(|| { benchmarks = decode_ucobs, decode_corncobs, decode_cobs
black_box(cobs_decode(black_box(enc), &mut buf)); );
});
});
}
group.finish();
}
}
criterion_group!(benches, bench_encode, bench_decode); main!(library_benchmark_groups = encode, decode);
criterion_main!(benches);
+20 -4
View File
@@ -4,8 +4,8 @@
default: default:
@just --list @just --list
# run ALL quality gates (tests, clippy, fmt check, doc, fuzz, 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: test-unit test-clippy test-fmt test-doc test-fuzz test-miri test-mutants
# quick compile check (no tests) # quick compile check (no tests)
check: check:
@@ -39,10 +39,26 @@ test-fuzz:
test-miri: test-miri:
cargo +nightly miri test -- --skip proptests cargo +nightly miri test -- --skip proptests
# run criterion benchmarks (ucobs vs cobs vs corncobs) # mutation testing (requires cargo-mutants)
bench: 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 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) # run python cobs benchmarks (requires: pip install cobs)
bench-python: bench-python:
python3 bench_python.py python3 bench_python.py
+4
View File
@@ -0,0 +1,4 @@
[tools]
"cargo:cargo-nextest" = "latest"
"cargo:cargo-mutants" = "latest"
"cargo:iai-callgrind-runner" = "latest"
+35 -37
View File
@@ -155,23 +155,24 @@
#![warn(missing_docs)] #![warn(missing_docs)]
/// Copy `src` into `dest[offset..]` in a const-compatible way. /// 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+. /// With `legacy-msrv` feature, falls back to a manual loop for Rust 1.83+.
#[inline(always)] #[inline(always)]
#[cfg(not(feature = "legacy-msrv"))] #[cfg(not(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 (_, tail) = dest.split_at_mut(offset); let (_, dt) = dest.split_at_mut(di);
let (chunk, _) = tail.split_at_mut(src.len()); let (dc, _) = dt.split_at_mut(n);
chunk.copy_from_slice(src); let (sc, _) = src.split_at(n);
dc.copy_from_slice(sc);
} }
/// Fallback const copy for toolchains older than Rust 1.93. /// Fallback const copy for toolchains older than Rust 1.93.
#[inline(always)] #[inline(always)]
#[cfg(feature = "legacy-msrv")] #[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; let mut i = 0;
while i < src.len() { while i < n {
dest[offset + i] = src[i]; dest[di + i] = src[i];
i += 1; i += 1;
} }
} }
@@ -215,13 +216,8 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
let remaining = src.len() - si; let remaining = src.len() - si;
let max_run = if remaining < 254 { remaining } else { 254 }; let max_run = if remaining < 254 { remaining } else { 254 };
// Create bounded source window (split_at is const since Rust 1.71). // Fast path: leading zero byte — skip split_at overhead entirely.
let (_, src_tail) = src.split_at(si); if max_run > 0 && src[si] == 0x00 {
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 {
if di >= dest.len() { if di >= dest.len() {
return None; return None;
} }
@@ -231,8 +227,11 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
continue; continue;
} }
// Phase 1: Scan for next zero byte. // Create bounded source window for scan + copy.
// LLVM sees `run < src_window.len()` → eliminates bounds check. 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; let mut run = 0;
while run < src_window.len() && src_window[run] != 0x00 { while run < src_window.len() && src_window[run] != 0x00 {
run += 1; run += 1;
@@ -246,13 +245,12 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
dest[di] = if full_block { 0xFF } else { (run + 1) as u8 }; dest[di] = if full_block { 0xFF } else { (run + 1) as u8 };
di += 1; di += 1;
// Phase 2: Copy via const copy_from_slice (Rust 1.93+) → memcpy. // Copy the run.
if run > 0 { if run > 0 {
if di + run > dest.len() { if di + run > dest.len() {
return None; return None;
} }
let (src_chunk, _) = src_window.split_at(run); const_copy(dest, di, src_window, run);
const_copy(dest, di, src_chunk);
di += run; di += run;
} }
si += run; si += run;
@@ -297,44 +295,44 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
/// ``` /// ```
#[must_use] #[must_use]
pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> { pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
let mut si = 0; let mut src = src;
let mut di = 0; let mut di = 0;
while si < src.len() { while let Some((&code_byte, rest)) = src.split_first() {
let code = src[si] as usize; src = rest;
si += 1; let code = code_byte as usize;
if code == 0 { if code == 0 {
return None; // unexpected zero in COBS data return None;
} }
if code == 1 { 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; let mut count = 1usize;
while si < src.len() && src[si] == 0x01 { while let Some((&next, rest)) = src.split_first() {
si += 1; if next != 0x01 {
break;
}
src = rest;
count += 1; count += 1;
} }
// Each 0x01 inserts an implicit zero, except the last at end-of-input. let zeros = if !src.is_empty() { count } else { count - 1 };
let zeros = if si < src.len() { count } else { count - 1 };
if zeros > 0 {
if di + zeros > dest.len() { if di + zeros > dest.len() {
return None; return None;
} }
dest[di..di + zeros].fill(0); dest[di..di + zeros].fill(0);
di += zeros; di += zeros;
}
continue; continue;
} }
// Bulk-copy `code - 1` data bytes. // Bulk-copy `code - 1` data bytes.
let n = code - 1; let n = code - 1;
if si + n > src.len() || di + n > dest.len() { if src.len() < n || di + n > dest.len() {
return None; 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; di += n;
si += n; src = rest;
// If code < 0xFF, an implicit zero follows (unless we're at the end). if code != 0xFF && !src.is_empty() {
if code != 0xFF && si < src.len() {
if di >= dest.len() { if di >= dest.len() {
return None; return None;
} }