perf: major encode/decode optimization via const copy_from_slice and batch zero fill

Encode: rewrite to two-pass (scan + copy_from_slice) leveraging Rust 1.93's
const fn copy_from_slice for memcpy at runtime while keeping const fn encode.
Sub-slice scan pattern helps LLVM eliminate bounds checks. Added zero byte
fast path for all-zeros workloads. Now leads corncobs on nonzero/mixed encode.

Decode: batch consecutive 0x01 codes (all-zeros data) and fill zeros via
memset instead of per-byte loop. Decode zeros throughput jumps from ~1.2 GB/s
to ~4.6 GB/s, now 3x faster than corncobs on that workload.

Bump MSRV 1.83 → 1.93 for const copy_from_slice.
Update dev-deps: cobs 0.3→0.5, criterion 0.5→0.8, rand 0.8→0.10.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-03-30 23:00:33 -06:00
1 parent 3d674989ab
commit adb0db7ee5
4 files changed
+73 -27

No files matched your search

+4 -4
View File
@@ -2,7 +2,7 @@
name = "ucobs"
version = "0.2.0"
edition = "2021"
rust-version = "1.83"
rust-version = "1.93"
license = "MIT"
description = "μCOBS — Consistent Overhead Byte Stuffing. no_std, zero-alloc, fastest, most-tested COBS implementation."
authors = ["Stephen Waits <steve@waits.net>"]
@@ -23,11 +23,11 @@ unsafe_code = "forbid"
all-features = true
[dev-dependencies]
cobs = "0.3"
cobs = "0.5"
corncobs = "0.1"
criterion = { version = "0.5", features = ["html_reports"] }
criterion = { version = "0.8", features = ["html_reports"] }
proptest = "1"
rand = "0.8"
rand = "0.10"
[[bench]]
name = "bench"
+8 -4
View File
@@ -1,4 +1,6 @@
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
// ── Test payloads ───────────────────────────────────────────────────
@@ -51,9 +53,11 @@ fn cobs_encode(data: &[u8]) -> Vec<u8> {
}
fn cobs_decode(encoded: &[u8], out: &mut [u8]) -> usize {
// unwrap_or(0): the cobs crate returns Err(EmptyFrame) for empty input,
// which is valid COBS. We treat it as 0 decoded bytes for benchmarking.
cobs::decode(encoded, out).unwrap_or(0)
// 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)
}
// ── Benchmarks ──────────────────────────────────────────────────────
+10
View File
@@ -0,0 +1,10 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 00411472de4d6cdb296c04bc52fd533354bc25ba1765807da8b7994868fcb987 # shrinks to len = 1
cc 81ebf688f1dd5ea2a735756a7fb703b92c2a2033057ae91886577c6a63763cf5 # shrinks to data = [0]
cc 32d0d2d26e2be6ec66b432f1e9baee378777b1a00b959922cff7a5784292ec4b # shrinks to data = [0]
cc fe447e0e58f4c0b21f6e1b38a90bd33578c968967c956964a06013a4df977d68 # shrinks to data = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
+51 -19
View File
@@ -184,14 +184,30 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
let mut di = 0;
loop {
// Scan for next zero (or 254-byte cap).
let remaining = src.len() - si;
let max_run = if remaining < 254 { remaining } else { 254 };
let mut run = 0;
if max_run > 0 && src[si] != 0x00 {
while run < max_run && src[si + run] != 0x00 {
run += 1;
// 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 {
if di >= dest.len() {
return None;
}
dest[di] = 0x01;
di += 1;
si += 1;
continue;
}
// Phase 1: Scan for next zero byte.
// LLVM sees `run < src_window.len()` → eliminates bounds check.
let mut run = 0;
while run < src_window.len() && src_window[run] != 0x00 {
run += 1;
}
let full_block = run == 254;
@@ -202,28 +218,24 @@ pub const fn encode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
dest[di] = if full_block { 0xFF } else { (run + 1) as u8 };
di += 1;
// Copy the run.
// Phase 2: Copy via const copy_from_slice (Rust 1.93+) → memcpy.
if run > 0 {
if di + run > dest.len() {
return None;
}
let mut i = 0;
while i < run {
dest[di + i] = src[si + i];
i += 1;
}
let (_, dest_tail) = dest.split_at_mut(di);
let (dest_chunk, _) = dest_tail.split_at_mut(run);
let (src_chunk, _) = src_window.split_at(run);
dest_chunk.copy_from_slice(src_chunk);
di += run;
}
si += run;
if full_block {
// 254 non-zero bytes emitted; no implicit zero.
// If input is exhausted exactly here, we're done.
if si >= src.len() {
break;
}
} else {
// Run ended at a zero or end-of-input.
if si >= src.len() {
break;
}
@@ -267,6 +279,26 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
if code == 0 {
return None; // unexpected zero in COBS data
}
if code == 1 {
// Batch path: count consecutive 0x01 codes, fill zeros in bulk.
let mut count = 1usize;
while si < src.len() && src[si] == 0x01 {
si += 1;
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;
}
continue;
}
// Bulk-copy `code - 1` data bytes.
let n = code - 1;
if si + n > src.len() || di + n > dest.len() {
@@ -276,7 +308,7 @@ pub fn decode(src: &[u8], dest: &mut [u8]) -> Option<usize> {
di += n;
si += n;
// If code < 0xFF, an implicit zero follows (unless we're at the end).
if code < 0xFF && si < src.len() {
if code != 0xFF && si < src.len() {
if di >= dest.len() {
return None;
}
@@ -910,11 +942,11 @@ mod tests {
#[test]
fn interop_random_payloads() {
use rand::Rng;
let mut rng = rand::thread_rng();
use rand::RngExt;
let mut rng = rand::rng();
for _ in 0..500 {
let len = rng.gen_range(0..=512);
let data: Vec<u8> = (0..len).map(|_| rng.gen()).collect();
let len = rng.random_range(0..=512);
let data: Vec<u8> = (0..len).map(|_| rng.random()).collect();
cross_encode(&data);
cross_decode(&data);
}