diff --git a/Cargo.lock b/Cargo.lock index b3d547b..166d67d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,7 @@ dependencies = [ name = "alktype" version = "0.3.0" dependencies = [ + "criterion", "jsonschema", "serde_json", ] @@ -39,6 +40,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "autocfg" version = "1.5.1" @@ -84,12 +97,107 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "criterion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "data-encoding" version = "2.11.0" @@ -107,6 +215,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "email_address" version = "0.2.9" @@ -174,6 +288,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -304,6 +429,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -479,6 +613,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "outref" version = "0.5.2" @@ -628,6 +768,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -733,6 +882,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -773,6 +932,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -827,12 +996,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 67a16b6..9e5c9bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,12 @@ default = [] [dependencies] jsonschema = { version = "0.46", default-features = false } -serde_json = { version = "1", features = ["preserve_order"] } \ No newline at end of file +serde_json = { version = "1", features = ["preserve_order"] } + +[dev-dependencies] +serde_json = "1" +criterion = { version = "0.7", default-features = false } + +[[bench]] +name = "wire_vs_bast" +harness = false \ No newline at end of file diff --git a/benches/wire_vs_bast.rs b/benches/wire_vs_bast.rs new file mode 100644 index 0000000..4a667e3 --- /dev/null +++ b/benches/wire_vs_bast.rs @@ -0,0 +1,643 @@ +//! Informal speed comparison: hand-rolled codec logic vs alktype-driven +//! codec over the same wire shapes. +//! +//! History: this bench originated (uncommitted) in `alktty` as the +//! curiosity probe that surfaced review #004's 400x read gap — the +//! finding that drove the 0.3.0 compiled-forms release (ADR-011/012). +//! It now lives here so alktype owns its perf story. The alktty-only +//! async roundtrip group (tokio `ChunkReader`/`ChunkWriter` over a +//! duplex pipe) was dropped — that measures alktty's I/O stack, not +//! this engine. +//! +//! The alktype engine / layout / plans are built **once outside** the +//! measured routine, per the "build cost is paid once" framing. +//! +//! Shapes: +//! +//! - **ChunkHeader** — a 5-byte header (`stream_type: uint8`, +//! `length: uint32` big-endian). The original shape, kept so numbers +//! stay comparable with the historical series (review #004: 400x → +//! 0.3.0: ~18x on read p64). +//! - **Read** — the hand-rolled path mirrors +//! `ChunkReader::read_chunk_after_peek` minus the tokio I/O +//! (identical overhead on both sides): validate `stream_type <= 4`, +//! parse `u32::from_be_bytes`, slice the payload. The alktype path +//! drives `SequentialReader::read_next` over the `ChunkHeader` +//! struct, then slices the payload at the parsed length. Both +//! return a `&[u8]` payload view — no allocation in either measured +//! path. +//! - **Write** — serialize the 5-byte header. Hand-rolled mirrors +//! `ChunkWriter::write_chunk`'s header writes; alktype uses +//! `PackedLayout` offsets (built once) and +//! `data_access::write_u8`/`write_u32` at those offsets. +//! - **Packet** — a byte-offset-discriminator union +//! (`Read {handle, length}` / `Write {handle, length, data: bytes}`), +//! the SFTP-shaped case ADR-011's framing argument was about: +//! exercises `CompositePlan::Union` dispatch, variant walks, and +//! length-prefixed variable reads. The alktype consumer pattern is +//! the documented one: `read_next` on the root yields +//! `FieldValue::Union { discriminator, variant_start }`, the consumer +//! selects the pre-built reader for that variant and walks it over +//! `&buf[variant_start..]`. +//! - **validate_bytes** — `engine.validate_bytes` per buffer +//! (materialize + `ValidationPlan` walk, ADR-010/ADR-012 §3): the +//! read+validate-on-untrusted-stream shape `alkcall` cares about. No +//! hand comparator: a hand-rolled codec validates inline during the +//! (already measured) parse, while `validate_bytes` additionally +//! materializes a `Value` tree per buffer — the honest reading is the +//! absolute per-chunk cost. +//! +//! One-shots (paid once at startup, not per chunk): +//! `alktype_engine_compile` (dominated by BAST meta-schema +//! validation), `alktype_sequential_reader_new` (an `Arc::clone`), +//! `alktype_layout_build`. +//! +//! Two payload sizes (64 B, 4 KiB) so per-chunk fixed overhead is +//! visible separately from payload-copy cost. +//! +//! Run: `cargo bench --bench wire_vs_bast` + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use std::hint::black_box; + +use alktype::{ + data_access, AlkTypeEngine, Endian, FieldValue, LayoutBuilder, LayoutMode, PackedLayout, + ReadPlan, SequentialReader, +}; + +/// Mirrors `alktty::wire::MAX_CHUNK_LEN` — the hand-rolled comparator +/// validates against the same cap the real codec enforces. +const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; + +/// The `ChunkHeader` BAST definition. The `StreamType` enum is +/// intentionally NOT used — BAST enums encode as `u32`, but the +/// on-wire `stream_type` is a `uint8`; both sides read it as `uint8`. +const CHUNK_HEADER_BAST: &str = r#"{ + "$schema": "https://alk.dev/bast/v1/schema", + "$defs": { + "ChunkHeader": { + "kind": "struct", + "endian": "big", + "fields": [ + { "name": "stream_type", "kind": "uint8" }, + { "name": "length", "kind": "uint32" } + ] + } + } +}"#; + +/// SFTP-shaped byte-discriminator union: one byte selects the variant, +/// `Write` carries a trailing length-prefixed `bytes` field. The root +/// struct wraps the union (`AlkTypeEngine::compile` requires a struct +/// root); mapping keys are the stringified `uint8` discriminator +/// values. +const PACKET_BAST: &str = r##"{ + "$schema": "https://alk.dev/bast/v1/schema", + "$defs": { + "Packet": { + "kind": "struct", + "endian": "big", + "fields": [ + { "name": "event", "kind": { "$ref": "#/$defs/Event" } } + ] + }, + "Event": { + "kind": "union", + "discriminator": { "kind": "byte", "offset": 0, "type": "uint8" }, + "mapping": { + "5": { "$ref": "#/$defs/Read" }, + "6": { "$ref": "#/$defs/Write" } + } + }, + "Read": { + "kind": "struct", + "endian": "big", + "fields": [ + { "name": "handle", "kind": "uint32" }, + { "name": "length", "kind": "uint32" } + ] + }, + "Write": { + "kind": "struct", + "endian": "big", + "fields": [ + { "name": "handle", "kind": "uint32" }, + { "name": "length", "kind": "uint32" }, + { "name": "data", "kind": "bytes" } + ] + } + } +}"##; + +// --------------------------------------------------------------------------- +// ChunkHeader fixtures +// --------------------------------------------------------------------------- + +/// One chunk's worth of bytes on the wire: 5-byte header + payload. +fn make_chunk_bytes(stream_type: u8, payload: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(5 + payload.len()); + buf.push(stream_type); + buf.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + buf.extend_from_slice(payload); + buf +} + +/// Concatenate `n` chunks into one buffer, each with `payload_len` bytes. +fn make_chunk_stream(n: usize, payload_len: usize) -> Vec { + let payload = vec![0xA5u8; payload_len]; + let mut buf = Vec::with_capacity(n * (5 + payload_len)); + for i in 0..n { + let st = (i % 5) as u8; + buf.extend_from_slice(&make_chunk_bytes(st, &payload)); + } + buf +} + +// --------------------------------------------------------------------------- +// Hand-rolled chunk read: mirrors ChunkReader::read_chunk_after_peek minus +// the tokio I/O. Returns (stream_type, payload) so the compiler can't +// elide the work. Validates stream_type <= 4 and length <= MAX_CHUNK_LEN. +// --------------------------------------------------------------------------- + +#[inline] +fn hand_read_header(buf: &[u8]) -> Option<(u8, u32)> { + if buf.len() < 5 { + return None; + } + let stream_type = buf[0]; + if stream_type > 4 { + return None; + } + let length = u32::from_be_bytes([buf[1], buf[2], buf[3], buf[4]]); + if length > MAX_CHUNK_LEN { + return None; + } + Some((stream_type, length)) +} + +#[inline] +fn hand_read_chunk(buf: &[u8]) -> Option<(u8, &[u8])> { + let (st, len) = hand_read_header(buf)?; + let end = 5usize.checked_add(len as usize)?; + if buf.len() < end { + return None; + } + Some((st, &buf[5..end])) +} + +/// Drive `hand_read_chunk` across `n` contiguous chunks in `buf`. +/// Returns the total payload bytes consumed (so the loop body is +/// meaningfully used and not optimized away). +fn hand_read_stream(buf: &[u8], n: usize) -> usize { + let mut pos = 0usize; + let mut total = 0usize; + for _ in 0..n { + let (st, payload) = match hand_read_chunk(&buf[pos..]) { + Some(v) => v, + None => break, + }; + total += payload.len(); + pos += 5 + payload.len(); + black_box(st); + } + black_box(total) +} + +// --------------------------------------------------------------------------- +// alktype chunk read: SequentialReader over ChunkHeader. The reader is +// constructed once per benchmark group and reset() between chunks. After +// the header read, the payload is sliced at the parsed length — same as +// the hand-rolled path. We do NOT re-read a length prefix for the payload +// (that would be the double-prefix problem). +// --------------------------------------------------------------------------- + +fn alktype_read_stream(buf: &[u8], n: usize, reader: &mut SequentialReader) -> usize { + let mut pos = 0usize; + let mut total = 0usize; + for _ in 0..n { + reader.reset(); + let st = match reader.read_next(&buf[pos..]) { + Ok(Some((_, FieldValue::U8(v)))) => v, + _ => break, + }; + let len = match reader.read_next(&buf[pos..]) { + Ok(Some((_, FieldValue::U32(v)))) => v, + _ => break, + }; + if len > MAX_CHUNK_LEN { + break; + } + let end = match 5usize.checked_add(len as usize) { + Some(e) if e <= buf.len() - pos => e, + _ => break, + }; + let payload = &buf[pos + 5..pos + end]; + total += payload.len(); + pos += end; + black_box(st); + black_box(payload.as_ptr()); + } + black_box(total) +} + +// --------------------------------------------------------------------------- +// Hand-rolled chunk write: mirrors ChunkWriter::write_chunk's header +// writes into a caller-provided buffer. Writes `n` contiguous chunks. +// --------------------------------------------------------------------------- + +fn hand_write_stream(out: &mut Vec, n: usize, payload_len: usize) { + let payload = vec![0xA5u8; payload_len]; + for i in 0..n { + let st = (i % 5) as u8; + let start = out.len(); + out.resize(start + 5 + payload_len, 0); + out[start] = st; + out[start + 1..start + 5].copy_from_slice(&(payload_len as u32).to_be_bytes()); + out[start + 5..start + 5 + payload_len].copy_from_slice(&payload); + } + black_box(out.len()); +} + +// --------------------------------------------------------------------------- +// alktype chunk write: data_access::write_u8 / write_u32 at the +// PackedLayout offsets. The layout is built once per group and reused. +// Payload bytes are copied with the same slice copy as the hand-rolled +// path so the comparison isolates the header-encoding overhead. +// --------------------------------------------------------------------------- + +fn alktype_write_stream(out: &mut Vec, n: usize, payload_len: usize, layout: &PackedLayout) { + let payload = vec![0xA5u8; payload_len]; + let st_pos = layout.get("stream_type").expect("stream_type field").offset; + let len_pos = layout.get("length").expect("length field").offset; + for i in 0..n { + let start = out.len(); + out.resize(start + 5 + payload_len, 0); + let _ = data_access::write_u8(out, start + st_pos, (i % 5) as u8, "stream_type"); + let _ = data_access::write_u32( + out, + start + len_pos, + payload_len as u32, + "length", + Endian::Big, + ); + out[start + 5..start + 5 + payload_len].copy_from_slice(&payload); + } + black_box(out.len()); +} + +// --------------------------------------------------------------------------- +// Packet fixtures: byte-disc union stream, alternating Read/Write +// variants. Wire layout per packet (packed, big-endian): +// Read: disc(1) + handle(4) + length(4) = 9 bytes +// Write: disc(1) + handle(4) + length(4) + len(4)+data = 13 + payload +// --------------------------------------------------------------------------- + +fn make_packet_bytes(disc: u8, payload: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(13 + 4 + payload.len()); + buf.push(disc); + buf.extend_from_slice(&0x0102_0304u32.to_be_bytes()); + buf.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + if disc == 6 { + buf.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + buf.extend_from_slice(payload); + } + buf +} + +fn make_packet_stream(n: usize, payload_len: usize) -> Vec { + let payload = vec![0xA5u8; payload_len]; + let mut buf = Vec::new(); + for i in 0..n { + let disc = if i % 2 == 0 { 5u8 } else { 6u8 }; + buf.extend_from_slice(&make_packet_bytes(disc, &payload)); + } + buf +} + +// --------------------------------------------------------------------------- +// Hand-rolled packet read: read the discriminator byte, match the +// variant, parse its fields directly. Returns bytes consumed. +// --------------------------------------------------------------------------- + +fn hand_read_packet(buf: &[u8]) -> Option { + let disc = *buf.first()?; + match disc { + 5 => { + if buf.len() < 9 { + return None; + } + let handle = u32::from_be_bytes(buf[1..5].try_into().ok()?); + let length = u32::from_be_bytes(buf[5..9].try_into().ok()?); + black_box((handle, length)); + Some(9) + } + 6 => { + if buf.len() < 13 { + return None; + } + let handle = u32::from_be_bytes(buf[1..5].try_into().ok()?); + let length = u32::from_be_bytes(buf[5..9].try_into().ok()?); + let data_len = u32::from_be_bytes(buf[9..13].try_into().ok()?); + let end = 13usize.checked_add(data_len as usize)?; + if buf.len() < end { + return None; + } + black_box((handle, length)); + black_box(&buf[13..end].as_ptr()); + Some(end) + } + _ => None, + } +} + +fn hand_read_packet_stream(buf: &[u8], n: usize) -> usize { + let mut pos = 0usize; + let mut total = 0usize; + for _ in 0..n { + let Some(consumed) = hand_read_packet(&buf[pos..]) else { + break; + }; + total += consumed; + pos += consumed; + } + black_box(total) +} + +// --------------------------------------------------------------------------- +// alktype packet read: the documented union consumer contract. The root +// reader walks the wrapping struct; `read_next` returns +// `FieldValue::Union { discriminator, variant_start }`; the consumer +// selects the pre-built reader for that variant and walks it over +// `&buf[pos + variant_start..]` until exhausted. +// --------------------------------------------------------------------------- + +/// Walk one variant's fields to exhaustion; returns bytes consumed. +fn alktype_walk_variant(reader: &mut SequentialReader, buf: &[u8]) -> Option { + reader.reset(); + loop { + match reader.read_next(buf) { + Ok(Some((name, value))) => { + black_box(name); + black_box(&value); + } + Ok(None) => return Some(reader.position()), + Err(_) => return None, + } + } +} + +fn alktype_read_packet_stream( + buf: &[u8], + n: usize, + packet: &mut SequentialReader, + read: &mut SequentialReader, + write: &mut SequentialReader, +) -> usize { + let mut pos = 0usize; + let mut total = 0usize; + for _ in 0..n { + packet.reset(); + let disc = match packet.read_next(&buf[pos..]) { + Ok(Some((_, FieldValue::Union { + discriminator, + variant_start, + }))) => { + pos += variant_start; + discriminator + } + _ => break, + }; + let vbuf = &buf[pos..]; + let consumed = match disc.as_str() { + "5" => alktype_walk_variant(read, vbuf), + "6" => alktype_walk_variant(write, vbuf), + _ => break, + }; + let Some(consumed) = consumed else { + break; + }; + total += consumed; + pos += consumed; + } + black_box(total) +} + +/// One-time sanity check (outside the measured loops): the union +/// consumer pattern the stream loop relies on — root reader reports the +/// mapping key and the variant start; the variant reader's walk to +/// exhaustion reports exactly the variant's byte size, so +/// `variant_start + consumed` lands on the next packet. +fn assert_packet_reader_parity( + payload_len: usize, + packet: &mut SequentialReader, + read: &mut SequentialReader, + write: &mut SequentialReader, +) { + let payload = vec![0u8; payload_len]; + + let read_pkt = make_packet_bytes(5, &payload); + packet.reset(); + match packet.read_next(&read_pkt) { + Ok(Some((_, FieldValue::Union { + discriminator, + variant_start, + }))) => { + assert_eq!(discriminator, "5"); + assert_eq!(variant_start, 1, "variant starts after the 1-byte disc"); + } + _ => panic!("expected union value for Read packet"), + } + let consumed = alktype_walk_variant(read, &read_pkt[1..]).expect("read variant walk"); + assert_eq!(consumed, 8, "Read = handle(4) + length(4)"); + assert_eq!(1 + consumed, read_pkt.len(), "Read packet fully consumed"); + + let write_pkt = make_packet_bytes(6, &payload); + packet.reset(); + match packet.read_next(&write_pkt) { + Ok(Some((_, FieldValue::Union { + discriminator, + variant_start, + }))) => { + assert_eq!(discriminator, "6"); + assert_eq!(variant_start, 1); + } + _ => panic!("expected union value for Write packet"), + } + let consumed = alktype_walk_variant(write, &write_pkt[1..]).expect("write variant walk"); + assert_eq!( + consumed, + 12 + payload_len, + "Write = handle(4) + length(4) + len-prefix(4) + data" + ); + assert_eq!(1 + consumed, write_pkt.len(), "Write packet fully consumed"); +} + +// --------------------------------------------------------------------------- +// Benchmarks +// --------------------------------------------------------------------------- + +fn bench_read(c: &mut Criterion) { + let bast: serde_json::Value = serde_json::from_str(CHUNK_HEADER_BAST).expect("bast json"); + let engine = + AlkTypeEngine::compile(&bast, "ChunkHeader", LayoutMode::Packed, None).expect("compile"); + let mut reader = engine.sequential_reader().expect("packed reader"); + + let mut group = c.benchmark_group("read_chunk_stream"); + for (payload_len, label) in [(64usize, "p64"), (4096usize, "p4k")] { + let n = 1024; + let buf = make_chunk_stream(n, payload_len); + + group.bench_with_input(BenchmarkId::new("hand_rolled", label), &n, |b, &n| { + b.iter(|| hand_read_stream(black_box(&buf), n)); + }); + group.bench_with_input(BenchmarkId::new("alktype", label), &n, |b, &n| { + b.iter(|| alktype_read_stream(black_box(&buf), n, &mut reader)); + }); + } + group.finish(); +} + +fn bench_write(c: &mut Criterion) { + let bast: serde_json::Value = serde_json::from_str(CHUNK_HEADER_BAST).expect("bast json"); + let builder = LayoutBuilder::new(&bast, "ChunkHeader").expect("builder"); + let layout = builder + .build(&std::collections::HashMap::new()) + .expect("layout"); + + let mut group = c.benchmark_group("write_chunk_stream"); + for (payload_len, label) in [(64usize, "p64"), (4096usize, "p4k")] { + let n = 1024; + + group.bench_with_input( + BenchmarkId::new("hand_rolled", label), + &(n, payload_len), + |b, &(n, pl)| { + b.iter(|| { + let mut out = Vec::with_capacity(n * (5 + pl)); + hand_write_stream(&mut out, n, pl); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("alktype", label), + &(n, payload_len), + |b, &(n, pl)| { + b.iter(|| { + let mut out = Vec::with_capacity(n * (5 + pl)); + alktype_write_stream(&mut out, n, pl, &layout); + }); + }, + ); + } + group.finish(); +} + +fn bench_packet_read(c: &mut Criterion) { + let bast: serde_json::Value = serde_json::from_str(PACKET_BAST).expect("bast json"); + let engine = + AlkTypeEngine::compile(&bast, "Packet", LayoutMode::Packed, None).expect("compile"); + let mut packet_reader = engine.sequential_reader().expect("packed reader"); + let read_plan = std::sync::Arc::new(ReadPlan::compile(&bast, "Read").expect("read plan")); + let write_plan = std::sync::Arc::new(ReadPlan::compile(&bast, "Write").expect("write plan")); + let mut read_reader = SequentialReader::new(read_plan); + let mut write_reader = SequentialReader::new(write_plan); + + // One-time parity check of the union consumer pattern (not measured). + assert_packet_reader_parity(64, &mut packet_reader, &mut read_reader, &mut write_reader); + + let mut group = c.benchmark_group("read_packet_stream"); + for (payload_len, label) in [(64usize, "p64"), (4096usize, "p4k")] { + let n = 1024; + let buf = make_packet_stream(n, payload_len); + + group.bench_with_input(BenchmarkId::new("hand_rolled", label), &n, |b, &n| { + b.iter(|| hand_read_packet_stream(black_box(&buf), n)); + }); + group.bench_with_input(BenchmarkId::new("alktype", label), &n, |b, &n| { + b.iter(|| { + alktype_read_packet_stream( + black_box(&buf), + n, + &mut packet_reader, + &mut read_reader, + &mut write_reader, + ) + }); + }); + } + group.finish(); +} + +fn bench_validate(c: &mut Criterion) { + let header_bast: serde_json::Value = + serde_json::from_str(CHUNK_HEADER_BAST).expect("bast json"); + let header_engine = AlkTypeEngine::compile(&header_bast, "ChunkHeader", LayoutMode::Packed, None) + .expect("compile"); + let packet_bast: serde_json::Value = serde_json::from_str(PACKET_BAST).expect("bast json"); + let packet_engine = + AlkTypeEngine::compile(&packet_bast, "Packet", LayoutMode::Packed, None).expect("compile"); + + let mut group = c.benchmark_group("validate_stream"); + let n = 1024; + + let headers: Vec> = (0..n) + .map(|i| make_chunk_bytes((i % 5) as u8, &[0xA5u8; 64])) + .collect(); + group.bench_function("alktype_chunk_header", |b| { + b.iter(|| { + for h in &headers { + header_engine.validate_bytes(black_box(h)).expect("validate"); + } + }) + }); + + for (payload_len, label) in [(64usize, "p64"), (4096usize, "p4k")] { + let payload = vec![0xA5u8; payload_len]; + let packets: Vec> = (0..n) + .map(|i| make_packet_bytes(if i % 2 == 0 { 5 } else { 6 }, &payload)) + .collect(); + group.bench_with_input( + BenchmarkId::new("alktype_packet", label), + &packets, + |b, packets| { + b.iter(|| { + for p in packets { + packet_engine.validate_bytes(black_box(p)).expect("validate"); + } + }) + }, + ); + } + group.finish(); +} + +/// One-shot costs paid once at startup, not per chunk. +fn bench_oneshot(c: &mut Criterion) { + let bast: serde_json::Value = serde_json::from_str(CHUNK_HEADER_BAST).expect("bast json"); + c.bench_function("alktype_engine_compile", |b| { + b.iter(|| { + let _ = + AlkTypeEngine::compile(black_box(&bast), "ChunkHeader", LayoutMode::Packed, None) + .expect("compile"); + }); + }); + c.bench_function("alktype_sequential_reader_new", |b| { + let engine = AlkTypeEngine::compile(&bast, "ChunkHeader", LayoutMode::Packed, None) + .expect("compile"); + b.iter(|| engine.sequential_reader()); + }); + c.bench_function("alktype_layout_build", |b| { + let builder = LayoutBuilder::new(&bast, "ChunkHeader").expect("builder"); + b.iter(|| builder.build(&std::collections::HashMap::new())); + }); +} + +criterion_group!( + benches, + bench_read, + bench_write, + bench_packet_read, + bench_validate, + bench_oneshot +); +criterion_main!(benches); \ No newline at end of file