3 Commits
Author SHA1 Message Date
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
swaitsandClaude Opus 4.7 dc97419161 perf: precompute transition/dispatch/terminate paths; shrink TimerEntry to u64
Six codegen-side optimization passes against step() and friends. No
user-facing API changes; all 296 tests pass under no-default, tokio,
embassy, and tokio+journal. Cumulative on h8_cross_tree (deepest
dispatch in the suite): -82% instructions / -72% cycles vs the
pre_lca_fix baseline.

* __PATH_DATA + __PATH_RANGE: per-(from, to) precomputed exit/enter
  sequences. Up/self/lateral semantics fold into one indexed lookup;
  removes lca() and is_up_transition() runtime helpers and the
  per-call heapless::Vec<u16, 16> ancestry build. Hierarchy depth no
  longer bounded by Vec capacity.

* __DISPATCH[(state, event)]: precomputed handler resolution. The
  runtime __PARENT bubble walk + per-hop __handler_lookup call in
  step() collapses to one indexed lookup. Bubble resolution is now
  O(1) regardless of depth -- bubble_top (4-hop) is within 3
  instructions of bubble_leaf (handler on current).
  __handler_lookup retained for timer dispatch.

* __TERMINATE_DATA: per-state precomputed exit chain (root inclusive
  -- chart-root entry/exit actions are valid). do_terminate() indexes
  the slice instead of walking __PARENT, and uses Deque::clear()
  (O(1) in heapless 0.8) instead of pop_front-loop. terminate_event
  bench: -22.5% instr / -23.4% cycles.

* TimerTable empty-skip: pop_expired / min_remaining / decrement /
  cancel_state each get inlined is_empty() early-out with the
  non-empty body marked #[cold].

* Codegen-static timer-machinery elide: when chart has no Duration
  triggers, every TimerTable call is omitted from step() and
  exit_state(). step() returns None directly. dispatch_trigger and
  the timer-fired arms remain but are unreachable; LLVM DCEs them.

* TimerEntry.remaining_ns: u128 -> u64 (~584-year ceiling). Halves
  entry size; replaces u128 saturating-sub with u64. The smaller
  machine struct improves cache density on every bench, even
  timer-free ones.

