this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add best-effort rust impl (minicbor + bumpalo + sync CAR)

zero-copy CBOR via minicbor, bump arena allocation, hand-rolled
sync CAR parser. narrows the zig gap from ~10x to ~2.5x, showing
the difference is SDK architecture (jacquard: 48k fps) vs language
(raw: 244k fps vs zat: 628k fps).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz afcbd24b dc2b7867

+531 -13
+15 -11
README.md
··· 21 21 22 22 | SDK | frames/sec (median) | MB/s | blocks/frame | errors | 23 23 |-----|--------:|-----:|-----:|-----:| 24 - | zig ([zat](https://tangled.sh/@zzstoatzz.io/zat), arena reuse) | 461,827 | 2,268.9 | 9.98 | 0 | 25 - | zig (zat, alloc per frame) | 395,485 | 1,890.0 | 9.98 | 0 | 26 - | rust ([jacquard](https://github.com/rsform/jacquard)) | 42,023 | 203.5 | 9.98 | 0 | 27 - | python ([atproto](https://github.com/MarshalX/atproto)) | 24,026 | 118.0 | 9.98 | 0 | 28 - | go ([indigo](https://github.com/bluesky-social/indigo)) | 10,896 | 53.3 | 9.98 | 0 | 24 + | zig ([zat](https://tangled.sh/@zzstoatzz.io/zat), arena reuse) | 628,091 | 3,044.8 | 9.98 | 0 | 25 + | zig (zat, alloc per frame) | 559,825 | 2,662.0 | 9.98 | 0 | 26 + | rust (raw, arena reuse) | 244,113 | 1,171.0 | 9.98 | 0 | 27 + | rust (raw, alloc per frame) | 186,962 | 919.4 | 9.98 | 0 | 28 + | rust ([jacquard](https://github.com/rsform/jacquard)) | 47,881 | 238.9 | 9.98 | 0 | 29 + | python ([atproto](https://github.com/MarshalX/atproto)) | 29,675 | 146.1 | 9.98 | 0 | 30 + | go ([indigo](https://github.com/bluesky-social/indigo)) | 11,548 | 58.0 | 9.98 | 0 | 29 31 30 32 run-to-run variance is ~30-40%. compare ratios within a single `just bench` run, not across runs. 31 33 ··· 36 38 | SDK | decode path | 37 39 |-----|-------------| 38 40 | zig | `cbor.decode` header → `cbor.decodeAll` payload → `car.read` → `cbor.decodeAll` per block | 39 - | rust | `SubscribeReposMessage::decode_framed` → typed `Commit`, `jacquard_repo::car::parse_car_bytes` → blocks, `serde_ipld_dagcbor` per block | 41 + | rust (raw) | `minicbor::Decoder` header → payload → hand-rolled sync CAR → `minicbor` + `bumpalo` per block | 42 + | rust (jacquard) | `SubscribeReposMessage::decode_framed` → typed `Commit`, `jacquard_repo::car::parse_car_bytes` → blocks, `serde_ipld_dagcbor` per block | 40 43 | go | `evt.Deserialize` → typed `RepoCommit` via code-gen CBOR → `car.NewBlockReader` → `cbornode.DecodeInto` per block | 41 44 | python | `Frame.from_bytes` + `parse_subscribe_repos_message` → `CAR.from_bytes` (libipld decodes all blocks internally) | 42 45 43 46 ## fairness notes 44 47 45 - - **zig** uses arena allocation (1 malloc/free per frame); rust/go/python use standard per-object allocators. the "alloc per frame" variant is the fair cross-language comparison; "arena reuse" shows the production pattern 46 - - **zig** returns zero-copy slices into the input buffer for strings and byte data; the other SDKs copy into owned allocations. this is a real architectural advantage, not a benchmark trick 48 + - **zig** and **rust (raw)** both use arena allocation + zero-copy string/byte decoding. the "alloc per frame" variants are the fair cross-language comparison; "arena reuse" shows the production pattern. zig uses its own arena allocator, rust uses bumpalo 49 + - **zig** returns zero-copy slices into the input buffer; rust (raw) does the same via minicbor's borrowed decoder. both avoid copying string/byte data. the remaining ~2.5x gap between zig and rust (raw) is likely due to Value type size (zig's 24-byte union vs rust's larger enum), arena implementation differences, and CBOR parser codegen 50 + - **rust (jacquard)** is the real AT Protocol SDK that rust developers use. it pays for serde-based owned deserialization (`String`, `BTreeMap`), async CAR parsing (tokio poll/wake per block via iroh-car), and per-object heap allocation. the "raw" variant shows what's possible in rust with the same architectural choices as zat 47 51 - **go** indigo — bluesky's own production relay — is the slowest despite using code-generated CBOR unmarshal (no reflection). the cost is GC pressure: every string, byte slice, and block is a heap allocation that the garbage collector has to sweep. at ~10 blocks/frame, that's a lot of short-lived objects per decode 48 - - **python** is faster than the rust and go benchmarks despite being "Python" — its hot path is `libipld` (Rust via PyO3), which does the entire CAR parse + per-block DAG-CBOR decode in one synchronous C-extension call. it uses a different (and faster) Rust library than the rust benchmark does 49 - - **rust** pays async overhead (tokio runtime + iroh-car's async `CarReader`) even though the I/O is an in-memory buffer. every `next_block().await` goes through poll/wake per block (~10 blocks/frame). this is why the rust benchmark is slower than python's libipld, which does the same work synchronously. there's no sync alternative in the iroh-car library 52 + - **python** is faster than jacquard despite being "Python" — its hot path is `libipld` (Rust via PyO3), which does the entire CAR parse + per-block DAG-CBOR decode in one synchronous C-extension call 50 53 - **error handling**: all SDKs use infallible decode functions that never abort on failure — errors are counted and the frame is skipped. this means a corrupted frame doesn't invalidate the entire benchmark run 51 54 - **capture coupling**: the corpus capture tool uses zat's CBOR decoder for the commit-with-ops header peek. this is standard CBOR parsing (not zat's typed firehose decoder), but it does mean frames that zat's CBOR decoder rejects won't appear in the corpus. in practice, CBOR header parsing is the least likely step to diverge across implementations 52 55 ··· 77 80 | lang | SDK | version | CBOR engine | CAR engine | 78 81 |------|-----|---------|-------------|------------| 79 82 | zig | [zat](https://tangled.sh/@zzstoatzz.io/zat) | 0.2.0 | hand-rolled | hand-rolled | 80 - | rust | [jacquard](https://github.com/rsform/jacquard) | 0.9 | [ciborium](https://crates.io/crates/ciborium) (header) + [serde_ipld_dagcbor](https://crates.io/crates/serde_ipld_dagcbor) (body) | [iroh-car](https://crates.io/crates/iroh-car) | 83 + | rust | raw (minicbor + bumpalo) | — | [minicbor](https://crates.io/crates/minicbor) (zero-copy) | hand-rolled (sync) | 84 + | rust | [jacquard](https://github.com/rsform/jacquard) | 0.9 | [ciborium](https://crates.io/crates/ciborium) (header) + [serde_ipld_dagcbor](https://crates.io/crates/serde_ipld_dagcbor) (body) | [iroh-car](https://crates.io/crates/iroh-car) (async) | 81 85 | go | [indigo](https://github.com/bluesky-social/indigo) | latest | [cbor-gen](https://github.com/whyrusleeping/cbor-gen) (code-generated) | [go-car/v2](https://github.com/ipld/go-car) | 82 86 | python | [atproto](https://github.com/MarshalX/atproto) | 0.0.65 | [libipld](https://github.com/MarshalX/atproto) (Rust via PyO3) | libipld | 83 87
+9 -2
justfile
··· 15 15 @echo "--------------------------------------------" 16 16 cd zig && zig build run-bench -Doptimize=ReleaseFast 17 17 @echo "--------------------------------------------" 18 - cd rust && cargo run --release 2>&1 18 + cd rust-raw && cargo run --release 2>&1 19 19 @echo "--------------------------------------------" 20 - cd go && go run . 20 + cd rust && cargo run --release 2>&1 21 21 @echo "--------------------------------------------" 22 22 cd python && uv run bench.py 23 + @echo "--------------------------------------------" 24 + cd go && go run . 23 25 @echo "============================================" 24 26 25 27 # run a single language's bench ··· 29 31 bench-rust: _ensure-fixtures 30 32 cd rust && cargo run --release 31 33 34 + bench-rust-raw: _ensure-fixtures 35 + cd rust-raw && cargo run --release 36 + 32 37 bench-go: _ensure-fixtures 33 38 cd go && go run . 34 39 ··· 39 44 build: 40 45 cd zig && zig build 41 46 cd rust && cargo build --release 47 + cd rust-raw && cargo build --release 42 48 cd go && go build . 43 49 44 50 # clean build artifacts 45 51 clean: 46 52 rm -rf zig/.zig-cache zig/zig-out 47 53 cd rust && cargo clean 54 + cd rust-raw && cargo clean 48 55 rm -f go/atproto-bench 49 56 rm -rf python/.venv 50 57
+30
rust-raw/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "atproto-bench-raw" 7 + version = "0.1.0" 8 + dependencies = [ 9 + "bumpalo", 10 + "minicbor", 11 + "unsigned-varint", 12 + ] 13 + 14 + [[package]] 15 + name = "bumpalo" 16 + version = "3.20.2" 17 + source = "registry+https://github.com/rust-lang/crates.io-index" 18 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 19 + 20 + [[package]] 21 + name = "minicbor" 22 + version = "0.25.1" 23 + source = "registry+https://github.com/rust-lang/crates.io-index" 24 + checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0" 25 + 26 + [[package]] 27 + name = "unsigned-varint" 28 + version = "0.8.0" 29 + source = "registry+https://github.com/rust-lang/crates.io-index" 30 + checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
+15
rust-raw/Cargo.toml
··· 1 + [package] 2 + name = "atproto-bench-raw" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [dependencies] 7 + minicbor = "0.25" 8 + unsigned-varint = "0.8" 9 + bumpalo = { version = "3", features = ["collections"] } 10 + 11 + [profile.release] 12 + opt-level = 3 13 + lto = true 14 + codegen-units = 1 15 + panic = "abort"
+462
rust-raw/src/main.rs
··· 1 + //! atproto firehose benchmarks — rust (minicbor, zero-copy) 2 + //! 3 + //! best-effort rust implementation: zero-copy CBOR via minicbor, 4 + //! sync hand-rolled CAR parser, no async runtime. 5 + //! matches the architectural choices that make zat fast: 6 + //! borrowed strings/bytes, flat map representation, synchronous decode. 7 + 8 + use bumpalo::Bump; 9 + use std::time::Instant; 10 + 11 + const WARMUP_PASSES: usize = 2; 12 + const MEASURED_PASSES: usize = 5; 13 + const FIXTURES_DIR: &str = "../fixtures"; 14 + 15 + // --- value type: borrowed from input buffer, containers in bump arena --- 16 + 17 + enum Value<'a> { 18 + Unsigned(u64), 19 + Signed(i64), 20 + Bytes(&'a [u8]), 21 + Text(&'a str), 22 + Array(&'a [Value<'a>]), 23 + Map(&'a [(&'a str, Value<'a>)]), 24 + Bool(bool), 25 + Null, 26 + Float(f64), 27 + Tag(u64, &'a Value<'a>), 28 + } 29 + 30 + // --- CBOR decode (into bump arena) --- 31 + 32 + fn decode_value<'a>( 33 + dec: &mut minicbor::Decoder<'a>, 34 + bump: &'a Bump, 35 + ) -> Result<Value<'a>, minicbor::decode::Error> { 36 + use minicbor::data::Type; 37 + match dec.datatype()? { 38 + Type::Bool => Ok(Value::Bool(dec.bool()?)), 39 + Type::Null => { 40 + dec.null()?; 41 + Ok(Value::Null) 42 + } 43 + Type::Undefined => { 44 + dec.undefined()?; 45 + Ok(Value::Null) 46 + } 47 + Type::U8 | Type::U16 | Type::U32 | Type::U64 => Ok(Value::Unsigned(dec.u64()?)), 48 + Type::I8 | Type::I16 | Type::I32 | Type::I64 => Ok(Value::Signed(dec.i64()?)), 49 + Type::F16 | Type::F32 | Type::F64 => Ok(Value::Float(dec.f64()?)), 50 + Type::Bytes | Type::BytesIndef => Ok(Value::Bytes(dec.bytes()?)), 51 + Type::String | Type::StringIndef => Ok(Value::Text(dec.str()?)), 52 + Type::Array | Type::ArrayIndef => { 53 + let len = dec.array()?; 54 + match len { 55 + Some(n) => { 56 + let n = n as usize; 57 + let items = bump.alloc_slice_fill_with(n, |_| Value::Null); 58 + for i in 0..n { 59 + items[i] = decode_value(dec, bump)?; 60 + } 61 + Ok(Value::Array(items)) 62 + } 63 + None => { 64 + dec.skip()?; 65 + Ok(Value::Null) 66 + } 67 + } 68 + } 69 + Type::Map | Type::MapIndef => { 70 + let len = dec.map()?; 71 + match len { 72 + Some(n) => { 73 + let n = n as usize; 74 + let entries = bump.alloc_slice_fill_with(n, |_| ("", Value::Null)); 75 + for i in 0..n { 76 + let key = dec.str()?; 77 + let value = decode_value(dec, bump)?; 78 + entries[i] = (key, value); 79 + } 80 + Ok(Value::Map(entries)) 81 + } 82 + None => { 83 + dec.skip()?; 84 + Ok(Value::Null) 85 + } 86 + } 87 + } 88 + Type::Tag => { 89 + let tag = dec.tag()?; 90 + let inner = decode_value(dec, bump)?; 91 + Ok(Value::Tag(tag.as_u64(), bump.alloc(inner))) 92 + } 93 + _ => { 94 + dec.skip()?; 95 + Ok(Value::Null) 96 + } 97 + } 98 + } 99 + 100 + // --- CAR parser (sync, zero-copy) --- 101 + 102 + fn skip_cid(data: &[u8]) -> Result<usize, &'static str> { 103 + if data.is_empty() { 104 + return Err("empty CID"); 105 + } 106 + 107 + // CIDv0: starts with multihash directly (0x12 = sha256, then 0x20 = 32 byte digest) 108 + if data[0] == 0x12 { 109 + if data.len() < 34 { 110 + return Err("truncated CIDv0"); 111 + } 112 + return Ok(34); 113 + } 114 + 115 + // CIDv1: version varint + codec varint + multihash(code varint + digest_len varint + digest) 116 + let (_, rest) = unsigned_varint::decode::u64(data).map_err(|_| "bad version varint")?; 117 + let (_, rest) = unsigned_varint::decode::u64(rest).map_err(|_| "bad codec varint")?; 118 + let (_, rest) = unsigned_varint::decode::u64(rest).map_err(|_| "bad mh code varint")?; 119 + let (digest_len, rest) = 120 + unsigned_varint::decode::u64(rest).map_err(|_| "bad mh digest len varint")?; 121 + let digest_len = digest_len as usize; 122 + if rest.len() < digest_len { 123 + return Err("truncated multihash digest"); 124 + } 125 + 126 + Ok(data.len() - rest.len() + digest_len) 127 + } 128 + 129 + struct CarBlocks<'a> { 130 + remaining: &'a [u8], 131 + } 132 + 133 + impl<'a> CarBlocks<'a> { 134 + fn new(data: &'a [u8]) -> Result<Self, &'static str> { 135 + let (header_len, rest) = 136 + unsigned_varint::decode::u64(data).map_err(|_| "bad header varint")?; 137 + let header_len = header_len as usize; 138 + if rest.len() < header_len { 139 + return Err("truncated CAR header"); 140 + } 141 + Ok(CarBlocks { 142 + remaining: &rest[header_len..], 143 + }) 144 + } 145 + } 146 + 147 + impl<'a> Iterator for CarBlocks<'a> { 148 + type Item = Result<&'a [u8], &'static str>; 149 + 150 + fn next(&mut self) -> Option<Self::Item> { 151 + if self.remaining.is_empty() { 152 + return None; 153 + } 154 + let (section_len, after_varint) = match unsigned_varint::decode::u64(self.remaining) { 155 + Ok(v) => v, 156 + Err(_) => return Some(Err("bad section varint")), 157 + }; 158 + let section_len = section_len as usize; 159 + if after_varint.len() < section_len { 160 + return Some(Err("truncated CAR section")); 161 + } 162 + let section = &after_varint[..section_len]; 163 + let cid_len = match skip_cid(section) { 164 + Ok(n) => n, 165 + Err(e) => return Some(Err(e)), 166 + }; 167 + if cid_len > section.len() { 168 + return Some(Err("CID larger than section")); 169 + } 170 + self.remaining = &after_varint[section_len..]; 171 + Some(Ok(&section[cid_len..])) 172 + } 173 + } 174 + 175 + // --- frame decode --- 176 + 177 + struct DecodeResult { 178 + blocks: usize, 179 + errors: usize, 180 + } 181 + 182 + /// extract blocks bytes from a frame. returns None if not a commit or no blocks. 183 + fn extract_blocks(data: &[u8]) -> Result<Option<&[u8]>, ()> { 184 + // 1. decode frame header 185 + let mut dec = minicbor::Decoder::new(data); 186 + let header_len = dec.map().map_err(|_| ())?.ok_or(())?; 187 + 188 + let mut is_commit = false; 189 + for _ in 0..header_len { 190 + let key = dec.str().map_err(|_| ())?; 191 + match key { 192 + "t" => is_commit = dec.str().map_err(|_| ())? == "#commit", 193 + _ => { 194 + dec.skip().map_err(|_| ())?; 195 + } 196 + } 197 + } 198 + 199 + if !is_commit { 200 + return Ok(None); 201 + } 202 + 203 + // 2. decode payload — extract blocks field 204 + let pos = dec.position(); 205 + let mut dec = minicbor::Decoder::new(&data[pos..]); 206 + let payload_len = dec.map().map_err(|_| ())?.ok_or(())?; 207 + 208 + let mut blocks_bytes: Option<&[u8]> = None; 209 + for _ in 0..payload_len { 210 + let key = dec.str().map_err(|_| ())?; 211 + if key == "blocks" { 212 + blocks_bytes = Some(dec.bytes().map_err(|_| ())?); 213 + } else { 214 + dec.skip().map_err(|_| ())?; 215 + } 216 + } 217 + 218 + Ok(blocks_bytes.filter(|b| !b.is_empty())) 219 + } 220 + 221 + /// full decode with a provided bump arena. 222 + fn decode_full_with_bump<'a>(data: &'a [u8], bump: &'a Bump) -> DecodeResult { 223 + let mut blocks = 0usize; 224 + let mut errors = 0usize; 225 + 226 + let blocks_data = match extract_blocks(data) { 227 + Ok(Some(b)) => b, 228 + Ok(None) => return DecodeResult { blocks: 0, errors: 0 }, 229 + Err(()) => return DecodeResult { blocks: 0, errors: 1 }, 230 + }; 231 + 232 + let car_blocks = match CarBlocks::new(blocks_data) { 233 + Ok(b) => b, 234 + Err(_) => return DecodeResult { blocks: 0, errors: 1 }, 235 + }; 236 + 237 + for block_result in car_blocks { 238 + let block = match block_result { 239 + Ok(b) => b, 240 + Err(_) => { 241 + errors += 1; 242 + continue; 243 + } 244 + }; 245 + let mut dec = minicbor::Decoder::new(block); 246 + match decode_value(&mut dec, bump) { 247 + Ok(_) => blocks += 1, 248 + Err(_) => errors += 1, 249 + } 250 + } 251 + 252 + DecodeResult { blocks, errors } 253 + } 254 + 255 + // --- benchmark harness --- 256 + 257 + struct CorpusInfo { 258 + raw: Vec<u8>, 259 + frame_ranges: Vec<(usize, usize)>, 260 + total_bytes: usize, 261 + min_frame: usize, 262 + max_frame: usize, 263 + } 264 + 265 + impl CorpusInfo { 266 + fn frames(&self) -> impl Iterator<Item = &[u8]> { 267 + self.frame_ranges 268 + .iter() 269 + .map(move |&(start, end)| &self.raw[start..end]) 270 + } 271 + } 272 + 273 + struct PassResult { 274 + frames: usize, 275 + blocks: usize, 276 + errors: usize, 277 + elapsed: std::time::Duration, 278 + } 279 + 280 + fn main() { 281 + println!("\n=== rust (raw) benchmarks ===\n"); 282 + 283 + let corpus = match load_corpus("firehose-frames.bin") { 284 + Ok(c) => c, 285 + Err(e) => { 286 + println!("firehose-frames.bin: SKIP ({e})"); 287 + return; 288 + } 289 + }; 290 + 291 + println!( 292 + "corpus: {} frames, {} bytes total", 293 + corpus.frame_ranges.len(), 294 + corpus.total_bytes 295 + ); 296 + println!( 297 + " frame sizes: {}..{} bytes", 298 + corpus.min_frame, corpus.max_frame 299 + ); 300 + println!( 301 + " passes: {} warmup, {} measured\n", 302 + WARMUP_PASSES, MEASURED_PASSES 303 + ); 304 + 305 + // verify first frame 306 + if let Some(first) = corpus.frames().next() { 307 + let bump = Bump::new(); 308 + let result = decode_full_with_bump(first, &bump); 309 + println!( 310 + "first frame: blocks={} errors={}", 311 + result.blocks, result.errors, 312 + ); 313 + } 314 + println!(); 315 + 316 + bench_decode_reuse(&corpus); 317 + bench_decode_alloc(&corpus); 318 + 319 + println!(); 320 + } 321 + 322 + /// arena reuse: one bump allocator, reset per frame. 323 + fn bench_decode_reuse(corpus: &CorpusInfo) { 324 + let mut bump = Bump::new(); 325 + 326 + for _ in 0..WARMUP_PASSES { 327 + for frame in corpus.frames() { 328 + bump.reset(); 329 + decode_full_with_bump(frame, &bump); 330 + } 331 + } 332 + 333 + let mut pass_results = Vec::with_capacity(MEASURED_PASSES); 334 + 335 + for _ in 0..MEASURED_PASSES { 336 + let mut pass_blocks = 0usize; 337 + let mut pass_errors = 0usize; 338 + let start = Instant::now(); 339 + for frame in corpus.frames() { 340 + bump.reset(); 341 + let result = decode_full_with_bump(frame, &bump); 342 + pass_blocks += result.blocks; 343 + pass_errors += result.errors; 344 + } 345 + let elapsed = start.elapsed(); 346 + pass_results.push(PassResult { 347 + frames: corpus.frame_ranges.len(), 348 + blocks: pass_blocks, 349 + errors: pass_errors, 350 + elapsed, 351 + }); 352 + } 353 + 354 + report_result("decode (reuse)", corpus, &pass_results); 355 + } 356 + 357 + /// alloc per frame: fresh bump allocator per frame (drop + new). 358 + fn bench_decode_alloc(corpus: &CorpusInfo) { 359 + for _ in 0..WARMUP_PASSES { 360 + for frame in corpus.frames() { 361 + let bump = Bump::new(); 362 + decode_full_with_bump(frame, &bump); 363 + } 364 + } 365 + 366 + let mut pass_results = Vec::with_capacity(MEASURED_PASSES); 367 + 368 + for _ in 0..MEASURED_PASSES { 369 + let mut pass_blocks = 0usize; 370 + let mut pass_errors = 0usize; 371 + let start = Instant::now(); 372 + for frame in corpus.frames() { 373 + let bump = Bump::new(); 374 + let result = decode_full_with_bump(frame, &bump); 375 + pass_blocks += result.blocks; 376 + pass_errors += result.errors; 377 + } 378 + let elapsed = start.elapsed(); 379 + pass_results.push(PassResult { 380 + frames: corpus.frame_ranges.len(), 381 + blocks: pass_blocks, 382 + errors: pass_errors, 383 + elapsed, 384 + }); 385 + } 386 + 387 + report_result("decode (alloc)", corpus, &pass_results); 388 + } 389 + 390 + fn report_result(name: &str, corpus: &CorpusInfo, pass_results: &[PassResult]) { 391 + let mut fps_values: Vec<f64> = pass_results 392 + .iter() 393 + .map(|r| r.frames as f64 / r.elapsed.as_secs_f64()) 394 + .collect(); 395 + fps_values.sort_by(|a, b| a.partial_cmp(b).unwrap()); 396 + 397 + let total_frames: usize = pass_results.iter().map(|r| r.frames).sum(); 398 + let total_blocks: usize = pass_results.iter().map(|r| r.blocks).sum(); 399 + let total_errors: usize = pass_results.iter().map(|r| r.errors).sum(); 400 + let total_elapsed: f64 = pass_results.iter().map(|r| r.elapsed.as_secs_f64()).sum(); 401 + 402 + let total_bytes = corpus.total_bytes as f64 * MEASURED_PASSES as f64; 403 + let throughput_mb = total_bytes / (1024.0 * 1024.0) / total_elapsed; 404 + let blocks_per_frame = total_blocks as f64 / total_frames as f64; 405 + 406 + let min_fps = fps_values[0]; 407 + let median_fps = fps_values[MEASURED_PASSES / 2]; 408 + let max_fps = fps_values[MEASURED_PASSES - 1]; 409 + 410 + println!( 411 + "{:<14} {:>10.0} frames/sec {:>8.1} MB/s blocks={} ({:.2}/frame) errors={}", 412 + name, median_fps, throughput_mb, total_blocks, blocks_per_frame, total_errors, 413 + ); 414 + println!( 415 + "{:<14} variance: min={:.0} median={:.0} max={:.0} frames/sec", 416 + "", min_fps, median_fps, max_fps, 417 + ); 418 + } 419 + 420 + fn load_corpus(name: &str) -> Result<CorpusInfo, Box<dyn std::error::Error>> { 421 + let path = format!("{FIXTURES_DIR}/{name}"); 422 + let raw = std::fs::read(&path).map_err(|e| { 423 + eprintln!("cannot open {path}: {e}"); 424 + eprintln!("run `just capture` first to generate fixtures"); 425 + e 426 + })?; 427 + 428 + if raw.len() < 4 { 429 + return Err("corpus file too small".into()); 430 + } 431 + 432 + let frame_count = u32::from_be_bytes(raw[0..4].try_into().unwrap()) as usize; 433 + let mut frame_ranges = Vec::with_capacity(frame_count); 434 + let mut pos = 4usize; 435 + let mut total_bytes = 0usize; 436 + let mut min_frame = usize::MAX; 437 + let mut max_frame = 0usize; 438 + 439 + for _ in 0..frame_count { 440 + if pos + 4 > raw.len() { 441 + return Err("truncated corpus".into()); 442 + } 443 + let frame_len = u32::from_be_bytes(raw[pos..pos + 4].try_into().unwrap()) as usize; 444 + pos += 4; 445 + if pos + frame_len > raw.len() { 446 + return Err("truncated corpus".into()); 447 + } 448 + frame_ranges.push((pos, pos + frame_len)); 449 + pos += frame_len; 450 + total_bytes += frame_len; 451 + min_frame = min_frame.min(frame_len); 452 + max_frame = max_frame.max(frame_len); 453 + } 454 + 455 + Ok(CorpusInfo { 456 + raw, 457 + frame_ranges, 458 + total_bytes, 459 + min_frame, 460 + max_frame, 461 + }) 462 + }