From 945122cb02ee7b6e81411b4b2fe6d321d7cda892 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 13 Jul 2026 09:32:46 +0200 Subject: [PATCH] perf(wire): pre-size effect batch encoding --- hemx-core/Cargo.toml | 4 ++ hemx-core/benches/wire.rs | 51 ++++++++++++++++++++++ hemx-core/src/lib.rs | 77 ++++++++++++++++++++++++++++++++- hemx-core/tests/effect_batch.rs | 59 ++++++++++++++++++++++++- 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 hemx-core/benches/wire.rs diff --git a/hemx-core/Cargo.toml b/hemx-core/Cargo.toml index d4e8974..02f5adf 100644 --- a/hemx-core/Cargo.toml +++ b/hemx-core/Cargo.toml @@ -6,6 +6,10 @@ edition.workspace = true [lib] path = "src/lib.rs" +[[bench]] +name = "wire" +harness = false + [features] default = ["std"] std = ["serde/std"] diff --git a/hemx-core/benches/wire.rs b/hemx-core/benches/wire.rs new file mode 100644 index 0000000..9d46b6c --- /dev/null +++ b/hemx-core/benches/wire.rs @@ -0,0 +1,51 @@ +//! Repeatable wire-encoding baseline: `cargo bench -p hemx-core --bench wire`. + +use hemx_core::{ + BuildFingerprint, Effect, EffectBatch, Payload, ResourceId, ResourceKind, ResourceRef, + EFFECT_BATCH_ABI_VERSION, +}; +use std::hint::black_box; +use std::time::Instant; + +const ITERATIONS: u32 = 100_000; +const ROUNDS: usize = 7; + +fn representative_batch() -> EffectBatch { + let target = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42)); + let ops = (0..50) + .map(|index| Effect::Put { + target: target.clone(), + payload: Payload::Text(format!("item-{index}-{}", "x".repeat(64))), + }) + .collect(); + EffectBatch { + abi_version: EFFECT_BATCH_ABI_VERSION, + fingerprint: BuildFingerprint(7), + ops, + } +} + +fn main() { + let batch = representative_batch(); + for _ in 0..10_000 { + black_box(batch.to_wire()); + } + + let mut nanos_per_batch = [0_u128; ROUNDS]; + let mut checksum = 0_usize; + for round in &mut nanos_per_batch { + let start = Instant::now(); + for _ in 0..ITERATIONS { + let bytes = black_box(&batch).to_wire(); + checksum = checksum.wrapping_add(black_box(bytes.len())); + } + *round = start.elapsed().as_nanos() / u128::from(ITERATIONS); + } + nanos_per_batch.sort_unstable(); + + println!( + "median_ns_per_batch={} wire_len={} checksum={checksum}", + nanos_per_batch[ROUNDS / 2], + batch.encoded_len(), + ); +} diff --git a/hemx-core/src/lib.rs b/hemx-core/src/lib.rs index 7b8437c..2e99911 100644 --- a/hemx-core/src/lib.rs +++ b/hemx-core/src/lib.rs @@ -459,9 +459,18 @@ pub struct EffectBatch { } impl EffectBatch { + /// Return the exact number of bytes produced by [`Self::to_wire`]. + pub fn encoded_len(&self) -> usize { + batch_wire_len(self) + } + pub fn to_wire(&self) -> Vec { - let mut out = Vec::new(); + let encoded_len = self.encoded_len(); + let mut out = Vec::with_capacity(encoded_len); + let initial_capacity = out.capacity(); write_batch(self, &mut out); + debug_assert_eq!(out.len(), encoded_len); + debug_assert_eq!(out.capacity(), initial_capacity); out } @@ -492,6 +501,72 @@ pub enum WireError { } const WIRE_MAGIC: &[u8; 4] = b"HEMX"; +const WIRE_BATCH_HEADER_LEN: usize = WIRE_MAGIC.len() + 4 + 8 + 4; + +fn batch_wire_len(batch: &EffectBatch) -> usize { + WIRE_BATCH_HEADER_LEN + batch.ops.iter().map(effect_wire_len).sum::() +} + +fn effect_wire_len(effect: &Effect) -> usize { + 1 + match effect { + Effect::Put { target, payload } => ref_wire_len(target) + payload_wire_len(payload), + Effect::Insert { + target, + key, + payload, + } + | Effect::Prepend { + target, + key, + payload, + } => ref_wire_len(target) + str_wire_len(key) + payload_wire_len(payload), + Effect::Remove { target, key } => { + ref_wire_len(target) + option_str_wire_len(key.as_deref()) + } + Effect::Move { + target, + key, + before, + } => ref_wire_len(target) + str_wire_len(key) + option_str_wire_len(before.as_deref()), + Effect::Focus { target } => ref_wire_len(target), + Effect::Navigate { + url, scroll, title, .. + } => { + str_wire_len(url) + 1 + scroll_wire_len(scroll) + option_str_wire_len(title.as_deref()) + } + Effect::Emit { name, payload } => str_wire_len(name) + str_wire_len(payload), + } +} + +fn ref_wire_len(reference: &ResourceRef) -> usize { + 1 + 4 + + 1 + + match &reference.scope { + None => 0, + Some(ScopeKey::KeyValue(value) | ScopeKey::Field(value)) => str_wire_len(value), + } +} + +fn payload_wire_len(payload: &Payload) -> usize { + 1 + match payload { + Payload::Text(value) | Payload::Html(value) => str_wire_len(value), + } +} + +fn scroll_wire_len(scroll: &ScrollBehavior) -> usize { + 1 + match scroll { + ScrollBehavior::Preserve | ScrollBehavior::Top => 0, + ScrollBehavior::Element(target) => ref_wire_len(target), + } +} + +fn option_str_wire_len(value: Option<&str>) -> usize { + 1 + value.map_or(0, str_wire_len) +} + +const fn str_wire_len(value: &str) -> usize { + 4 + value.len() +} fn write_batch(batch: &EffectBatch, out: &mut Vec) { out.extend_from_slice(WIRE_MAGIC); diff --git a/hemx-core/tests/effect_batch.rs b/hemx-core/tests/effect_batch.rs index 0a7da9a..96935f4 100644 --- a/hemx-core/tests/effect_batch.rs +++ b/hemx-core/tests/effect_batch.rs @@ -1,7 +1,8 @@ use hemx_core::{ event, navigate, redirect, replace, Atom, AtomSnapshot, AtomState, BuildFingerprint, ComponentRef, CssClass, CssClasses, Effect, EffectBatch, Form, Handle, IntoEffect, KeyedSlot, - NavigateMode, ParamName, Payload, ResourceKind, SafeHtml, ScopeKey, Slot, + NavigateMode, ParamName, Payload, ResourceId, ResourceKind, ResourceRef, SafeHtml, ScopeKey, + ScrollBehavior, Slot, }; #[test] @@ -22,12 +23,68 @@ fn effect_batch_wire_round_trips() { let bytes = batch.to_wire(); assert_eq!(&bytes[..4], b"HEMX"); + assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007 let decoded = EffectBatch::from_wire(&bytes).unwrap(); assert_eq!(decoded, batch); assert!(decoded.is_compatible()); } +#[test] +fn encoded_len_covers_every_effect_shape() { + let unscoped = ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)); + let scoped = ResourceRef::scoped( + ResourceId::new(ResourceKind::Form, 2), + ScopeKey::Field(String::from("email")), + ); + let batch = EffectBatch { + abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION, + fingerprint: BuildFingerprint(42), + ops: vec![ + Effect::Put { + target: unscoped.clone(), + payload: Payload::Html(String::from("

safe

")), + }, + Effect::Insert { + target: scoped.clone(), + key: String::from("insert"), + payload: Payload::Text(String::from("one")), + }, + Effect::Prepend { + target: scoped.clone(), + key: String::from("prepend"), + payload: Payload::Text(String::from("two")), + }, + Effect::Remove { + target: scoped.clone(), + key: Some(String::from("remove")), + }, + Effect::Move { + target: scoped.clone(), + key: String::from("move"), + before: Some(String::from("before")), + }, + Effect::Focus { + target: scoped.clone(), + }, + Effect::Navigate { + url: String::from("/next"), + mode: NavigateMode::Replace, + scroll: ScrollBehavior::Element(unscoped), + title: Some(String::from("Next")), + }, + Effect::Emit { + name: String::from("notice"), + payload: String::from("saved"), + }, + ], + }; + + let bytes = batch.to_wire(); + assert_eq!(batch.encoded_len(), bytes.len()); // req: wire/007 + assert_eq!(EffectBatch::from_wire(&bytes).unwrap(), batch); +} + #[test] fn optional_effects_compose_into_batches() { // req: component/005 req: public_api/003