perf(homecore): criterion benches for state-machine hot paths
`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<State> read paths
- all_snapshot/{10,100,1000} — Vec<Arc<State>> 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 <ruv@ruv.net>
This commit is contained in:
parent
75d5760351
commit
0c55498475
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<State> 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);
|
||||
Loading…
Reference in New Issue