Files
hsmc/hsmc/benches/dispatch.rs
T
swaitsandClaude Opus 4.7 2e55484080 chore(release): 0.4.1
Bump workspace version 0.4.0 → 0.4.1.

Patch release: dispatch hot-path performance only. No public API
changes; every chart that compiled against 0.4.0 compiles unchanged
and behaves identically. CHANGELOG.md captures the per-pass writeup
under the 0.4.1 heading.

Headlines (deepest dispatch in the bench suite):
  h8_cross_tree     -82% instructions / -72% cycles  vs pre_lca_fix
  bubble_top        -22% instructions /  -6% cycles  vs handler walk
  terminate_event   -22% instructions / -23% cycles  vs PARENT walk

The runtime LCA helper, is_up_transition, the per-call heapless::Vec
ancestry build, the runtime PARENT bubble walk, and (on timer-free
charts) the entire TimerTable machinery are all gone. Callgrind
reports the entire HSM machinery as a single inlined `step` symbol.

Bench suite expanded from 7 to 13 benches: bubble_leaf/mid/top
quantify handler resolution at varying ancestor depths,
timer_fire_dispatch covers the only path that exercises pop_expired's
non-empty branch, terminate_event covers do_terminate, and
bubble_cross_tree covers the realistic HSM worst case (deep bubble +
deep cross-tree transition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:11:29 -06:00

634 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Deterministic instruction-count benches for the dispatch hot path.
//!
//! Run with `cargo bench --bench dispatch` (Linux only — uses
//! iai-callgrind + valgrind).
//!
//! Each bench measures one dispatch kind in isolation:
//! - `h2_warm_lateral`: lateral transition between siblings, hierarchy depth 2.
//! - `h4_cross_tree`: lateral transition crossing two parallel subtrees, depth 4 from root.
//! - `h8_cross_tree`: same shape but depth 8 — exercises LCA worst-case.
//! - `h4_up`: up-transition (target is an ancestor of current).
//! - `h4_self`: self-transition (target == current).
//! - `h4_drop`: send an event with no matching handler (drop path).
//! - `h4_self_emit_chain`: action emits next event 3×; tests the dispatch drain loop.
//! - `h2_cold_start`: first dispatch into a freshly-constructed chart (priming step).
//!
//! Each bench's setup function primes the chart to a known state outside the
//! measured region. The body sends one event and runs `step()` once.
#[cfg(not(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy")))))]
fn main() {
// The benches use sync action impls; under `--features tokio` /
// `--features embassy` the codegen emits async fns and the impls
// would need an `async` keyword. Rather than maintain two parallel
// bench files, we gate the whole suite to the default-features /
// Linux configuration, which is what we measure latency in anyway.
eprintln!(
"hsmc dispatch benches require Linux + default features \
(iai-callgrind + valgrind, sync action impls)."
);
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use hsmc::{statechart, Duration};
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use iai_callgrind::{library_benchmark, library_benchmark_group, main};
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use std::hint::black_box;
// ──────────────────────────────────────────────────────────────────────
// Chart 1: H=2 sibling. LCA(A, B) = P. Two states deep.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h2 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
}
statechart! {
H2 {
context: Ctx;
events: Ev;
default(P);
state P {
default(A);
state A { on(Go) => B; }
state B { entry: noop; }
}
}
}
impl H2Actions for H2ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 2: H=4 cross-tree. LCA(L3, R3) = root. Stresses LCA over 8 ancestors.
// Also has up- and self-transition events for additional benches.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h4 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Cross,
Up,
Self_,
}
statechart! {
H4 {
context: Ctx;
events: Ev;
default(L1);
state L1 {
default(L2);
state L2 {
default(L3);
state L3 {
on(Cross) => R3;
on(Up) => L1;
on(Self_) => L3;
}
}
}
state R1 {
default(R2);
state R2 {
default(R3);
state R3 { entry: noop; }
}
}
}
}
impl H4Actions for H4ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 3: H=8 cross-tree. LCA(L7, R7) = root. Worst-case for the
// pre-fix O(H²) LCA — old code does ~64 compares; new code does ~16.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h8 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Cross,
}
statechart! {
H8 {
context: Ctx;
events: Ev;
default(L1);
state L1 { default(L2);
state L2 { default(L3);
state L3 { default(L4);
state L4 { default(L5);
state L5 { default(L6);
state L6 { default(L7);
state L7 { on(Cross) => R7; }
}
}
}
}
}
}
state R1 { default(R2);
state R2 { default(R3);
state R3 { default(R4);
state R4 { default(R5);
state R5 { default(R6);
state R6 { default(R7);
state R7 { entry: noop; }
}
}
}
}
}
}
}
}
impl H8Actions for H8ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 4: emit-chain. S0 →(Go)→ S1 →(emits Next)→ S2 →(emits Next)→ S3 →(emits Next)→ S4.
// One Send + one Step ⇒ chart drains 4 events through 3 self-emits.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod chain {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Next,
}
statechart! {
Chain {
context: Ctx;
events: Ev;
default(S0);
state S0 { on(Go) => S1; }
state S1 { entry: emit_next; on(Next) => S2; }
state S2 { entry: emit_next; on(Next) => S3; }
state S3 { entry: emit_next; on(Next) => S4; }
state S4 { entry: settle; }
}
}
impl ChainActions for ChainActionContext<'_> {
fn emit_next(&mut self) {
let _ = self.emit(Ev::Next);
}
fn settle(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 5: handler bubbling. Active state is L4 (depth 5). The three
// events resolve to handlers at three different ancestor depths so the
// __PARENT walk in step() is exercised at increasing distance:
//
// Leaf — handler on L4 itself → bubble depth 0
// Mid — handler on L1 (3 ancestors) → bubble depth 3
// Top — handler on Outer (4 anc.) → bubble depth 4
//
// `Sibling` is the transition target so each event causes the same
// dispatch shape; the only thing that varies between benches is the
// bubble distance to the handler.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod bubble {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Leaf,
Mid,
Top,
}
statechart! {
Bubble {
context: Ctx;
events: Ev;
default(Outer);
state Outer {
default(L1);
on(Top) => Sibling;
state L1 {
default(L2);
on(Mid) => Sibling;
state L2 {
default(L3);
state L3 {
default(L4);
state L4 {
on(Leaf) => Sibling;
}
}
}
}
}
state Sibling { entry: noop; }
}
}
impl BubbleActions for BubbleActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 6: timer firing. A single timer armed for 1 ns; the bench body
// advances time past expiry so `pop_expired` returns Some and dispatches
// the timer-driven transition. This is the only bench that exercises
// `step()`'s pop_expired non-empty branch — every other chart in this
// suite declares no `Duration` triggers and (post timer-elide pass) has
// the timer-table machinery removed at codegen.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod timer_fire {
use super::*;
#[derive(Default)]
pub struct Ctx;
statechart! {
TimerFire {
context: Ctx;
default(Armed);
state Armed {
on(after Duration::from_nanos(1)) => Fired;
}
state Fired { entry: noop; }
}
}
impl TimerFireActions for TimerFireActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 7: terminate event. `Quit` is declared as the terminate event,
// so dispatching it runs `do_terminate()` — exits every active state up
// to the root, sets `terminated`, and short-circuits future `step()`s.
// Only bench that exercises `__check_terminate`'s true arm and the
// terminate-on-event path.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod term {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Quit,
}
statechart! {
Term {
context: Ctx;
events: Ev;
default(L1);
terminate(Quit);
state L1 {
default(L2);
state L2 {
default(L3);
state L3 { entry: noop; }
}
}
}
}
impl TermActions for TermActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 8: bubble + cross-tree. Handler declared on L1 (4 ancestors up
// from current=L5) targets a deep leaf in a sibling subtree (R5). Two
// dispatch costs at once: the (now-O(1)) handler resolution and a
// 10-state-op cross-tree transition (5 exits + 5 enters). The
// realistic worst case for HSM dispatch — a parent's "back to default"
// handler that descends deep on the other side.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod bubble_cross {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Switch,
}
statechart! {
BubbleCross {
context: Ctx;
events: Ev;
default(L1);
state L1 {
default(L2);
on(Switch) => R5;
state L2 {
default(L3);
state L3 {
default(L4);
state L4 {
default(L5);
state L5 { entry: noop; }
}
}
}
}
state R1 {
default(R2);
state R2 {
default(R3);
state R3 {
default(R4);
state R4 {
default(R5);
state R5 { entry: noop; }
}
}
}
}
}
}
impl BubbleCrossActions for BubbleCrossActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Setup helpers. Prime each chart to its measurement starting state
// OUTSIDE the measured region — iai-callgrind only counts work inside
// the bench fn body.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h2_warm() -> h2::H2<8> {
let mut m = h2::H2::new(h2::Ctx);
let _ = m.step(Duration::ZERO); // priming step → enters P → A
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h2_cold() -> h2::H2<8> {
h2::H2::new(h2::Ctx)
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h4_warm() -> h4::H4<8> {
let mut m = h4::H4::new(h4::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h8_warm() -> h8::H8<8> {
let mut m = h8::H8::new(h8::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1..L7
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_chain_warm() -> chain::Chain<8> {
let mut m = chain::Chain::new(chain::Ctx);
let _ = m.step(Duration::ZERO); // priming step → S0
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_bubble_warm() -> bubble::Bubble<8> {
let mut m = bubble::Bubble::new(bubble::Ctx);
let _ = m.step(Duration::ZERO); // priming step → Outer → L1 → L2 → L3 → L4
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_timer_fire_armed() -> timer_fire::TimerFire<8> {
let mut m = timer_fire::TimerFire::new(timer_fire::Ctx);
let _ = m.step(Duration::ZERO); // priming step → enters Armed, arms 1ns timer
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_term_warm() -> term::Term<8> {
let mut m = term::Term::new(term::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_bubble_cross_warm() -> bubble_cross::BubbleCross<8> {
let mut m = bubble_cross::BubbleCross::new(bubble_cross::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3 → L4 → L5
m
}
// ──────────────────────────────────────────────────────────────────────
// Benches. One library_benchmark per scenario. Each takes its primed
// chart by value, sends one event, and runs one step.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cold(setup = setup_h2_cold)]
fn h2_cold_start(mut m: h2::H2<8>) -> h2::H2<8> {
// first .step() primes the chart by entering the default state path
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::warm(setup = setup_h2_warm)]
fn h2_warm_lateral(mut m: h2::H2<8>) -> h2::H2<8> {
let _ = black_box(m.send(black_box(h2::Ev::Go)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cross(setup = setup_h4_warm)]
fn h4_cross_tree(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Cross)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::up(setup = setup_h4_warm)]
fn h4_up(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Up)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::self_(setup = setup_h4_warm)]
fn h4_self(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Self_)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cross(setup = setup_h8_warm)]
fn h8_cross_tree(mut m: h8::H8<8>) -> h8::H8<8> {
let _ = black_box(m.send(black_box(h8::Ev::Cross)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::leaf(setup = setup_bubble_warm)]
fn bubble_leaf(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on L4 (current state). Bubble distance = 0.
let _ = black_box(m.send(black_box(bubble::Ev::Leaf)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::mid(setup = setup_bubble_warm)]
fn bubble_mid(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on L1. Bubble walks L4 → L3 → L2 → L1 (3 __PARENT hops).
let _ = black_box(m.send(black_box(bubble::Ev::Mid)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::top(setup = setup_bubble_warm)]
fn bubble_top(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on Outer. Bubble walks L4 → L3 → L2 → L1 → Outer (4 hops).
let _ = black_box(m.send(black_box(bubble::Ev::Top)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::fire(setup = setup_timer_fire_armed)]
fn timer_fire_dispatch(mut m: timer_fire::TimerFire<8>) -> timer_fire::TimerFire<8> {
// Advance time by 2ns — past the 1ns arm — so pop_expired returns
// Some and dispatches the timer-driven Armed → Fired transition.
let _ = black_box(m.step(black_box(Duration::from_nanos(2))));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::quit(setup = setup_term_warm)]
fn terminate_event(mut m: term::Term<8>) -> term::Term<8> {
// Send Quit (declared as terminate event); step runs check_terminate's
// true arm and do_terminate, which exits L3 → L2 → L1 → root.
let _ = black_box(m.send(black_box(term::Ev::Quit)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::switch(setup = setup_bubble_cross_warm)]
fn bubble_cross_tree(mut m: bubble_cross::BubbleCross<8>) -> bubble_cross::BubbleCross<8> {
// Switch handler is on L1 (4 bubble hops from current=L5). Target is
// R5 in a sibling subtree, so the transition is exit L5..L1 + enter
// R1..R5 = 10 state operations.
let _ = black_box(m.send(black_box(bubble_cross::Ev::Switch)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::chain(setup = setup_chain_warm)]
fn chain_self_emit(mut m: chain::Chain<8>) -> chain::Chain<8> {
let _ = black_box(m.send(black_box(chain::Ev::Go)));
// Drain the chain: 1 step per event. Go → S1 (entry emits Next) → S2
// (entry emits Next) → S3 (entry emits Next) → S4. 4 events total.
for _ in 0..4 {
let _ = black_box(m.step(black_box(Duration::ZERO)));
}
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
library_benchmark_group!(
name = dispatch;
benchmarks =
h2_cold_start,
h2_warm_lateral,
h4_cross_tree,
h4_up,
h4_self,
h8_cross_tree,
bubble_leaf,
bubble_mid,
bubble_top,
timer_fire_dispatch,
terminate_event,
bubble_cross_tree,
chain_self_emit,
);
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
main!(library_benchmark_groups = dispatch);