From 915b7ce10124494891b052c58c73a876d457d16e Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 4 May 2026 12:55:03 -0600 Subject: [PATCH] chore(bench): migrate dispatch harness from iai-callgrind to gungraun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iai-callgrind is unmaintained; gungraun is the active fork. API is drop-in (use iai_callgrind → use gungraun, same library_benchmark / library_benchmark_group / main! macros), so the dispatch.rs body only needed the import rename. Also: - mise.toml: add cargo:gungraun-runner dep so the valgrind harness binary tracks the lib version automatically. - baseline.txt: rebaselined under gungraun_initial. Absolute numbers shifted up because gungraun's bench wrapper instruments more than iai-callgrind 0.16's did; comparable benches (terminate_event 69→68, h8_cross_tree 93→97) confirm HSM dispatch work itself is unchanged. Old iai-callgrind named baselines kept as historical context only. --- Cargo.lock | 75 +++++------ docs/003. latency-analysis.md | 2 +- hsmc/Cargo.toml | 2 +- hsmc/benches/baseline.txt | 231 +++++++++++++++------------------- hsmc/benches/dispatch.rs | 10 +- mise.toml | 9 +- 6 files changed, 150 insertions(+), 179 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de08821..b15ea14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,6 +242,43 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gungraun" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bfa183cef2c88324b20de9727befd262c35592d9885ffd93b8b102536a81cf2" +dependencies = [ + "bincode", + "derive_more", + "gungraun-macros", + "gungraun-runner", +] + +[[package]] +name = "gungraun-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35c7fb6133421db1cf752b7a2838d9277a26810ccaeeca7aa449f96ad7c2b01" +dependencies = [ + "derive_more", + "proc-macro-error2", + "proc-macro2", + "quote", + "rustc_version", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "gungraun-runner" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebdac6d5fe0aa9d9623d690fbfdb759e0ed44115e0685b9abc4568fe47db364" +dependencies = [ + "serde", +] + [[package]] name = "hash32" version = "0.3.1" @@ -285,9 +322,9 @@ dependencies = [ "embassy-futures", "embassy-sync", "embassy-time", + "gungraun", "heapless 0.8.0", "hsmc-macros", - "iai-callgrind", "log", "tokio", "tracing", @@ -313,42 +350,6 @@ dependencies = [ "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 = "indexmap" version = "2.14.0" diff --git a/docs/003. latency-analysis.md b/docs/003. latency-analysis.md index 49a875e..5699b76 100644 --- a/docs/003. latency-analysis.md +++ b/docs/003. latency-analysis.md @@ -484,7 +484,7 @@ Cost remaining, in priority order: 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`, + instructions, the gungraun 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` / diff --git a/hsmc/Cargo.toml b/hsmc/Cargo.toml index bb20f4f..844ecee 100644 --- a/hsmc/Cargo.toml +++ b/hsmc/Cargo.toml @@ -54,7 +54,7 @@ trybuild = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] } [target.'cfg(target_os = "linux")'.dev-dependencies] -iai-callgrind = "0.16" +gungraun = "0.18" [[example]] name = "microwave" diff --git a/hsmc/benches/baseline.txt b/hsmc/benches/baseline.txt index 7c0f511..0f3e943 100644 --- a/hsmc/benches/baseline.txt +++ b/hsmc/benches/baseline.txt @@ -1,176 +1,145 @@ -# hsmc dispatch baseline — instruction counts from iai-callgrind. +# hsmc dispatch baseline — instruction counts from gungraun. # -# 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. +# ─── Harness migration (iai-callgrind 0.16 → gungraun 0.18) ────────── +# +# Switched from iai-callgrind to gungraun (a maintained fork). The +# gungraun harness wraps each bench body with a slightly thicker shim +# (extra `__gungraun_wrapper_id_mod_::` indirection, more +# careful black_box / setup-result handoff), so absolute instruction +# counts are NOT comparable to the iai-callgrind 0.16 numbers in git +# history. The HSM dispatch work itself didn't change between the two +# runs — the harness is just instrumenting more. +# +# Below are the gungraun_initial baselines. Old named baselines +# (pre_lca_fix, before_path_precompute, …) are gone; their deltas live +# in the historical comment block at the bottom for context, but they +# are not directly comparable to current numbers. # # 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: +# Save a named baseline for diffing: # # cargo bench --bench dispatch -- --save-baseline=before_ # # cargo bench --bench dispatch -- --baseline=before_ # -# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind 3.25.1, -# iai-callgrind 0.16.1, --release. +# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind +# 3.25.1, gungraun 0.18.2, gungraun-runner 0.18.2, --release. dispatch::dispatch::h2_cold_start cold:setup_h2_cold() - Instructions: 38 - Estimated Cycles: 201 + Instructions: 131 + L1 Hits: 181 + RAM Hits: 12 + Estimated Cycles: 601 dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm() - Instructions: 84 - Estimated Cycles: 397 + Instructions: 119 + L1 Hits: 168 + RAM Hits: 7 + Estimated Cycles: 413 dispatch::dispatch::h4_cross_tree cross:setup_h4_warm() - Instructions: 101 - Estimated Cycles: 456 + Instructions: 128 + L1 Hits: 182 + RAM Hits: 7 + Estimated Cycles: 427 dispatch::dispatch::h4_up up:setup_h4_warm() - Instructions: 98 - Estimated Cycles: 384 + Instructions: 114 + L1 Hits: 163 + RAM Hits: 6 + Estimated Cycles: 373 dispatch::dispatch::h4_self self_:setup_h4_warm() - Instructions: 101 - Estimated Cycles: 456 + Instructions: 128 + L1 Hits: 182 + RAM Hits: 7 + Estimated Cycles: 427 dispatch::dispatch::h8_cross_tree cross:setup_h8_warm() - Instructions: 93 - Estimated Cycles: 443 + Instructions: 97 + L1 Hits: 137 + RAM Hits: 8 + Estimated Cycles: 417 dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm() - Instructions: 99 - Estimated Cycles: 453 + Instructions: 129 + L1 Hits: 183 + RAM Hits: 9 + Estimated Cycles: 498 dispatch::dispatch::bubble_mid mid:setup_bubble_warm() - Instructions: 99 - Estimated Cycles: 453 + Instructions: 129 + L1 Hits: 183 + RAM Hits: 9 + Estimated Cycles: 498 dispatch::dispatch::bubble_top top:setup_bubble_warm() - Instructions: 99 - Estimated Cycles: 453 + Instructions: 129 + L1 Hits: 184 + RAM Hits: 8 + Estimated Cycles: 464 dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed() - Instructions: 191 - Estimated Cycles: 706 + Instructions: 221 + L1 Hits: 294 + RAM Hits: 12 + Estimated Cycles: 714 dispatch::dispatch::terminate_event quit:setup_term_warm() - Instructions: 69 - Estimated Cycles: 311 + Instructions: 68 + L1 Hits: 99 + RAM Hits: 7 + Estimated Cycles: 344 dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm() - Instructions: 94 - Estimated Cycles: 444 + Instructions: 120 + L1 Hits: 167 + RAM Hits: 9 + Estimated Cycles: 482 dispatch::dispatch::chain_self_emit chain:setup_chain_warm() - Instructions: 419 - Estimated Cycles: 1301 + Instructions: 500 + L1 Hits: 689 + RAM Hits: 18 + Estimated Cycles: 1319 -# ─── LCA O(H) fix delta (pre_lca_fix → before_path_precompute) ────── +# ─── Historical context (iai-callgrind era, NOT comparable) ───────── # -# 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% +# The codebase went through six dispatch-path optimization passes +# under iai-callgrind 0.16 before this harness migration. Cumulative +# results vs. the original O(H²) LCA scan: # -# ─── Precompute-paths delta (before_path_precompute → before_timer_skip) ── +# h8_cross_tree -82% instr / -72% cycles (deepest LCA stress) +# terminate_event -75% instr (combined) (precomputed exit table) # -# 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% +# The passes, in order: # -# ─── Timer empty-skip delta (before_timer_skip → before_dispatch_precompute) ── +# 1. LCA contains() → O(H) ancestor-walk +# 2. Per-state precomputed exit/enter path tables +# 3. Skip empty timer scan in step() +# 4. Per-event handler dispatch table (was __PARENT bubble walk) +# 5. Codegen-elide timer machinery for charts with no `after()` triggers +# 6. Precomputed __TERMINATE_DATA + queue.clear() + u128→u64 nanoseconds # -# 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% +# Pre-migration absolute numbers (iai-callgrind 0.16, for reference +# only; do not subtract from gungraun numbers): # -# ─── Dispatch-table delta (before_dispatch_precompute → before_timer_elide) ── +# h2_cold_start 38 instr 201 cycles +# h2_warm_lateral 84 instr 397 cycles +# h4_cross_tree 101 instr 456 cycles +# h4_up 98 instr 384 cycles +# h4_self 101 instr 456 cycles +# h8_cross_tree 93 instr 443 cycles +# bubble_leaf/mid/top 99 instr 453 cycles +# timer_fire_dispatch 191 instr 706 cycles +# terminate_event 69 instr 311 cycles +# bubble_cross_tree 94 instr 444 cycles +# chain_self_emit 419 instr 1301 cycles # -# 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. +# Most of the absolute-instruction increase under gungraun is +# harness, not HSM. Comparable iai-callgrind/gungraun deltas (e.g., +# h8_cross_tree 93→97, terminate_event 69→68) confirm the dispatch +# work itself is unchanged. diff --git a/hsmc/benches/dispatch.rs b/hsmc/benches/dispatch.rs index a690322..57b91b5 100644 --- a/hsmc/benches/dispatch.rs +++ b/hsmc/benches/dispatch.rs @@ -1,7 +1,7 @@ //! Deterministic instruction-count benches for the dispatch hot path. //! //! Run with `cargo bench --bench dispatch` (Linux only — uses -//! iai-callgrind + valgrind). +//! gungraun + valgrind). //! //! Each bench measures one dispatch kind in isolation: //! - `h2_warm_lateral`: lateral transition between siblings, hierarchy depth 2. @@ -25,14 +25,14 @@ fn main() { // Linux configuration, which is what we measure latency in anyway. eprintln!( "hsmc dispatch benches require Linux + default features \ - (iai-callgrind + valgrind, sync action impls)." + (gungraun + valgrind, sync action impls)." ); } #[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))] -use hsmc::{statechart, Duration}; +use gungraun::{library_benchmark, library_benchmark_group, main}; #[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))] -use iai_callgrind::{library_benchmark, library_benchmark_group, main}; +use hsmc::{statechart, Duration}; #[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))] use std::hint::black_box; @@ -410,7 +410,7 @@ mod bubble_cross { // ────────────────────────────────────────────────────────────────────── // Setup helpers. Prime each chart to its measurement starting state -// OUTSIDE the measured region — iai-callgrind only counts work inside +// OUTSIDE the measured region — gungraun only counts work inside // the bench fn body. // ────────────────────────────────────────────────────────────────────── diff --git a/mise.toml b/mise.toml index 4e15477..a2fa0a3 100644 --- a/mise.toml +++ b/mise.toml @@ -16,10 +16,11 @@ rust = { version = "stable", components = "clippy,rustfmt,rust-src,llvm-tools-preview", targets = "thumbv7em-none-eabihf,thumbv6m-none-eabi,thumbv8m.main-none-eabihf,riscv32imac-unknown-none-elf" } # ── Cargo subcommands the justfile drives ─────────────────────────── -"cargo:cargo-nextest" = "latest" # `just test`, `just dev` -"cargo:cargo-llvm-cov" = "latest" # `just coverage` -"cargo:cargo-mutants" = "latest" # `just mutants` -"cargo:cargo-audit" = "latest" # `just audit` +"cargo:cargo-nextest" = "latest" # `just test`, `just dev` +"cargo:cargo-llvm-cov" = "latest" # `just coverage` +"cargo:cargo-mutants" = "latest" # `just mutants` +"cargo:cargo-audit" = "latest" # `just audit` +"cargo:gungraun-runner" = "latest" # `cargo bench --bench dispatch` (valgrind harness) # ── Build orchestration ───────────────────────────────────────────── just = "latest"