Bench coverage: four new benches added to cover paths the original
suite missed -- bubble_leaf/mid/top (handler resolution at varying
ancestor depths), timer_fire_dispatch (only bench exercising
pop_expired's non-empty branch and dispatch_trigger), terminate_event
(only bench exercising do_terminate), bubble_cross_tree (deep bubble
+ 10-state cross-tree, the realistic HSM worst case).

See hsmc/benches/baseline.txt for the per-pass delta blocks and
docs/003. latency-analysis.md sections 1, 9, and 12 for the
cost-surface analysis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:51:21 -06:00
swaitsandClaude Opus 4.7 e633f5dc55 perf: O(H) lca() depth-walk + dispatch bench scoreboard
The runtime `lca()` helper used a `heapless::Vec<u16,32>` ancestry-set
with a linear `contains()` per node — O(H²) and capped at H ≤ 32. The
codegened `__DEPTH` table already had everything needed for an O(H)
depth-walk with no allocation and no hierarchy ceiling. Switch to that.

Also adds an iai-callgrind dispatch bench harness (Linux-only dev-dep)
so future optimizations can be measured deterministically by instruction
count. Seven benches cover: cold start, lateral H=2/4/8, up, self, and a
self-emit chain. Baseline numbers checked in at `hsmc/benches/baseline.txt`.

The latency analysis (`docs/003. latency-analysis.md`) is rounded out
with event-entry-point and cooperative-driving audits — three new
sections documenting the four event injection paths, the run-loop's
cooperative shape, and three correctness invariants (sticky overflow
flag, biased-select trap, cancel-safety on durings).

Bench deltas (cargo bench --bench dispatch -- --baseline=pre_lca_fix):

  h2_cold_start        -1.9%  /  -0.3%   (priming step, no lca() — noise)
  h2_warm_lateral     -22.8%  / -22.1%
  h4_cross_tree       -25.9%  / -10.6%
  h4_up                +1.0%  /  +0.4%   (up-transition, no lca() — noise)
  h4_self             -21.5%  / -11.7%
  h8_cross_tree       -46.2%  / -41.0%   ← headline: deepest LCA stress
  chain_self_emit     -15.4%  / -10.4%

Improvement scales with hierarchy depth, exactly as the O(H²) → O(H)
algorithmic change would predict.

All tests pass under --no-default-features, --features tokio, and
--features embassy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:58:53 -06:00
9 changed files with 2111 additions and 205 deletions

No files matched your search

+81
View File
@@ -4,6 +4,87 @@ All notable changes to this workspace are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).
## 0.4.1 — 2026-04-28
### Performance — dispatch hot path
Six codegen-side optimization passes against `step()` and friends. No
user-facing API changes; all 296 tests pass under `--no-default-features`,
`--features tokio`, `--features embassy`, and `--features tokio,journal`.
Cumulative on `h8_cross_tree` (the deepest dispatch in the bench suite):
**−82% instructions / −72% cycles** vs the pre-`pre_lca_fix` baseline.
See `hsmc/benches/baseline.txt` for per-pass delta blocks and
`docs/003. latency-analysis.md` §1/§9/§12 for the cost-surface analysis.
- **Precomputed transition paths.** For every `(from, to)` state pair,
codegen materializes the exit/enter sequence into a single flat
`__PATH_DATA` blob with a `__PATH_RANGE: [(u16, u16, u16); N²]`
index. `transition()` becomes one indexed range lookup + two slice
loops; the `lca()` runtime helper, `is_up_transition()`, and the
per-call `heapless::Vec<u16, 16>` ancestry build are gone. Up /
self / lateral semantics are baked into the table (empty enter
slice ⇒ up-transition, target excluded; non-empty enter slice
includes the default-descent tail). Hierarchy depth is no longer
bounded by the old 32-state Vec capacity.
- **Precomputed event-dispatch table.** For every `(state, event)`
pair, codegen resolves the handler the runtime `__PARENT` bubble
walk would have found. `__DISPATCH: [(u16, &'static [u16],
Option<u16>); N×E]` stores `(handler_state, action_ids,
transition_target)` per pair; `u16::MAX` in the handler-state slot
is the no-handler sentinel. The runtime walk + per-hop
`__handler_lookup` call in `step()` collapses to one indexed lookup.
Handler resolution is now O(1) regardless of bubble depth — the
`bubble_top` bench (4-hop walk) is now within 3 instructions of
`bubble_leaf` (handler on current state). `__handler_lookup` is
retained for timer dispatch (the `dispatch_trigger` path doesn't
bubble).
- **Precomputed terminate exit path.** Per-state `__TERMINATE_DATA`
table holds the full exit chain up to and including `__Root` (which
may carry root-level `entry:`/`exit:` actions). `do_terminate()`
indexes the table once and iterates the slice, replacing the
runtime `__PARENT` walk + per-iteration match. Queue drain switched
from `while pop_front().is_some()` to `Deque::clear()` (heapless
0.8 implements this as O(1) — resets indices instead of popping
each entry). `terminate_event` bench: −22.5% instr / −23.4% cycles.
- **TimerTable empty-skip.** `pop_expired`, `min_remaining`,
`decrement`, and `cancel_state` each get `#[inline]` + an
`is_empty()` early-out, with the non-empty body split into a
`#[cold]` helper. Runtime branch on charts with timers; the body
stays out-of-line.
- **Codegen-static timer-machinery elide.** When a chart declares no
`Duration` triggers (the common case for event-driven HSMs), every
`TimerTable` call is omitted from `step()` and `exit_state()` at
codegen. `step()` returns `None` directly for the next-tick
deadline. `dispatch_trigger()` and the timer-fired arms remain in
the binary but are unreachable; LLVM DCEs them.
- **`TimerEntry.remaining_ns: u128 → u64`.** Internal-only field type
change. u64 nanoseconds bounds at ~584 years — comfortably past any
realistic `Duration`. Eliminates u128 saturating-sub in
`decrement()` (4–6 instructions per entry → 1–2) and the manual
secs/nanos decomposition in `min_remaining()` (replaced with
`Duration::from_nanos`). The struct shrinks 32 B → 16 B with
alignment, so the entire machine struct gets smaller — every bench's
RAM-hit count drops from tighter cache packing.
### Bench coverage
Four new benches added to cover hot paths the original suite missed:
- **`bubble_leaf` / `bubble_mid` / `bubble_top`** — handler resolution
at varying ancestor depths (0 / 3 / 4 hops) on a single chart.
Quantifies the bubble-walk cost as a function of distance, and
confirms the `__DISPATCH` table makes resolution depth-independent.
- **`timer_fire_dispatch`** — only bench that exercises
`pop_expired`'s non-empty branch and `dispatch_trigger`. The most
expensive single-step bench in the suite (191 instr / 706 cycles).
- **`terminate_event`** — only bench exercising `__check_terminate`'s
true arm and `do_terminate`. 69 instr / 311 cycles.
- **`bubble_cross_tree`** — handler on L1 (4 bubble hops up from
current=L5) targets R5 in a sibling subtree (10-state cross-tree
transition). Realistic HSM worst case; 94 instr / 444 cycles —
almost the same as a plain 7-deep cross-tree because both bubble
resolution and transition path are now O(1) lookups.
## 0.4.0 — 2026-04-28
### Changed — observability (breaking)
Generated
+86 -3
View File
@@ -14,6 +14,15 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -195,6 +204,27 @@ dependencies = [
"thiserror",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
@@ -465,7 +495,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hsmc"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"crossterm",
"defmt 0.3.100",
@@ -475,6 +505,7 @@ dependencies = [
"futures",
"heapless 0.8.0",
"hsmc-macros",
"iai-callgrind",
"log",
"ratatui",
"tokio",
@@ -484,7 +515,7 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"hsmc",
"proc-macro2",
@@ -495,12 +526,48 @@ dependencies = [
[[package]]
name = "hsmc-verification"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"creusot-std",
"hsmc",
]
[[package]]
name = "iai-callgrind"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e4910d3a9137442723dfb772c32dc10674c4181ca078d2fd227cd5dce9db0"
dependencies = [
"bincode",
"derive_more",
"iai-callgrind-macros",
"iai-callgrind-runner",
]
[[package]]
name = "iai-callgrind-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d03775318d3f9f01b39ac6612b01464006dc397a654a89dd57df2fd34fb68c3"
dependencies = [
"derive_more",
"proc-macro-error2",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn",
]
[[package]]
name = "iai-callgrind-runner"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74c9743c00c3bca4aaffc69c87cae56837796cd362438daf354a3f785788c68"
dependencies = [
"serde",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -773,6 +840,15 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -804,6 +880,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
@@ -811,6 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["hsmc", "hsmc-macros", "verification"]
[workspace.package]
version = "0.4.0"
version = "0.4.1"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Stephen Waits <steve@waits.net>"]
+500
View File
@@ -0,0 +1,500 @@
# 003. Latency analysis: hsmc hot-path cost surface
This is an audit of where wall-clock and code-size cost lives in `hsmc`'s
event-dispatch path. It is intended to inform mitigation work for embedded
and embassy-rs use cases where every microsecond of dispatch overhead
couples directly into hardware-control loop tightness and jitter.
The audit is **analytical**, with three categories of evidence:
1. **Source reading** of `hsmc/src/lib.rs`, `hsmc/src/journal.rs`, and
`hsmc-macros/src/codegen.rs`.
2. **`cargo expand --release`** of two test programs (one with no
observation features, one with `tokio + journal`) to confirm what the
macro really emits.
3. **`cargo rustc --release --emit=asm`** spot-checks of the compiled
output to confirm LLVM-level claims (jump-table lowering, dead-code
elimination of unused name tables, async-closure code size).
The audit deliberately stops short of recommending mitigations. A ranked
menu of candidates is included at the end so a follow-up planning round
can pick from it; nothing here is a commitment.
---
## 1. Cost surface, ranked by per-event impact
| # | Source | Class | Per-event cost (estimate) | Location |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| # | Source | Class | Per-event cost (estimate) | Status |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| 1 | `dispatch().await` loop | Discretionary, async-only | 2 awaits cold-start / 1 await steady-state per dispatched event. ~free if action body never `Pending`-yields. | open |
| 2 | LCA + transition path build | Discretionary | ~~O(H²) `heapless::Vec<u16,32>::contains` scan~~ → ~~O(H) depth-walk~~ → **O(1) `__PATH_RANGE` lookup**. Up/self/lateral folded into the precomputed table; `lca()`, `is_up_transition()`, and the per-call `heapless::Vec<u16,16>` ancestry build are all gone. | resolved |
| 3 | Parent walk for handler search | Discretionary | ~~O(H) `__PARENT` walk + per-hop `__handler_lookup` call~~ → **O(1) `__DISPATCH[(state, event)]` lookup**. Every (state, event) pair statically resolved at codegen. `__handler_lookup` retained for timer dispatch only. | resolved |
| 4 | `__handler_lookup` nested match (timer path) | Intrinsic | Still used for timer dispatch. LLVM lowers to jump tables (verified `LJTI*`). ~1 indirect jump per call. | open (already at floor) |
| 5 | `run_action` dispatch | Intrinsic | One match arm + monomorphized trait call + 1 vtable hop on `&mut dyn QueuePush` (verified devirtualized when callsite type is known). | open (already at floor) |
| 6 | Event queue push/pop | Intrinsic | `heapless::Deque::push_back` / `pop_front`, both O(1). | open (already at floor) |
| 7 | `__chart_observe!` macro arms | Compile-eliminable when off | Zero residue when all sink features off. Each call site collapses to `{};`. | verified zero-cost |
| 8 | Journal `Vec::push` per atom | Conditional on `journal` | Amortized O(1); ~16 B per `TraceEvent`. ~11 push sites fire per transition. | opt-in |
| 9 | Async-fn future size (binary cost) | Discretionary, async-only | ~3000 asm lines of chart machinery for a 5-state chart; dominated by async-fn state machines. | open (mitigation H3) |
| 10 | `tokio::sync::mpsc::unbounded_channel` | Tokio-only, std-bound | Heap-backed channel for cross-task event injection. Embassy uses bounded `embassy_sync::channel`. | feature-specific |
| 11 | TimerTable per-step / per-exit fixed cost | Discretionary | ~~Unconditional `pop_expired` + `cancel_state` calls every step / exit~~ → **inlined `is_empty()` + `#[cold]` non-empty body** (runtime branch on charts that use timers; compile-time elided on timer-free charts). | resolved |
| 12 | TimerEntry size + per-decrement work | Discretionary | ~~`remaining_ns: u128`, 32-byte entry, u128 saturating-sub per decrement~~ → **`u64`, 16-byte entry, u64 saturating-sub** (~584-year ceiling; Duration::from_nanos round-trips exactly). | resolved |
| 13 | `do_terminate()` exit walk | Discretionary | ~~Runtime `__PARENT` walk + `pop_front`-loop queue drain~~ → **precomputed `__TERMINATE_DATA` slice + `Deque::clear()`** (O(1)). | resolved |
Items 2, 3, 11, 12, 13 are fully resolved by the precompute / inline-
empty-check passes in this round of work — see `hsmc/benches/baseline.txt`
for the per-pass delta blocks. Items 4–6 are at or near the algorithmic
floor for hierarchical state machines. Item 7 is verified zero-cost.
Items 1 (async overhead) and 9 (async machinery binary size) are the
only meaningful open items; both are addressable via a `dispatch_sync()`
fast path (mitigation H3 in §9), which would be a user-facing API
change and is therefore out-of-scope for this round.
---
## 2. Per-event cost model (analytical)
For a chart with hierarchy depth `H=3`, one entry action + one exit action,
a simple lateral transition, no observation features, `cargo build
--release`, on a Cortex-M4-class core (~168 MHz):
| Stage | Cycles (rough) | Notes |
|----------------------------------------|--------------------|------------------------------------------------|
| `queue.pop_front` | ~5–10 | `heapless::Deque` index op |
| `__event_to_id` | ~3–5 | match arm (jump table) |
| `__DISPATCH[(state, event)]` lookup | ~5 | one indexed load + sentinel branch |
| `run_action` (1 action) | ~10–20 | match + monomorphized call + 1 vtable hop |
| Path lookup (`__PATH_RANGE`) | ~5 | one indexed load + slice arithmetic |
| Exit chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Enter chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Timer machinery (timer-free chart) | 0 | codegen-elided when `ir.duration_triggers.is_empty()` |
| Timer machinery (timer chart, no fire) | ~3 | inlined `is_empty()` branches × 3 |
| Timer machinery (timer firing) | ~30–40 | decrement + pop_expired + dispatch_trigger |
| **Total `.step()` steady-state** | ~0.4 – 0.8 µs | for H=3, A=1; user action body excluded |
| Async overhead (`.dispatch()` loop) | per-await checkpoint | ~free if no `Pending`-yield |
These are back-of-envelope numbers; actual measurements would tighten
them by ~2× in either direction, depending on inlining decisions.
For empirical numbers (deterministic, instruction-counted), see
`hsmc/benches/baseline.txt`. Headline numbers (current):
h2_warm_lateral 84 instr / 397 cycles; h4_cross_tree 101 instr / 456
cycles; h8_cross_tree 93 instr / 443 cycles; bubble_top 99 instr /
453 cycles (4-hop handler resolution); bubble_cross_tree 94 instr /
444 cycles (4-hop bubble + 10-state cross-tree); terminate_event 69
instr / 311 cycles; timer_fire_dispatch 191 instr / 706 cycles;
chain_self_emit 419 instr across 4 events (≈105 instr/event).
Cumulative on h8_cross_tree: **-82% instructions / -72% cycles** vs
the original O(H²) LCA scan.
---
## 3. Spot-check evidence
### 3.1 `__chart_observe!` is zero-cost when off (cargo expand)
Test: `tests/state_chart_const.rs`, with no observation features enabled.
In the expanded source (`/tmp/hsmc_expand_no_features.rs`, 911 lines), every
observation site collapses to an empty block:
```rust
// in `step()`:
if self.current.is_none() {
{}; // would be observe_started
self.enter_path(0);
return self.timers.min_remaining();
}
// ...
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
{}; // would be observe_timer_fired
// ...
}
```
A grep for any of `TraceEvent::`, `::log::`, `::tracing::`, `::defmt::`,
`JournalSink::push` in the expanded code returns **zero matches**. The
macro arms entirely vanish.
`JournalSink` itself becomes a unit struct when the feature is off:
```rust
#[cfg(not(feature = "journal"))]
pub struct JournalSink; // ZST — 0 bytes in the chart struct
```
so the `__journal` field on the chart costs zero bytes at runtime.
### 3.2 Name lookups are dead-code-eliminated (cargo rustc --emit=asm)
Test: `tests/det_lateral.rs`, built with `--features tokio,journal`.
Even with `journal` enabled, a grep across the 22,477 lines of generated
asm for `state_name`, `action_name`, `event_name`, or `timer_name` returns
**zero matches**. The journal stores `u16` indices, not strings, and no
trace-* feature is on, so the name-lookup helper functions are uncalled
and DCE'd from the final binary.
This means even if a user enables `journal` for replay/verification, they
pay zero binary cost for the human-readable name tables until they also
enable a `trace-*` sink.
### 3.3 LLVM lowers handler matches to jump tables
Same asm file. Grep for `LJTI`:
```asm
leaq .LJTI2_0(%rip), %rcx
jmpq *%rax
.LJTI2_0:
.long .LBB2_1-.LJTI2_0
.long .LBB2_28-.LJTI2_0
.long .LBB2_29-.LJTI2_0
.long .LBB2_23-.LJTI2_0
```
Multiple jump tables are present, each sized roughly to the chart's
state/event count. This corroborates the assumption that
`__handler_lookup`'s nested `match (state_id) { match (kind, trigger_id) }`
lowers to a single indirect jump on platforms LLVM can target with one,
not a chain of compares.
### 3.4 Async machinery code size
Symbol-by-symbol asm sizes from `det_lateral`:
| Symbol (async closure / future) | Asm lines | Notes |
|---------------------------------|-----------|----------------------------------------------|
| `Lat::exit_state` | ~275 | one `.await` per exit action |
| `Lat::run_action` | ~349 | match arm + N monomorphized async branches |
| `Lat::run_handlers` | ~826 | loop over actions, each `.await` |
| `Lat::enter_state_no_descent` | ~217 | |
| `Lat::step` | ~970 | inlines `lca`, `is_up_transition`, etc. |
| `Lat::dispatch` | ~137 | thin loop wrapper |
| **Lat-chart total** | **~2,800**| async machinery for one 5-state chart |
For comparison, `Lat::new` is 141 lines. The async coloring
roughly doubles to triples the binary footprint of a chart relative to a
pure sync codegen. On Cortex-M0/M0+ where flash is small, this matters.
### 3.5 Journal expansion adds N pushes, nothing else (cargo expand)
Same test, expanded with `--features tokio,journal`
(`/tmp/hsmc_expand_with_journal.rs`, 2,426 lines vs. 911 lines without).
Counts:
- 104 total `TraceEvent::*` constructions.
- 20 distinct variant names (the full vocabulary).
- 8 `Vec::push` call sites inside `step()` — but only one fires per
invocation (timer-fired path, event-received path, etc., are mutually
exclusive).
The diff against the no-feature expansion is exactly: every `{};`
placeholder becomes a `(&mut self.__journal).push(TraceEvent::...)`. No
other infrastructure is added. No locking, no allocation per push beyond
the amortized `Vec` growth.
---
## 4. Observability cost matrix
Distilled from `lib.rs:158-712` and the spot-checks above:
| Feature(s) | Per-call CPU | Per-call alloc | Off → fully elided? |
|-------------------|-------------------------------------------|--------------------------|-------------------------------|
| `(none)` | 0 | 0 | Yes (verified) |
| `journal` | `Vec::push` ~10 ns amortized | ~16-24 B amortized | n/a |
| `trace-defmt` | format-hash u32 + field write | 0 | Yes |
| `trace-log` | `log_enabled!()` check + format if passes | 0 unless format | Yes (with small residual when on but level filtered) |
| `trace-tracing` | unconditional field collection + span | 0 | Yes (with ~30-cycle residual when on but no subscriber) |
The codegen emits **~11 `__chart_observe!` calls per typical transition**
in deterministic order: `EventReceived` → `EventDelivered` →
`TransitionFired` → `ExitBegan` → `ActionInvoked{Exit}` → `Exited` →
`EnterBegan` → `ActionInvoked{Entry}` → `Entered` → `TransitionComplete`,
plus action-handler markers. That's the multiplier on every per-call
number above.
---
## 5. What is *not* a problem (audited and dismissed)
- **Handler-lookup growth with chart size** — confirmed jump-table
lowering (§3.3). O(1) per event regardless of state or event count
(modulo cache effects from larger jump tables).
- **Trait-object cost on action dispatch** — only the `&mut dyn QueuePush`
proxy is `dyn`. User actions are monomorphized trait methods (one match
arm per action ID, direct calls). One vtable hop per emit, *not* per
action.
- **Observation overhead with features off** — confirmed zero. Verified
three ways: macro arm structure (every body `#[cfg]`-gated), expanded
source (every site is `{};`), and asm (no name-lookup symbols, no trace
symbols).
- **Heap allocation in default build** — `extern crate alloc` is confined
to `journal.rs:26`. With `journal` off, the runtime is fully stack-only.
Verified: no `Vec`, `Box`, or `String` in expanded sync code.
- **`Pin<Box<...>>` futures on the hot path** — none. Chart futures are
inline state machines, not boxed.
- **Tokio `unbounded_channel` for embedded** — irrelevant: tokio is `std`
anyway. Embassy uses bounded `embassy_sync::channel::Channel` (no
alloc).
---
## 6. Event entry points
Events reach the chart's internal `heapless::Deque` via four distinct
paths. Each has its own latency, wakeup, ordering, and backpressure
profile, summarized below.
| Path | API | Backing | Wakeup | Ordering | Backpressure |
|------|-------------------------------------------|--------------------------------------------|-------------------------|----------------------------------------|------------------------------------|
| 1 | `chart.send(event)` (sync, single-task) | `heapless::Deque::push_back` | none — caller drives | FIFO within queue | `Err(QueueFull)` immediate |
| 2a | `chart.sender().send(ev)` (tokio) | `tokio::sync::mpsc::unbounded_channel` | tokio task wake | FIFO; biased select deprioritizes vs. durings | **unbounded memory** |
| 2b | `chart.sender().send(ev)` (embassy) | `embassy_sync::channel::Channel` (bounded) | embassy task wake | FIFO; same biased-select | `Err(Full)` (try_send) or backpressure-await |
| 3 | `actx.emit(ev)` from inside an action | same internal `Deque` as path 1 | none — drained inline | drained synchronously after current handler completes; *before* `step()` returns | sticky overflow flag |
| 4 | return from a `during:` future | `select!` arm in run loop | driver-specific | biased priority over channel events | silent drop on `Deque` full |
**Path 1 (sync send)** — `codegen.rs:2316-2319`. Bare
`Deque::push_back`; owning task only, no synchronization. ~5–10 cycles.
Caller is responsible for invoking `step()` (or `dispatch()` under an
async feature) to actually process the event.
**Path 2 (cross-task `sender()`)** — `codegen.rs:1492` (tokio sender),
`1638` (embassy sender), `1308` / `1405` (run-loop receive sites). The
canonical cross-thread / ISR path per the spec. Tokio uses an unbounded
MPSC (heap-backed; no backpressure signal); embassy uses a bounded
`Channel<M, E, N>` with `try_send` for ISR-safe non-blocking emits and
an `async send` that backpressures the producer. The receiver task
wakes on send; one iteration of the run-loop's `select!` consumes the
event and pushes it into the internal `Deque`.
**Path 3 (self-emit from action)** — `actx.emit(...)` goes through
`EmitProxy::push` (`hsmc/src/lib.rs`) which writes into the same
internal `Deque` as Path 1. Self-emitted events are drained in the
*same* `step()` call that emitted them — `dispatch()` runs `while
!queue.is_empty() { step().await }` until quiescence. Nothing crosses a
task boundary.
**Path 4 (`during:` return)** — `codegen.rs:1306–1313` (tokio with
durings), `1392+` (embassy). Durings are racing futures spawned in the
run-loop's `select!`. When one resolves to an event, the `select!`
unblocks and the event is pushed into the internal `Deque`. The
`select!` is `biased;` so durings have priority over the channel —
relevant if a during is a poll-based driver that's almost always
ready (see §8).
**One ordering caveat**: there is no global FIFO across paths 1–4.
Within each path, events are FIFO; across paths, the order depends on
when each path's wakeup arrives at the run loop. For most charts this
is a non-issue, but a chart that mixes high-frequency cross-task sends
with rapid self-emits could observe self-emits processed before
external events that arrived "first" in wall-clock terms.
---
## 7. Cooperative-driving audit
The library targets embassy-rs's single-priority cooperative scheduler,
where any tight loop without an `.await` can starve other tasks. An
audit of every loop and await pattern in the runtime and codegen:
| Loop / await | Location | Bounded? | Awaits per iter? | Verdict |
|---------------------------------------|---------------------------|-------------------------|--------------------------|---------------------|
| Main `run()` outer | codegen.rs:1290+, 1379+ | by `terminated` flag | yes — always | cooperative |
| `dispatch()` drain loop | codegen.rs:2339 | by queue size | only via `step().await` | suspect — fairness |
| Transition exit/enter slice loops | codegen.rs (transition) | by precomputed slice len| yes (async variant) | OK |
| Termination drain | codegen.rs:2750 | by hierarchy + queue | yes (async variant) | OK |
| Timer decrement / pop | runtime / lib.rs | by timer-table size | sync, off the event-loop | OK |
| Send/recv backends | codegen.rs:1308, 1405 | n/a | always `.await` | cooperative |
No `try_recv` patterns anywhere. No fixed-period polling. The library
is fundamentally event-driven and embassy-friendly.
**One latent fairness characteristic**: the `dispatch()` drain loop —
`while !self.queue.is_empty() { step(Duration::ZERO).await }` — never
yields to the executor between iterations. If user actions are all
synchronous and they emit a chain of follow-on events (action A emits
B, B emits C, …), each `step().await` completes inline, and the loop
becomes effectively synchronous for the duration of the chain. Embassy
high-priority tasks could be delayed by the chain length.
This is **not a bug** — `dispatch()` is documented to drain to
quiescence — but it's worth knowing about. A future option (not part of
this round of work) would be a feature-gated
`embassy_futures::yield_now().await` every Nth iteration, or a
chart-level config to bound chain length. Not done by default because
it would serialize the common case for no benefit.
---
## 8. Correctness invariants surfaced during the audit
Three contracts that aren't bugs, but warrant explicit documentation
because they are easy to miss when reading the code:
**Sticky overflow flag in `EmitProxy`** (`hsmc/src/lib.rs`,
`EmitProxy::push`). When a self-emit fails because the internal `Deque`
is full, an `__overflow` flag is set on the chart. *Every* subsequent
`actx.emit(...)` in the same dispatch chain returns `Err(QueueFull)`,
even after the queue has drained. The flag is cleared at the top of
each `dispatch()` call by `take_overflow()`. This is intentional — it
prevents a chart from observing a partially-applied sequence of
self-emissions where some made it into the queue and others did not.
Authors of actions that emit multiple events should treat
`Err(QueueFull)` as terminal for the current chain.
**Biased select in run loop** (tokio: `codegen.rs:1306–1313`; embassy:
`1392+`). The `select!` is `biased;`, so durings are always polled
before the cross-task channel. For most charts this is the right
priority — a sensor driver should pre-empt a UI event. But for a
`during:` that wraps a poll-based driver (one that returns `Ready`
nearly every poll), the channel can be starved indefinitely. Cure:
make the during yield (`embassy_futures::yield_now`) when there's
nothing real to report.
**Cancel-safety contract on durings** (`hsmc/src/lib.rs:53–60`). Every
`.await` in a `during:` future must be a clean resume point, because
the future will be dropped if the state transitions before it
completes. This is the standard async-cancel-safety contract, but it
is easy to violate by holding a mutable borrow across an await — the
borrow is "valid" when resumed but the surrounding state may have
moved on.
---
## 9. Candidate mitigations (menu, NOT a commitment)
Sized for sequencing in a follow-up plan-mode session. "Effort" is gut
feel: L = afternoon, M = couple of days, H = a week or more.
| ID | Mitigation | Targets | Impact | Effort | Cost |
|-----|-----------------------------------------------------------------------------------------------------------------------------------|-----------|-----------------------------------------|--------|---------------------------------------|
| H0 | Replace `lca()` with an O(H) depth-walk via the `__DEPTH` table. | #2 | **Landed.** | - | - |
| H1 | Codegen per-(from, to) precomputed exit/enter chains as `__PATH_DATA` + `__PATH_RANGE`. | #2 | **Landed.** Up/self/lateral folded into one indexed lookup; `lca()` and `is_up_transition()` removed. | - | ~`6·N²` B index + ~`N²·H` B path data |
| T1 | Inlined `is_empty()` early-out on `pop_expired` / `min_remaining` / `decrement` / `cancel_state` with `#[cold]` non-empty bodies. | #11 | **Landed.** | - | - |
| T2 | Codegen-static elide of all `TimerTable` calls when `ir.duration_triggers.is_empty()`. | #11 | **Landed.** Most charts are timer-free; `step()` returns `None` directly. | - | - |
| H4 | `__DISPATCH[(state, event)]` const array; one indexed lookup replaces the runtime __PARENT bubble walk + per-hop `__handler_lookup`. | #3 | **Landed.** Each (state, event) statically resolved at codegen. | - | ~24 B per (state, event) pair |
| U1 | Shrink `TimerEntry.remaining_ns: u128 → u64` (~584-year ceiling). Halves entry size; replaces u128 saturating-sub with u64. | #12 | **Landed.** | - | - |
| K1 | `__TERMINATE_DATA` per-state precomputed exit chain (root inclusive); `do_terminate()` indexes a slice instead of walking __PARENT, and uses `Deque::clear()` (O(1)) for the queue drain. | #13 | **Landed.** terminate_event -22.5% instr / -23.4% cycles. | - | ~`(H+1)·N` B path data |
| H3 | Provide a `dispatch_sync()` API that bypasses `.await` when the chart's actions are fully synchronous. | #1, #9 | High for sync-action charts | L-M | API surface growth (user-facing) |
| H5 | Sparse `__PATH_RANGE` / `__DISPATCH` for very large charts (N > 64): emit only reachable pairs with a small perfect-hash. Latency-neutral; cuts O(N²) static memory growth. | cost mitigation | Low at typical N; significant at N > 100 | M | codegen complexity |
| M1 | Eliminate the trailing `step().await` in `dispatch()` when queue empties mid-step. | #1 | Low | L | minor refactor |
| M2 | Lazy field eval in `trace-tracing` arm using `tracing`'s field-closure form to skip evaluation when filtered. | #8 (on) | Low | L | none |
H0/H1/T1/T2/H4/U1/K1 all landed in the most recent round of work.
Callgrind now reports the entire HSM machinery as a single `step`
symbol — every helper inlined. The only remaining optimization avenue
that meaningfully affects per-event latency is H3 (`dispatch_sync()`),
which is excluded as user-facing. Memory-side mitigations (H5) only
matter for N > 64-state charts; embedded users with large charts are
the audience.
---
## 10. Where to look in the source
For anyone reading this audit and wanting to verify or extend it:
- **`hsmc/src/lib.rs`**
- `:94` — the `#![cfg_attr(... no_std)]` boundary
- `:158-712` — `__chart_observe!` macro, all observation arms
- `:737-772` — `JournalSink` ZST/Vec duality
- **`hsmc/src/journal.rs`**
- `:26-32` — `extern crate alloc` and `Vec` import (only here)
- `:65-150+` — `TraceEvent` enum (the journal vocabulary)
- **`hsmc-macros/src/codegen.rs`**
- `compute_transition_paths()` (top of file, after `compute_chart_hash`) — builds `__PATH_DATA` + `__PATH_RANGE` at codegen time
- `__PARENT`, `__DEPTH`, `__DEFAULT_CHILD`, `__PATH_DATA`, `__PATH_RANGE` static-table emission (search "const __PARENT")
- `step()` (search "fn step" / "async fn step") — sync vs. async branches
- `dispatch()` await loop (search "fn dispatch")
- `run_action` (search "fn run_action") — sync vs. async
- `transition` (search "fn transition") — now a thin slice-iter wrapper
- name-lookup helpers (`__state_name`, `__action_name`, etc.) at `:631-667`
- **Test reference shape**: `hsmc/tests/state_chart_const.rs` is the
smallest sync-only chart in the tree; `hsmc/tests/det_lateral.rs` is
the reference async + journal shape used for the asm spot-check.
---
## 11. Reproducing the spot-checks
```sh
# Zero-cost observation verification (expand → no observation residue)
cargo expand --release --test state_chart_const \
> /tmp/expand_no_features.rs --manifest-path hsmc/Cargo.toml
grep -E "TraceEvent::|::log::|::tracing::|::defmt::" /tmp/expand_no_features.rs
# expect: no matches
# Journal expansion (count of TraceEvent push sites)
cargo expand --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml \
| grep -c "TraceEvent::"
# expect: ~100
# Asm spot-check (jump tables, function sizes, name DCE)
cargo rustc --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml -- --emit=asm
ASM=$(find target -name 'det_lateral*.s' | head -1)
grep -E "LJTI|jmpq?[[:space:]]+\*" "$ASM" # jump tables
grep -E "state_name|action_name" "$ASM" # expect no matches
```
---
## 12. Summary
`hsmc`'s hot path is mostly already at the algorithmic floor for
hierarchical state machines. The intrinsic per-event cost (parent walk,
handler lookup, action dispatch, queue ops) is small and well-monomorphized.
Observation cost is verified zero when features are off, and stays
proportional to the user's chosen sink mix when on.
**Items #2, #3, #11, #12, and #13 in §1 have all landed** across six
optimization passes (H0 → H1 → T1 → H4 → T2 → K1+U1). Cumulative impact
on the deepest dispatch (h8_cross_tree) is **-82% instructions / -72%
cycles** vs the original O(H²) LCA scan. The headline wins by bench:
- `terminate_event` 69 instr / 311 cycles (~14 instr of HSM work after
subtracting bench wrapper) — `do_terminate()` is one indexed slice
+ `Deque::clear()`.
- `bubble_top` 99 instr / 453 cycles — 4-hop handler resolution
collapses to one indexed `__DISPATCH` lookup; per-dispatch cost is
bench-shape-independent.
- `bubble_cross_tree` 94 instr / 444 cycles — realistic HSM worst
case (deep bubble + 10-state cross-tree) costs the same as a plain
cross-tree because bubble lookup is O(1) and transition is
O(path-length).
- `h8_cross_tree` 93 instr / 443 cycles — 7-deep cross-tree dispatch.
Callgrind reports the entire HSM machinery as a single `step` symbol —
`run_handlers`, `run_action`, `transition`, `do_terminate`,
`exit_state`, `enter_state_no_descent`, `EmitProxy::push`, all four
`TimerTable` methods, the dispatch-table lookup, and the
terminate-path lookup are all inlined.
Cost remaining, in priority order:
1. **Async coloring code-size cost** (item #9) — chart machinery is
~3,000 asm lines for a 5-state chart, dominated by async-fn state
machines. Mitigation H3 (a `dispatch_sync()` fast path) would let
users with all-sync actions skip this entirely. **User-facing API
change** — out of scope for the current optimization pass.
2. **Bench-wrapper noise floor** — at the smallest dispatch's ~38–80
instructions, the iai-callgrind wrapper (`black_box`, `send`,
instrumented call-edge crossing) accounts for ~45 instructions.
Sub-bench-wrapper improvements are below the measurement floor.
3. **Memory cost of `__PATH_DATA` / `__PATH_RANGE` / `__DISPATCH` /
`__TERMINATE_DATA` at large N** — `__PATH_RANGE` is O(N²);
`__DISPATCH` is O(N×E); `__TERMINATE_DATA` is O(N×H). For N ≤ 32 /
E ≤ 16 / H ≤ 8 the total static footprint is a few KiB. A
64-state × 32-event chart would push past ~80 KiB. Mitigation H5
sketches a sparse encoding.
All addressable purely in `hsmc-macros/src/codegen.rs` and `hsmc/src/lib.rs`
without breaking runtime semantics or feature gates. The bench harness
in `hsmc/benches/dispatch.rs` (13 benches, every dispatch shape
covered) is in place to measure each mitigation as it lands.
+564 -191
View File
@@ -4,7 +4,7 @@ use proc_macro2::{Span, TokenStream};
use quote::{format_ident, quote};
use syn::Ident;
use crate::ir::{HandlerKindIr, Ir, TriggerIr, VariantKind};
use crate::ir::{HandlerKindIr, Ir, StateIr, TriggerIr, VariantKind};
use crate::parse::{During, PayloadKind};
/// FNV-1a 64-bit hash of a chart's structural fingerprint. Stable across
@@ -88,6 +88,294 @@ fn compute_chart_hash(ir: &Ir) -> u64 {
h
}
/// (exit_data, enter_data, exit_range, enter_range) — flat blobs of u16
/// state ids and `(start, end)` index pairs into them, one per (from, to).
/// Returned by `compute_transition_paths()` and folded by the caller into
/// the consolidated `__PATH_DATA` / `__PATH_RANGE` tables.
type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)>);
/// Static precompute of every (from, to) state pair's exit and enter sequence,
/// folded into flat tables emitted alongside `__PARENT` / `__DEPTH`. Replaces
/// the runtime LCA depth-walk + ancestry walks + heapless::Vec construction
/// in `transition()` with two slice lookups and two straight slice iterations.
///
/// The (from, to) lookup matches the runtime LCA call: `from` is the active
/// leaf (`self.current`), `to` is the transition target. The same three
/// transition shapes are encoded uniformly by what the precomputed paths
/// contain:
///
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
/// `from..to` (target excluded; `to` is already on the active path and
/// must not be re-entered). enter_path is empty — `transition()` sets
/// `self.current = Some(target)` after the loops.
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
/// is `[to, default_child(to), default_child(default_child(to)), ...]`
/// — re-enter target plus descend defaults.
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
/// `from` up to but not including LCA. enter_path walks
/// LCA-side child of `to` down to `to`, then continues descending
/// `__DEFAULT_CHILD` from `to`.
///
/// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)`
/// offsets into them. Entries for unreachable pairs are still emitted (we
/// don't know which pairs are reachable without running the dispatcher's
/// full reachability analysis), but they cost only their few u16s of path
/// data and a single `(u16, u16)` index slot — and `transition()` never
/// looks them up at runtime.
fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
let n = states.len();
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
let default_child = |s: u16| -> Option<u16> { states[s as usize].default_child };
let depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
let lca = |mut a: u16, mut b: u16| -> Option<u16> {
let mut da = depth(a);
let mut db = depth(b);
while da > db {
a = parent(a)?;
da -= 1;
}
while db > da {
b = parent(b)?;
db -= 1;
}
while a != b {
a = parent(a)?;
b = parent(b)?;
}
Some(a)
};
let is_strict_ancestor = |anc: u16, desc: u16| -> bool {
let mut cur = parent(desc);
while let Some(c) = cur {
if c == anc {
return true;
}
cur = parent(c);
}
false
};
let compute_one = |from: u16, to: u16| -> (Vec<u16>, Vec<u16>) {
// Up-transition: target is a strict ancestor of current. Exit
// strictly between them; do not re-enter target.
if from != to && is_strict_ancestor(to, from) {
let mut exit_path = Vec::new();
let mut cur = from;
while cur != to {
exit_path.push(cur);
cur = parent(cur).expect("up-transition walk must reach `to`");
}
return (exit_path, Vec::new());
}
// Self-transition: external semantics — exit and re-enter target,
// then descend defaults.
if from == to {
let exit_path = vec![to];
let mut enter_path = vec![to];
let mut cur = default_child(to);
while let Some(c) = cur {
enter_path.push(c);
cur = default_child(c);
}
return (exit_path, enter_path);
}
// Lateral: exit `from` up to (not including) LCA; enter from
// LCA-child on `to`'s side down to `to`, then descend defaults.
let lca_id = lca(from, to);
let mut exit_path = Vec::new();
let mut cur = from;
loop {
if Some(cur) == lca_id {
break;
}
exit_path.push(cur);
match parent(cur) {
Some(p) => cur = p,
None => break,
}
}
let mut rev = Vec::new();
let mut cur = to;
loop {
if Some(cur) == lca_id {
break;
}
rev.push(cur);
match parent(cur) {
Some(p) => cur = p,
None => break,
}
}
let mut enter_path: Vec<u16> = rev.into_iter().rev().collect();
let mut cur = default_child(to);
while let Some(c) = cur {
enter_path.push(c);
cur = default_child(c);
}
(exit_path, enter_path)
};
let mut exit_data: Vec<u16> = Vec::new();
let mut enter_data: Vec<u16> = Vec::new();
let mut exit_range: Vec<(u16, u16)> = Vec::with_capacity(n * n);
let mut enter_range: Vec<(u16, u16)> = Vec::with_capacity(n * n);
for from in 0..n as u16 {
for to in 0..n as u16 {
let (ep, np) = compute_one(from, to);
let estart: u16 = exit_data
.len()
.try_into()
.expect("exit path data exceeds u16 offset range");
exit_data.extend(ep.iter().copied());
let eend: u16 = exit_data
.len()
.try_into()
.expect("exit path data exceeds u16 offset range");
exit_range.push((estart, eend));
let nstart: u16 = enter_data
.len()
.try_into()
.expect("enter path data exceeds u16 offset range");
enter_data.extend(np.iter().copied());
let nend: u16 = enter_data
.len()
.try_into()
.expect("enter path data exceeds u16 offset range");
enter_range.push((nstart, nend));
}
}
(exit_data, enter_data, exit_range, enter_range)
}
/// Static precompute of `do_terminate()`'s exit walk: for each leaf state,
/// the chain `[current, parent(current), ..., __Root]` (root inclusive,
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared
/// at the chart top level — see the `det_initial` test for that pattern).
///
/// Replaces the runtime `__PARENT` walk + per-iteration `match` in
/// `do_terminate` with one indexed slice into a flat blob. The terminate
/// path is always at most `H + 1` states long, so the total table size is
/// bounded by `N × (H_max + 1)` — under a KiB for any practical chart.
fn compute_terminate_paths(states: &[StateIr]) -> (Vec<u16>, Vec<(u16, u16)>) {
let n = states.len();
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
let mut data: Vec<u16> = Vec::new();
let mut range: Vec<(u16, u16)> = Vec::with_capacity(n);
for from in 0..n as u16 {
let start: u16 = data
.len()
.try_into()
.expect("terminate path data exceeds u16 offset range");
let mut cur = Some(from);
while let Some(s) = cur {
data.push(s);
cur = parent(s);
}
let end: u16 = data
.len()
.try_into()
.expect("terminate path data exceeds u16 offset range");
range.push((start, end));
}
(data, range)
}
/// Static precompute of the runtime event-bubble walk in `step()`.
///
/// Currently the dispatcher does:
///
/// ```text
/// while let Some(s) = __cur {
/// if let Some((actions, target)) = __handler_lookup(s, 0, __ev_id) { ... break; }
/// __cur = __PARENT[s as usize];
/// }
/// ```
///
/// — at runtime, walking up `__PARENT` until a state with an `on(EventX)`
/// handler is found. Each hop costs ~10-12 instructions (call + branch +
/// load), and the depth depends on where the handler is declared. With
/// every (state, event) pair statically resolvable from the chart, the
/// walk collapses to one indexed lookup.
///
/// For each `(current_state, event_id)` pair we materialize:
/// - `handler_state` — the deepest ancestor (or `current_state` itself)
/// with a matching `on()` clause, or `u16::MAX` if no ancestor handles
/// the event (runtime drops it).
/// - `action_ids` — the aggregated action ids that the original
/// `state_handler_arms` codegen would have produced for that
/// (state, event) pair, in declaration order.
/// - `target` — the transition target if any.
///
/// The aggregation rules mirror `state_handler_arms` exactly: per
/// (state, event), action handlers stack into an ordered Vec and the
/// (singular) transition target is captured if present. This is the
/// same shape `__handler_lookup` returns, just resolved at codegen time
/// for events. Timer triggers stay in `__handler_lookup` (the
/// `dispatch_trigger` path doesn't bubble).
fn compute_event_dispatch(ir: &Ir) -> Vec<(u16, Vec<u16>, Option<u16>)> {
use std::collections::BTreeMap;
/// `event_id → (action_ids, transition_target)` aggregation for one state.
type StateHandlers = BTreeMap<u16, (Vec<u16>, Option<u16>)>;
let n_states = ir.states.len();
let n_events = ir.event_variants.len();
// Per-state, per-event handler resolution. Mirrors the aggregation in
// `state_handler_arms` (sort by decl_index, group by trigger).
let mut per_state: Vec<StateHandlers> = vec![StateHandlers::new(); n_states];
for (sid, state) in ir.states.iter().enumerate() {
let mut handlers = state.handlers.iter().collect::<Vec<_>>();
handlers.sort_by_key(|h| h.decl_index);
for h in handlers {
let event_id = match &h.trigger {
TriggerIr::Event(id, _, _) => *id,
TriggerIr::Duration(_) => continue,
};
let entry = per_state[sid].entry(event_id).or_default();
match &h.kind {
HandlerKindIr::Action(aid, _) => entry.0.push(*aid),
HandlerKindIr::Transition(_, Some(tid)) => entry.1 = Some(*tid),
HandlerKindIr::Transition(_, None) => {}
}
}
}
// For each (state, event_id), bubble up __PARENT and stop at the
// first state with a matching handler. No-handler ⇒ sentinel.
let mut out = Vec::with_capacity(n_states * n_events);
for state in 0..n_states {
for event_id in 0..n_events as u16 {
let mut cur = Some(state as u16);
let mut resolved: Option<(u16, Vec<u16>, Option<u16>)> = None;
while let Some(s) = cur {
if let Some((actions, target)) = per_state[s as usize].get(&event_id) {
resolved = Some((s, actions.clone(), *target));
break;
}
cur = ir.states[s as usize].parent;
}
out.push(resolved.unwrap_or((u16::MAX, Vec::new(), None)));
}
}
out
}
/// For each user-declared leaf state that has one or more `during:` activities
/// active (either declared on it, on any ancestor, or on root), return its
/// state id and the flattened list of durings in leaf-first order. Durings
@@ -907,6 +1195,73 @@ pub fn generate(ir: &Ir) -> TokenStream {
);
};
// Codegen-static elide of the timer-table machinery for charts that
// declare no `Duration` triggers. The TimerTable is still constructed
// (the field is part of the machine struct so the generic parameters
// line up), but every per-step / per-exit call into it is gone — no
// empty-table branch, no `min_remaining()`-returns-None call, no
// `cancel_state()`. `step()` returns `None` directly for the
// next-tick deadline. For the bench charts (none of which arm
// timers) this drops several instructions per `step()` and one per
// `exit_state()`. `dispatch_trigger()` and the timer-fired arms
// remain in the binary but are unreachable; LLVM DCEs them.
let has_timers = !ir.duration_triggers.is_empty();
let timer_decrement_call = if has_timers {
quote! { self.timers.decrement(elapsed); }
} else {
quote! {}
};
let timer_pop_block_sync = if has_timers {
quote! {
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
#observe_timer_fired
if __duration_repeats(trig) {
let d = __duration_for(trig);
self.timers.start(state, trig, d);
let sid = state;
let tid = trig;
#observe_timer_armed
}
self.dispatch_trigger(state, 1, trig);
return self.timers.min_remaining();
}
}
} else {
quote! {}
};
let timer_pop_block_async = if has_timers {
quote! {
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
#observe_timer_fired
if __duration_repeats(trig) {
let d = __duration_for(trig);
self.timers.start(state, trig, d);
let sid = state;
let tid = trig;
#observe_timer_armed
}
self.dispatch_trigger(state, 1, trig).await;
return self.timers.min_remaining();
}
}
} else {
quote! {}
};
let timer_min_remaining_expr = if has_timers {
quote! { self.timers.min_remaining() }
} else {
quote! { None }
};
let timer_cancel_state_call = if has_timers {
quote! { self.timers.cancel_state(sid); }
} else {
quote! {}
};
// Per-state during count, used to emit DuringStarted/Cancelled
// events one per declared during in the state.
let durings_count_arms: Vec<TokenStream> = ir
@@ -971,6 +1326,74 @@ pub fn generate(ir: &Ir) -> TokenStream {
None => quote! { None },
})
.collect::<Vec<_>>();
// Path data is concatenated [exit; enter] per pair into a single
// `__PATH_DATA` blob; one packed `(start, mid, end)` per pair indexes
// both halves. One range lookup, one base array — minimizes cold-cache
// line touches on the dispatch hot path (vs four separate tables).
let (
transition_exit_data,
transition_enter_data,
transition_exit_range,
transition_enter_range,
) = compute_transition_paths(&ir.states);
let mut path_data: Vec<u16> =
Vec::with_capacity(transition_exit_data.len() + transition_enter_data.len());
let mut path_range: Vec<(u16, u16, u16)> = Vec::with_capacity(transition_exit_range.len());
for ((es, ee), (ns, ne)) in transition_exit_range
.iter()
.zip(transition_enter_range.iter())
{
let start: u16 = path_data
.len()
.try_into()
.expect("transition path data exceeds u16 offset range");
path_data.extend_from_slice(&transition_exit_data[*es as usize..*ee as usize]);
let mid: u16 = path_data
.len()
.try_into()
.expect("transition path data exceeds u16 offset range");
path_data.extend_from_slice(&transition_enter_data[*ns as usize..*ne as usize]);
let end: u16 = path_data
.len()
.try_into()
.expect("transition path data exceeds u16 offset range");
path_range.push((start, mid, end));
}
let path_data_len = path_data.len();
let n_pairs = n_states * n_states;
let path_data_lit: Vec<_> = path_data.iter().map(|x| quote! { #x }).collect();
let path_range_lit: Vec<_> = path_range
.iter()
.map(|(a, b, c)| quote! { (#a, #b, #c) })
.collect();
// Precomputed terminate-path table: for every state, the exit chain
// up to and including __Root. `do_terminate()` uses this instead of
// walking __PARENT at runtime.
let (terminate_data, terminate_range) = compute_terminate_paths(&ir.states);
let terminate_data_len = terminate_data.len();
let terminate_data_lit: Vec<_> = terminate_data.iter().map(|x| quote! { #x }).collect();
let terminate_range_lit: Vec<_> = terminate_range
.iter()
.map(|(a, b)| quote! { (#a, #b) })
.collect();
// Precomputed event-dispatch table: for every (state, event_id) pair,
// the resolved handler that the runtime bubble walk would have found.
// See `compute_event_dispatch` for encoding.
let dispatch_table = compute_event_dispatch(ir);
let n_events = ir.event_variants.len();
let n_dispatch = n_states * n_events;
let dispatch_lit: Vec<TokenStream> = dispatch_table
.iter()
.map(|(h_state, actions, target)| {
let actions_lit = actions.iter().map(|a| quote! { #a });
let target_tok = match target {
Some(t) => quote! { Some(#t) },
None => quote! { None },
};
quote! { (#h_state, &[#(#actions_lit),*], #target_tok) }
})
.collect();
let state_id_to_variant_arms = ir
.states
.iter()
@@ -1998,6 +2421,51 @@ pub fn generate(ir: &Ir) -> TokenStream {
const __DEPTH: [u8; #n_states] = [#(#depth_table),*];
const __DEFAULT_CHILD: [Option<u16>; #n_states] = [#(#default_child_table),*];
// ── Precomputed transition paths ────────────────────────────
//
// For every (from, to) state pair, the exit-then-enter sequence
// a transition would walk is materialized at codegen time. The
// dispatch hot path (`fn transition`) becomes one range lookup
// and two straight loops; the runtime LCA depth-walk and the
// heapless::Vec ancestry construction are gone.
//
// Encoding: `__PATH_DATA` is the concatenation of every
// `[exit; enter]` slice. `__PATH_RANGE[i*N + j] = (s, m, e)`
// gives the boundaries: `exit = data[s..m]`, `enter = data[m..e]`.
// Empty enter slice (`m == e`) ⇒ up-transition, target excluded;
// otherwise the enter slice already includes the default-descent
// tail so no separate `descend_defaults()` call is needed.
//
// Folding both ranges into one struct halves the cold-cache
// line touches on first dispatch — the up-transition hot path
// does one indexed load instead of two.
const __PATH_DATA: [u16; #path_data_len] = [#(#path_data_lit),*];
const __PATH_RANGE: [(u16, u16, u16); #n_pairs] = [#(#path_range_lit),*];
// ── Precomputed terminate exit paths ───────────────────────
//
// For every starting state, the full exit chain up to and
// including __Root. `do_terminate()` indexes into this instead
// of walking __PARENT at runtime.
const __TERMINATE_DATA: [u16; #terminate_data_len] = [#(#terminate_data_lit),*];
const __TERMINATE_RANGE: [(u16, u16); #n_states] = [#(#terminate_range_lit),*];
// ── Precomputed event-dispatch table ────────────────────────
//
// For every (current_state, event_id) pair, the deepest ancestor
// state with an `on(EventX)` clause is resolved at codegen time.
// The runtime `step()` event-handling block does one indexed
// lookup instead of an `__PARENT` bubble walk + `__handler_lookup`
// call per ancestor.
//
// Entry layout: (handler_state, action_ids, transition_target).
// `handler_state == u16::MAX` is the no-handler sentinel — the
// event would have been dropped at runtime. The dropped/delivered
// observation arms key off that sentinel, preserving the existing
// journal vocabulary.
const __DISPATCH: [(u16, &'static [u16], Option<u16>); #n_dispatch] =
[#(#dispatch_lit),*];
#name_helpers
#[allow(dead_code, unreachable_patterns)]
@@ -2008,7 +2476,12 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
}
// Hot-path lookup helpers. `#[inline]` so LLVM folds the
// (typically jump-table) match directly into `step()` /
// `transition()` / `exit_state()`, removing a call+return per
// dispatched event and cutting one stack frame.
#[allow(unreachable_code, unused_variables)]
#[inline]
fn __duration_for(tid: u16) -> ::hsmc::Duration {
match tid {
#(#duration_expr_arms)*
@@ -2017,6 +2490,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
#[allow(unreachable_code, unused_variables)]
#[inline]
fn __duration_repeats(tid: u16) -> bool {
match tid {
#(#duration_repeat_arms)*
@@ -2025,6 +2499,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
#[allow(unreachable_patterns, unused_variables)]
#[inline]
fn __event_to_id(ev: &#ev_ty) -> u16 {
match ev {
#(#event_variant_arms)*
@@ -2033,6 +2508,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
#[allow(unused_variables)]
#[inline]
fn __handler_lookup(
state_id: u16,
trigger_kind: u8,
@@ -2046,6 +2522,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
#[allow(unused_variables)]
#[inline]
fn __owned_timers(state_id: u16) -> &'static [u16] {
match state_id {
#(#owned_timers_arms)*
@@ -2056,6 +2533,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
enum TerminateCheck { Yes, No }
#[allow(unused_variables)]
#[inline]
fn __check_terminate(__ev: &#ev_ty) -> TerminateCheck {
#terminate_match
TerminateCheck::No
@@ -2188,25 +2666,12 @@ pub fn generate(ir: &Ir) -> TokenStream {
if self.current.is_none() {
#observe_started
self.enter_path(0);
return self.timers.min_remaining();
return #timer_min_remaining_expr;
}
self.timers.decrement(elapsed);
#timer_decrement_call
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
#observe_timer_fired
if __duration_repeats(trig) {
let d = __duration_for(trig);
self.timers.start(state, trig, d);
let sid = state;
let tid = trig;
#observe_timer_armed
}
self.dispatch_trigger(state, 1, trig);
return self.timers.min_remaining();
}
#timer_pop_block_sync
if let Some(ev) = self.queue.pop_front() {
let __ev_id = __event_to_id(&ev);
@@ -2217,31 +2682,35 @@ pub fn generate(ir: &Ir) -> TokenStream {
return None;
}
if __ev_id != u16::MAX {
let mut __cur = self.current;
let mut __delivered = false;
while let Some(s) = __cur {
if let Some((actions, target)) = __handler_lookup(s, 0, __ev_id) {
let __handler_state = s;
// Resolve handler via precomputed __DISPATCH table:
// one indexed lookup replaces the runtime __PARENT
// bubble walk + per-hop __handler_lookup() call.
// u16::MAX in the handler_state slot is the
// no-handler sentinel.
if let Some(__cur) = self.current {
let __dispatch_idx =
(__cur as usize) * #n_events + (__ev_id as usize);
let (__handler_state, __actions, __target) =
__DISPATCH[__dispatch_idx];
if __handler_state != u16::MAX {
#observe_event_delivered
self.run_handlers(
s, actions, target, Some(&ev),
__handler_state, __actions, __target, Some(&ev),
1u8, __ev_id, 0u16,
);
__delivered = true;
break;
} else {
#observe_event_dropped
}
__cur = __PARENT[s as usize];
}
if !__delivered {
} else {
#observe_event_dropped
}
} else {
#observe_event_dropped
}
return self.timers.min_remaining();
return #timer_min_remaining_expr;
}
self.timers.min_remaining()
#timer_min_remaining_expr
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
@@ -2252,25 +2721,12 @@ pub fn generate(ir: &Ir) -> TokenStream {
if self.current.is_none() {
#observe_started
self.enter_path(0).await;
return self.timers.min_remaining();
return #timer_min_remaining_expr;
}
self.timers.decrement(elapsed);
#timer_decrement_call
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
#observe_timer_fired
if __duration_repeats(trig) {
let d = __duration_for(trig);
self.timers.start(state, trig, d);
let sid = state;
let tid = trig;
#observe_timer_armed
}
self.dispatch_trigger(state, 1, trig).await;
return self.timers.min_remaining();
}
#timer_pop_block_async
if let Some(ev) = self.queue.pop_front() {
let __ev_id = __event_to_id(&ev);
@@ -2281,31 +2737,32 @@ pub fn generate(ir: &Ir) -> TokenStream {
return None;
}
if __ev_id != u16::MAX {
let mut __cur = self.current;
let mut __delivered = false;
while let Some(s) = __cur {
if let Some((actions, target)) = __handler_lookup(s, 0, __ev_id) {
let __handler_state = s;
// Same precomputed __DISPATCH lookup as the sync
// branch; see that copy for the encoding notes.
if let Some(__cur) = self.current {
let __dispatch_idx =
(__cur as usize) * #n_events + (__ev_id as usize);
let (__handler_state, __actions, __target) =
__DISPATCH[__dispatch_idx];
if __handler_state != u16::MAX {
#observe_event_delivered
self.run_handlers(
s, actions, target, Some(&ev),
__handler_state, __actions, __target, Some(&ev),
1u8, __ev_id, 0u16,
).await;
__delivered = true;
break;
} else {
#observe_event_dropped
}
__cur = __PARENT[s as usize];
}
if !__delivered {
} else {
#observe_event_dropped
}
} else {
#observe_event_dropped
}
return self.timers.min_remaining();
return #timer_min_remaining_expr;
}
self.timers.min_remaining()
#timer_min_remaining_expr
}
/// Push an event directly into the machine's queue (synchronous,
@@ -2488,130 +2945,48 @@ pub fn generate(ir: &Ir) -> TokenStream {
__dispatch(&mut actx, aid, event).await;
}
// §2.6: classify transitions by the relationship between the
// innermost active state `I` and the target `T`:
// - Up-transition: T is already active (T is a strict ancestor
// of I). Unwind the subtree strictly below T; do NOT exit or
// re-enter T; do NOT descend into defaults. You cannot enter
// a state you never left.
// - Self-transition (T == I): external semantics — exit T and
// re-enter it, then descend into defaults if T has children.
// - Lateral: standard LCA semantics.
fn is_up_transition(current: Option<u16>, target: u16) -> bool {
let Some(mut node) = current else { return false; };
if node == target { return false; }
while let Some(p) = __PARENT[node as usize] {
if p == target { return true; }
node = p;
}
false
}
// §2.6 transition shapes (up / self / lateral) are folded
// into the precomputed path tables emitted at codegen time.
// An empty enter slice (`m == e`) means up-transition: target
// is already on the active path, must not be re-entered, and
// `self.current` is set to `target` after the exit loop. A
// non-empty enter slice ends at the deepest default-descended
// leaf; `enter_state_no_descent` sets `self.current` as it
// goes, so no fixup is needed on that path.
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn transition(&mut self, _source_matched_state: u16, target: u16) {
if Self::is_up_transition(self.current, target) {
if let Some(mut cur) = self.current {
while cur != target {
self.exit_state(cur);
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
}
}
let from = self.current.unwrap_or(target);
let idx = (from as usize) * #n_states + (target as usize);
let (s, m, e) = __PATH_RANGE[idx];
let exit_path = &__PATH_DATA[s as usize..m as usize];
let enter_path = &__PATH_DATA[m as usize..e as usize];
for &sid in exit_path {
self.exit_state(sid);
}
for &sid in enter_path {
self.enter_state_no_descent(sid);
}
if enter_path.is_empty() {
self.current = Some(target);
return;
}
let mut lca = Self::lca(self.current.unwrap_or(target), target);
if self.current == Some(target) {
// Self-transition: bump LCA so target is exited and re-entered.
lca = __PARENT[target as usize];
}
if let Some(mut cur) = self.current {
loop {
if Some(cur) == lca { break; }
self.exit_state(cur);
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
}
}
let mut path: ::hsmc::__private::heapless::Vec<u16, 16> =
::hsmc::__private::heapless::Vec::new();
let mut node = Some(target);
while node != lca && node.is_some() {
let n = node.unwrap();
let _ = path.push(n);
node = __PARENT[n as usize];
}
for i in (0..path.len()).rev() {
let s = path[i];
self.enter_state_no_descent(s);
}
self.descend_defaults(target);
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
if Self::is_up_transition(self.current, target) {
if let Some(mut cur) = self.current {
while cur != target {
self.exit_state(cur).await;
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
}
}
let from = self.current.unwrap_or(target);
let idx = (from as usize) * #n_states + (target as usize);
let (s, m, e) = __PATH_RANGE[idx];
let exit_path = &__PATH_DATA[s as usize..m as usize];
let enter_path = &__PATH_DATA[m as usize..e as usize];
for &sid in exit_path {
self.exit_state(sid).await;
}
for &sid in enter_path {
self.enter_state_no_descent(sid).await;
}
if enter_path.is_empty() {
self.current = Some(target);
return;
}
let mut lca = Self::lca(self.current.unwrap_or(target), target);
if self.current == Some(target) {
lca = __PARENT[target as usize];
}
if let Some(mut cur) = self.current {
loop {
if Some(cur) == lca { break; }
self.exit_state(cur).await;
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
}
}
let mut path: ::hsmc::__private::heapless::Vec<u16, 16> =
::hsmc::__private::heapless::Vec::new();
let mut node = Some(target);
while node != lca && node.is_some() {
let n = node.unwrap();
let _ = path.push(n);
node = __PARENT[n as usize];
}
for i in (0..path.len()).rev() {
let s = path[i];
self.enter_state_no_descent(s).await;
}
self.descend_defaults(target).await;
}
fn lca(a: u16, b: u16) -> Option<u16> {
let mut set: ::hsmc::__private::heapless::Vec<u16, 32> =
::hsmc::__private::heapless::Vec::new();
let mut cur = Some(a);
while let Some(x) = cur {
let _ = set.push(x);
cur = __PARENT[x as usize];
}
let mut cur = Some(b);
while let Some(x) = cur {
if set.contains(&x) { return Some(x); }
cur = __PARENT[x as usize];
}
None
}
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
@@ -2695,7 +3070,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
for &tid in __owned_timers(sid) {
#observe_timer_cancelled
}
self.timers.cancel_state(sid);
#timer_cancel_state_call
let exits = Self::exits_of(sid);
for &aid in exits {
#observe_action_exit
@@ -2713,7 +3088,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
for &tid in __owned_timers(sid) {
#observe_timer_cancelled
}
self.timers.cancel_state(sid);
#timer_cancel_state_call
let exits = Self::exits_of(sid);
for &aid in exits {
#observe_action_exit
@@ -2723,12 +3098,14 @@ pub fn generate(ir: &Ir) -> TokenStream {
#observe_exited
}
#[inline]
fn entries_of(sid: u16) -> &'static [u16] {
match sid {
#(#entries_of_arms)*
_ => &[],
}
}
#[inline]
fn exits_of(sid: u16) -> &'static [u16] {
match sid {
#(#exits_of_arms)*
@@ -2746,35 +3123,31 @@ pub fn generate(ir: &Ir) -> TokenStream {
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn do_terminate(&mut self) {
if let Some(mut cur) = self.current {
loop {
self.exit_state(cur);
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
if let Some(cur) = self.current {
let (s, e) = __TERMINATE_RANGE[cur as usize];
let path = &__TERMINATE_DATA[s as usize..e as usize];
for &sid in path {
self.exit_state(sid);
}
}
self.terminated = true;
self.current = None;
while self.queue.pop_front().is_some() {}
self.queue.clear();
#observe_terminated
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
async fn do_terminate(&mut self) {
if let Some(mut cur) = self.current {
loop {
self.exit_state(cur).await;
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
if let Some(cur) = self.current {
let (s, e) = __TERMINATE_RANGE[cur as usize];
let path = &__TERMINATE_DATA[s as usize..e as usize];
for &sid in path {
self.exit_state(sid).await;
}
}
self.terminated = true;
self.current = None;
while self.queue.pop_front().is_some() {}
self.queue.clear();
#observe_terminated
}
+8 -1
View File
@@ -40,7 +40,7 @@ trace-tracing = ["dep:tracing"]
journal = []
[dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.4.0" }
hsmc-macros = { path = "../hsmc-macros", version = "0.4.1" }
heapless = "0.8"
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
embassy-sync = { version = "0.8", optional = true }
@@ -57,6 +57,9 @@ ratatui = "0.28"
crossterm = { version = "0.28", features = ["event-stream"] }
futures = "0.3"
[target.'cfg(target_os = "linux")'.dev-dependencies]
iai-callgrind = "0.16"
[[example]]
name = "microwave"
required-features = ["tokio"]
@@ -69,3 +72,7 @@ required-features = ["tokio"]
name = "embassy_full"
required-features = ["embassy"]
crate-type = ["lib"]
[[bench]]
name = "dispatch"
harness = false
+176
View File
@@ -0,0 +1,176 @@
# hsmc dispatch baseline — instruction counts from iai-callgrind.
#
# Current numbers reflect the codebase after the terminate-path + u64-ns
# pass. `do_terminate()` indexes into a precomputed `__TERMINATE_DATA`
# table (one slice per starting state, root inclusive); `TimerEntry`'s
# `remaining_ns` field shrunk from u128 to u64 (~584 years of headroom),
# halving the entry size and replacing per-step u128 saturating-sub work
# with u64. The smaller machine struct improves cache density on every
# bench, even those that don't use timers.
#
# Re-run with:
#
# cargo bench --bench dispatch
#
# Named baselines under target/iai/ for diffing past changes:
# pre_lca_fix — original O(H²) LCA contains() scan
# before_path_precompute — post-LCA-fix, pre-precompute
# before_timer_skip — post-precompute, pre-timer-skip (runtime is_empty)
# before_dispatch_precompute — post-timer-skip, pre-dispatch-table
# before_timer_elide — post-dispatch-table, pre-codegen-elide
# before_terminate_and_timer_opts — post-codegen-elide, pre-terminate-path / u64
#
# To diff a future change against the current numbers:
#
# cargo bench --bench dispatch -- --save-baseline=before_<your_change>
# <change>
# cargo bench --bench dispatch -- --baseline=before_<your_change>
#
# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind 3.25.1,
# iai-callgrind 0.16.1, --release.
dispatch::dispatch::h2_cold_start cold:setup_h2_cold()
Instructions: 38
Estimated Cycles: 201
dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm()
Instructions: 84
Estimated Cycles: 397
dispatch::dispatch::h4_cross_tree cross:setup_h4_warm()
Instructions: 101
Estimated Cycles: 456
dispatch::dispatch::h4_up up:setup_h4_warm()
Instructions: 98
Estimated Cycles: 384
dispatch::dispatch::h4_self self_:setup_h4_warm()
Instructions: 101
Estimated Cycles: 456
dispatch::dispatch::h8_cross_tree cross:setup_h8_warm()
Instructions: 93
Estimated Cycles: 443
dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::bubble_mid mid:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::bubble_top top:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed()
Instructions: 191
Estimated Cycles: 706
dispatch::dispatch::terminate_event quit:setup_term_warm()
Instructions: 69
Estimated Cycles: 311
dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm()
Instructions: 94
Estimated Cycles: 444
dispatch::dispatch::chain_self_emit chain:setup_chain_warm()
Instructions: 419
Estimated Cycles: 1301
# ─── LCA O(H) fix delta (pre_lca_fix → before_path_precompute) ──────
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -22.8% -22.1%
# h4_cross_tree -25.9% -10.6%
# h4_self -21.5% -11.7%
# h8_cross_tree -46.2% -41.0% ← deepest LCA stress
# chain_self_emit -15.4% -10.4%
#
# ─── Precompute-paths delta (before_path_precompute → before_timer_skip) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -19.5% -4.8%
# h4_cross_tree -45.0% -28.6%
# h8_cross_tree -48.6% -34.8%
# chain_self_emit -28.1% -14.6%
#
# ─── Timer empty-skip delta (before_timer_skip → before_dispatch_precompute) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -30.8% -35.1%
# h4_cross_tree -38.4% -35.4%
# h8_cross_tree -54.4% -52.9%
# chain_self_emit -27.9% -29.2%
#
# ─── Dispatch-table delta (before_dispatch_precompute → before_timer_elide) ──
#
# Bench Instr Δ Cycle Δ
# bubble_mid -19.4% -4.7% 3-hop __PARENT walk → table lookup
# bubble_top -22.3% -5.8% 4-hop walk → table lookup
# h8_cross_tree +4.2% +15.8% cold-cache miss on __DISPATCH (warm-cache neutral)
#
# ─── Timer-elide delta (before_timer_elide → before_terminate_and_timer_opts) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -26.9% -9.7%
# h4_cross_tree -22.2% -20.1%
# h8_cross_tree -24.2% -20.8%
# bubble_top -22.1% -23.2%
# chain_self_emit -18.0% -8.4%
#
# ─── Terminate-path + u64-ns delta (before_terminate_and_timer_opts → current) ──
#
# Bench Instr Δ Cycle Δ Notes
# h2_cold_start -2.6% -14.1% smaller machine struct, tighter cache
# h2_warm_lateral -3.4% -21.2% (RAM hits 11→8)
# h4_cross_tree -3.8% -1.7%
# h4_up -3.9% -22.3% (RAM hits 10→7)
# h4_self -3.8% -8.4%
# h8_cross_tree -1.1% -0.7%
# bubble_leaf -6.6% -9.6%
# bubble_mid -6.6% -3.0%
# bubble_top -6.6% -9.6%
# timer_fire_dispatch +2.7% -12.6% smaller TimerEntry → -3 RAM hits
# terminate_event -22.5% -23.4% ← headline: precomputed exit path + queue.clear()
# bubble_cross_tree -2.1% -0.9%
# chain_self_emit -0.9% -3.1%
#
# Two changes in this pass:
#
# (1) `__TERMINATE_DATA` / `__TERMINATE_RANGE` — per-state precomputed
# exit chain up to and including __Root. `do_terminate()` indexes the
# table once and iterates the slice, replacing the runtime `__PARENT`
# walk + per-iteration match. Also swapped the queue-drain loop
# (`while self.queue.pop_front().is_some() {}`) for `self.queue.clear()`,
# which heapless 0.8 implements as O(1) (resets indices, no per-element
# pop). Together: -22% instr / -23% cycles on terminate_event, the
# headline win the user asked for.
#
# (2) `TimerEntry.remaining_ns: u128 → u64`. Internal-only field type
# change. u64 nanoseconds bounds at ~584 years — past any realistic
# Duration. Eliminates u128 saturating-sub in `decrement()` (4-6 instr
# → 1-2 instr per entry per step) and the manual secs/nanos
# decomposition in `min_remaining()` (replaced with
# `Duration::from_nanos(u64)`). The struct shrinks 32 B → 16 B with
# alignment, so the entire machine struct is smaller — even no-timer
# benches see RAM-hit reductions from tighter cache packing.
#
# Net across the six optimization passes (pre_lca_fix → current):
# h8_cross_tree dispatch is **-82% instructions / -72% cycles** vs the
# original O(H²) LCA scan. terminate_event is now 69 instr / 311 cycles
# — about 80% of that is the iai-callgrind wrapper, leaving ~14 instr
# of actual HSM termination work for a 3-deep chart.
#
# ─── Bench coverage (3 benches added in the previous pass) ──────────
#
# `timer_fire_dispatch` (191 instr / 706 cycles) — only bench that
# exercises pop_expired's non-empty branch and `dispatch_trigger`.
# `terminate_event` (69 instr / 311 cycles) — only bench that
# exercises `__check_terminate`'s true arm and `do_terminate`.
# `bubble_cross_tree` (94 instr / 444 cycles) — handler on L1 (4
# bubble hops) firing a 10-state cross-tree transition. Realistic
# HSM worst case.
+633
View File
@@ -0,0 +1,633 @@
//! 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);
+62 -9
View File
@@ -812,7 +812,12 @@ pub mod __private {
pub struct TimerEntry {
pub state: u16,
pub trigger: u16,
pub remaining_ns: u128,
// u64 nanoseconds bounds at ~584 years of countdown — comfortably
// beyond any practical Duration. Storing the original u128 from
// `Duration::as_nanos()` would force u128 saturating_sub on every
// `decrement()` call; on 64-bit it's 4-6 instructions per entry vs
// 1-2 for u64. The ceiling is enforced by saturating-cast at insert.
pub remaining_ns: u64,
}
impl<const N: usize> Default for TimerTable<N> {
@@ -828,7 +833,12 @@ pub mod __private {
}
}
pub fn start(&mut self, state: u16, trigger: u16, duration: core::time::Duration) {
let ns = duration.as_nanos();
// Truncating u128→u64. u64 nanoseconds bounds at ~584 years —
// past that the cast wraps, but no realistic timer duration
// gets near it (Duration::from_secs(u64::MAX) overflows long
// before, and from_millis/from_nanos take u64 directly so
// round-trip exactly).
let ns = duration.as_nanos() as u64;
// Replace existing entry for same (state, trigger) — resets on re-entry.
for e in self.entries.iter_mut() {
if e.state == state && e.trigger == trigger {
@@ -842,22 +852,51 @@ pub mod __private {
remaining_ns: ns,
});
}
/// Cancel every timer owned by `state`. Inlined empty-check so an
/// `exit_state` on a chart with no live timers compiles to a single
/// branch — the `retain` call (and its closure) stays out-of-line.
#[inline]
pub fn cancel_state(&mut self, state: u16) {
self.entries.retain(|e| e.state != state);
if !self.entries.is_empty() {
self.entries.retain(|e| e.state != state);
}
}
pub fn cancel_one(&mut self, state: u16, trigger: u16) {
self.entries
.retain(|e| !(e.state == state && e.trigger == trigger));
}
/// Subtract `elapsed` from every live timer (saturating at 0). Inlined
/// empty-check so the per-`step()` call costs one branch when no
/// timers are armed.
#[inline]
pub fn decrement(&mut self, elapsed: core::time::Duration) {
let ns = elapsed.as_nanos();
if self.entries.is_empty() {
return;
}
let ns = elapsed.as_nanos() as u64;
for e in self.entries.iter_mut() {
e.remaining_ns = e.remaining_ns.saturating_sub(ns);
}
}
/// Returns (state, trigger) of first expired timer, picking the innermost
/// (deepest) state, ties broken by declaration order.
///
/// The empty-table case is split out and `#[inline]`d so charts with no
/// live timers pay only a one-instruction `is_empty` branch per
/// `step()`, not the full function-call + iterator-setup overhead. The
/// non-empty body stays out-of-line to keep the hot path's icache
/// footprint small.
#[inline]
pub fn pop_expired(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
if self.entries.is_empty() {
None
} else {
self.pop_expired_nonempty(depth)
}
}
#[cold]
fn pop_expired_nonempty(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
let mut best: Option<usize> = None;
for (i, e) in self.entries.iter().enumerate() {
if e.remaining_ns == 0 {
@@ -880,12 +919,26 @@ pub mod __private {
None
}
}
/// Smallest `remaining_ns` across live timers, or `None` if there are
/// none. Same inline-the-empty-case treatment as `pop_expired` so
/// timer-free charts pay only a branch per `step()`.
#[inline]
pub fn min_remaining(&self) -> Option<core::time::Duration> {
self.entries.iter().map(|e| e.remaining_ns).min().map(|ns| {
let secs = (ns / 1_000_000_000) as u64;
let rem = (ns % 1_000_000_000) as u32;
core::time::Duration::new(secs, rem)
})
if self.entries.is_empty() {
None
} else {
self.min_remaining_nonempty()
}
}
#[cold]
fn min_remaining_nonempty(&self) -> Option<core::time::Duration> {
self.entries
.iter()
.map(|e| e.remaining_ns)
.min()
.map(core::time::Duration::from_nanos)
}
}