From 0c55498475408db1e52868d4505ab7ebe3031ca9 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 19:53:16 -0400 Subject: [PATCH] perf(homecore): criterion benches for state-machine hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo bench -p homecore --bench state_machine` covers: - set/first_write — cold-path insert + alloc + broadcast - set/warm_write_state_change — same-entity update fires broadcast - set/noop_suppressed — same state+attrs, no broadcast (HA semantic) - get/hit + get/miss — zero-copy Arc read paths - all_snapshot/{10,100,1000} — Vec> snapshot for REST - all_by_domain_light_20_of_100 — domain prefix filter - broadcast_fan_out/{1,4,16,64} — 1 sender + N subscribers, async, measures end-to-end deliver-and-recv latency The broadcast fan-out is the most load-bearing measurement for HOMECORE — every integration, the recorder, the automation engine, and every WS subscriber holds a receiver, so the per-subscriber delivery cost determines how many add-ons the runtime can host. criterion 0.5 with sample_size=20 (fast tick, the fast-path benches run in nanoseconds and don't need 100 samples). Refs: docs/adr/ADR-127-homecore-state-machine-rust.md Refs: #798 Co-Authored-By: claude-flow --- v2/crates/homecore/Cargo.toml | 5 + v2/crates/homecore/benches/state_machine.rs | 205 ++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 v2/crates/homecore/benches/state_machine.rs diff --git a/v2/crates/homecore/Cargo.toml b/v2/crates/homecore/Cargo.toml index 2242e6f4..bbc7393b 100644 --- a/v2/crates/homecore/Cargo.toml +++ b/v2/crates/homecore/Cargo.toml @@ -41,3 +41,8 @@ once_cell = "1" [dev-dependencies] tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] } +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "state_machine" +harness = false diff --git a/v2/crates/homecore/benches/state_machine.rs b/v2/crates/homecore/benches/state_machine.rs new file mode 100644 index 00000000..db80e896 --- /dev/null +++ b/v2/crates/homecore/benches/state_machine.rs @@ -0,0 +1,205 @@ +//! Criterion benchmarks for the HOMECORE state-machine hot paths. +//! +//! Run with: +//! +//! cargo bench -p homecore --bench state_machine +//! +//! Hot paths covered: +//! - `set` first-time-write (cold path: insert + allocate + broadcast) +//! - `set` repeat-write (warm path: same entity, fires broadcast) +//! - `set` no-op (suppress path: same state + same attrs, no broadcast) +//! - `get` (zero-copy Arc clone) +//! - `all` snapshot (allocates Vec; REST GET /api/states path) +//! - `all_by_domain` filter +//! - Broadcast fan-out: 1 sender + N subscribers + +use std::sync::Arc; + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use tokio::runtime::Runtime; + +use homecore::{Context, EntityId, StateMachine}; + +fn bench_set_first_write(c: &mut Criterion) { + let mut g = c.benchmark_group("set"); + g.throughput(Throughput::Elements(1)); + g.bench_function("first_write", |b| { + b.iter_with_setup( + || (StateMachine::new(), EntityId::parse("light.benchmark").unwrap()), + |(sm, id)| { + sm.set( + id, + black_box("on"), + black_box(serde_json::json!({"brightness": 200})), + Context::new(), + ) + }, + ) + }); + g.finish(); +} + +fn bench_set_warm_write(c: &mut Criterion) { + let sm = StateMachine::new(); + let id = EntityId::parse("light.benchmark").unwrap(); + // Prime the entry + sm.set(id.clone(), "off", serde_json::json!({}), Context::new()); + + let mut g = c.benchmark_group("set"); + g.throughput(Throughput::Elements(1)); + g.bench_function("warm_write_state_change", |b| { + let mut toggle = false; + b.iter(|| { + toggle = !toggle; + let v = if toggle { "on" } else { "off" }; + sm.set( + id.clone(), + black_box(v), + black_box(serde_json::json!({"toggle": toggle})), + Context::new(), + ) + }); + }); + g.finish(); +} + +fn bench_set_noop(c: &mut Criterion) { + let sm = StateMachine::new(); + let id = EntityId::parse("light.benchmark").unwrap(); + sm.set(id.clone(), "on", serde_json::json!({"brightness": 200}), Context::new()); + + let mut g = c.benchmark_group("set"); + g.throughput(Throughput::Elements(1)); + g.bench_function("noop_suppressed", |b| { + b.iter(|| { + sm.set( + id.clone(), + black_box("on"), + black_box(serde_json::json!({"brightness": 200})), + Context::new(), + ) + }); + }); + g.finish(); +} + +fn bench_get(c: &mut Criterion) { + let sm = StateMachine::new(); + let id = EntityId::parse("sensor.temperature").unwrap(); + sm.set(id.clone(), "20.5", serde_json::json!({"unit": "C"}), Context::new()); + + let mut g = c.benchmark_group("get"); + g.throughput(Throughput::Elements(1)); + g.bench_function("hit", |b| { + b.iter(|| { + let _ = black_box(sm.get(&id)); + }); + }); + g.bench_function("miss", |b| { + let missing = EntityId::parse("sensor.missing").unwrap(); + b.iter(|| { + let _ = black_box(sm.get(&missing)); + }); + }); + g.finish(); +} + +fn bench_all_snapshot(c: &mut Criterion) { + let mut g = c.benchmark_group("all_snapshot"); + for n_entities in [10, 100, 1000].iter() { + let sm = StateMachine::new(); + for i in 0..*n_entities { + let id = EntityId::parse(format!("sensor.entity_{}", i)).unwrap(); + sm.set(id, "on", serde_json::json!({"i": i}), Context::new()); + } + g.throughput(Throughput::Elements(*n_entities as u64)); + g.bench_with_input( + BenchmarkId::from_parameter(n_entities), + n_entities, + |b, _| { + b.iter(|| black_box(sm.all())); + }, + ); + } + g.finish(); +} + +fn bench_all_by_domain(c: &mut Criterion) { + let sm = StateMachine::new(); + // 100 entities split across 5 domains + for i in 0..100 { + let domain = match i % 5 { + 0 => "light", + 1 => "sensor", + 2 => "switch", + 3 => "binary_sensor", + _ => "automation", + }; + let id = EntityId::parse(format!("{}.e_{}", domain, i)).unwrap(); + sm.set(id, "on", serde_json::json!({}), Context::new()); + } + + c.bench_function("all_by_domain_light_20_of_100", |b| { + b.iter(|| black_box(sm.all_by_domain("light"))); + }); +} + +fn bench_broadcast_fan_out(c: &mut Criterion) { + let rt = Runtime::new().unwrap(); + let mut g = c.benchmark_group("broadcast_fan_out"); + for n_subscribers in [1, 4, 16, 64].iter() { + g.throughput(Throughput::Elements(*n_subscribers as u64)); + g.bench_with_input( + BenchmarkId::from_parameter(n_subscribers), + n_subscribers, + |b, &n| { + b.iter_custom(|iters| { + rt.block_on(async { + let sm = StateMachine::new(); + let id = Arc::new(EntityId::parse("light.fanout").unwrap()); + + // Spawn N subscribers + let mut handles = Vec::new(); + for _ in 0..n { + let mut rx = sm.subscribe(); + handles.push(tokio::spawn(async move { + for _ in 0..iters { + let _ = rx.recv().await; + } + })); + } + + let start = std::time::Instant::now(); + for i in 0..iters { + let v = if i % 2 == 0 { "on" } else { "off" }; + sm.set( + (*id).clone(), + v, + serde_json::json!({"i": i}), + Context::new(), + ); + } + for h in handles { + let _ = h.await; + } + start.elapsed() + }) + }); + }, + ); + } + g.finish(); +} + +criterion_group! { + name = state_machine; + config = Criterion::default().sample_size(20); + targets = bench_set_first_write, + bench_set_warm_write, + bench_set_noop, + bench_get, + bench_all_snapshot, + bench_all_by_domain, + bench_broadcast_fan_out +} +criterion_main!(state_machine);