Bus / scheduler per-instruction pass — 2026-09-17
Working notes for perf/bus-scheduler-pass. Goal: cut host instructions per
simulated instruction with zero behaviour change, on the loop the browser
runs (Machine::advance → Cpu::step_batch, tick interval 512).
How everything here was measured
- Ir/step — host instructions retired per simulated CPU step, the metric
scripts/perf/board_perf.pygates on. Two callgrind runs per board-mode at 200 000 and 1 200 000 steps; the per-step cost is the slope between them, so ELF load, YAML parse and simulator construction cancel out. Deterministic to well under 1 % for a fixed binary, and it transfers to the wasm build. - CLI —
cargo build --release -p labwired-cli --features event-scheduler(the browser feature set), rustc 1.95.0, measured throughscripts/perf/board_perf.py --cli <binary> --boards nrf52840,stm32f405,esp32c3. - Profiles —
valgrind --tool=callgrind --cache-sim=no --branch-sim=noover a 1 200 000-step--batchedrun,callgrind_annotatefor self cost. Symbol rows below are self Ir; a Rust generic inlined fromcoreis attributed to its defining file with the host function named after the colon, socore/src/ptr/non_null.rs:…highest_priority_pendingis that function's inlined iterator, not a separate cost.
Numbers below are from one machine, one binary per row; only before/after pairs measured the same way are compared.
Baseline (commit 122ce319c + this worktree, before any change)
| board | mode | Ir/step | steps/batch |
|---|---|---|---|
| nrf52840 | batch | 229.8 | 511.9 |
| nrf52840 | step | 1419.8 | — |
| stm32f405 | batch | 229.0 | 511.9 |
| stm32f405 | step | 1030.8 | — |
| esp32c3 | batch | 402.0 | 511.9 |
Profile — nrf52840, perf-spin-nrf, --batched, 1.2 M steps
Total 566,865,481 Ir. Per-step cost by the slope is 229.8 × 1.2 M ≈ 276 M Ir;
the rest is one-off YAML/ELF setup (unsafe_libyaml, malloc) which the slope
removes. Top self-cost rows:
| Ir | % total | symbol |
|---|---|---|
| 125,313,716 | 22.11 % | cortex_m.rs:CortexM::step_batch |
| 45,600,000 | 8.04 % | cortex_m.rs:CortexM::highest_priority_pending |
| 44,836,211 | 7.91 % | unsafe_libyaml::reader::yaml_parser_update_buffer (setup) |
| 26,400,000 | 4.66 % | core/ptr/non_null.rs:CortexM::highest_priority_pending (its inlined iterator) |
| 20,480,487 | 3.61 % | unsafe_libyaml::scanner::yaml_parser_fetch_plain_scalar (setup) |
| 20,418,697 | 3.60 % | libc:_int_free (setup) |
| 19,183,592 | 3.38 % | core/slice/iter/macros.rs:CortexM::step_batch (the pending_exceptions scans) |
| 16,988,042 | 3.00 % | unsafe_libyaml::scanner::yaml_parser_fetch_more_tokens (setup) |
| 14,508,371 | 2.56 % | libc:malloc (setup) |
| 14,051,935 | 2.48 % | unsafe_libyaml macros (setup) |
| 14,042,295 | 2.48 % | libc:_int_malloc (setup) |
| 9,107,970 | 1.61 % | libc:free (setup) |
| 9,000,000 | 1.59 % | bus/accessors.rs:SystemBus::write_u32 |
| 8,602,898 | 1.52 % | libc:__memcpy_avx_unaligned_erms (setup) |
| 8,414,064 | 1.48 % | core/option.rs:CortexM::step_batch |
Finding 1. highest_priority_pending costs 45.6 M + 26.4 M = 72.0 M Ir
for 1.2 M instructions — 60 Ir on every single instruction, i.e. ~26 % of the
whole per-step cost. CortexM::step_execute calls it unconditionally at the top
of every instruction (highest_priority_pending().unwrap_or(0)) and only then
asks whether anything is pending at all. With no exception pending — the
overwhelmingly common case — the call walks a [u64; 4] through
iter().enumerate() and returns None. The same shape appears on the real
Zephyr firmware (nrf52840-zephyr-l0-hello.elf): 45.6 M + 26.4 M + 4.8 M Ir.
Finding 2. The self.pending_exceptions.iter().any(|&w| w != 0) scans in
step_batch cost 19.2 M Ir (16 Ir/instruction) as a slice iterator rather than
a four-way OR.
Profile — esp32c3, perf-spin-esp32c3, --batched, 1.2 M steps
Total 851,024,508 Ir; per-step cost ≈ 402 × 1.2 M ≈ 482 M Ir.
| Ir | % total | symbol |
|---|---|---|
| 138,401,533 | 16.26 % | riscv.rs:RiscV::step |
| 58,670,593 | 6.89 % | unsafe_libyaml::reader::yaml_parser_update_buffer (setup) |
| 39,600,066 | 4.65 % | core/num/uint_macros.rs:SystemBus::read_u32 (inlined Memory::read_u32 bounds maths) |
| 36,000,061 | 4.23 % | bus/accessors.rs:SystemBus::read_u32 |
| 28,919,544 | 3.40 % | riscv.rs:RiscV::step_batch |
| 27,599,933 | 3.24 % | core/num/uint_macros.rs:RiscV::step |
| 24,869,798 | 2.92 % | libc:_int_free (setup) |
| 24,263,173 | 2.85 % | unsafe_libyaml::scanner::yaml_parser_fetch_plain_scalar (setup) |
| 24,000,023 | 2.82 % | bus/routing.rs:SystemBus::find_peripheral_index |
| 21,600,036 | 2.54 % | core/intrinsics/mod.rs:SystemBus::read_u32 |
| 21,408,568 | 2.52 % | unsafe_libyaml::scanner::yaml_parser_fetch_more_tokens (setup) |
| 19,200,000 | 2.26 % | core/ptr/non_null.rs:RiscV::step |
| 17,653,376 | 2.07 % | libc:malloc (setup) |
| 16,844,967 | 1.98 % | libc:_int_malloc (setup) |
| 15,995,254 | 1.88 % | unsafe_libyaml macros (setup) |
Finding 3. RiscV::fetch_opcode_u32's local code window never hits on the
esp32c3: the callgraph shows bus.read_u32(pc) taken 1,200,000 times — once
per instruction — for 130.8 M Ir inclusive, 109 Ir per instruction, 27 % of
the per-step cost. refill_fetch_window only fills from a FlashXipPeripheral
or from extra_mem; a plain flash region (what configs/chips/esp32c3.yaml
declares at 0x4200_0000) matches neither, so the refill fails, fetch_len stays
0, and the refill is re-attempted on the next instruction as well — including
its find_peripheral_index call, which is where the 24.0 M Ir
(20 Ir/instruction) in routing comes from.
Finding 4. Inside that read_u32, the address is served by flash, but
only after extra_mem_word has probed all five memory_regions the C3
declares (iram, drom, rtc_fast, rom, rom_data), none of which is anywhere near
0x4200_0000. That linear probe is the 39.6 M + 21.6 M + 13.2 M + 10.8 M Ir of
inlined bounds maths and slice iteration attributed to read_u32.
Differential oracle used for "zero behaviour change"
--gpio-trace writes nothing on these boards (no --system board_io wiring), so
the byte-identical gate uses three stronger artefacts per firmware, over seven
runs (nrf52840 Zephyr step + batch, nrf52840 tier1, stm32f405 tier1 step + batch,
esp32c3 tier1, esp32c3 demo), all at --max-steps 5000000:
- stdout — firmware console / TIER1 protocol lines;
--bus-trace-outJSON — every UART/I²C/SPI event with its exactcyclestamp, so a change in when anything happens shows up;- the
[batched]summary —instructions,batches,steps_per_batch,tick_interval,peripheral_ticks, which pins the scheduling path itself.
21 sha256 hashes in total; all 21 must be unchanged.
Result
Three commits, each measured on its own binary, all byte-identical to the pre-series baseline on every one of the 21 hashes.
| board | mode | baseline | after (1) cortex-m | after (2) extra_mem | after (3) riscv fetch | total |
|---|---|---|---|---|---|---|
| nrf52840 | batch | 229.8 | 158.4 | 158.0 | 158.6 | −31.0 % |
| nrf52840 | step | 1419.8 | 1353.0 | 1354.0 | 1354.0 | −4.6 % |
| stm32f405 | batch | 229.0 | 157.5 | 157.5 | 157.5 | −31.2 % |
| stm32f405 | step | 1030.8 | 962.0 | 963.0 | 963.0 | −6.6 % |
| esp32c3 | batch | 402.0 | 402.0 | 358.0 | 262.0 | −34.8 % |
batch is the loop the browser runs (Sim::step_batch → Machine::advance),
so that column is the one that transfers to the wasm build. Its run-to-run
noise floor is ~±0.5 %, which is why the ±0.3–0.4 % moves in the columns where
a change does not apply are read as noise and not as findings.
step moves much less than batch because it is dominated by the per-instruction
machine boundary (commit_advance_boundary + a peripheral tick at interval 1),
which none of these three changes touches.
The step rows also sit above scripts/perf/baselines.json (nrf52840 1373.8,
stm32f405 949.8): that gap is present on the unmodified worktree too — it is
compiler/host drift against numbers recorded on CI, not something this branch
introduced, and it is why every comparison here is against the locally measured
baseline rather than against the checked-in file.
What is left on the table
CortexM::step_batchis still 22 % of the spin-fixture profile and is now, by a wide margin, the largest single item on the ARM path. That is the interpreter itself (decode-cache lookup, theInstructionmatch, register file), not orchestration.- On the C3,
RiscV::stepis 16 % and the remainingread_u32per fetch is still a full bus walk — the fetch window cannot serve a plainflashregion at all. Teachingrefill_fetch_windowaboutSystemBus::flashwould remove that walk outright, but it would also stop the fetch incrementingmemory_reads, whichbus.access_counts()reports; that is a visible change and therefore out of scope for a pass whose contract is zero behaviour change. - Per-tick (so amortised 512×, not a lever here):
collect_enabled_nvic_interruptsdoes 16SeqCstatomic loads per tick.
Re-measured on current main after merging it in
The branch was merged with origin/main at 80c01390d and everything above
re-run against a binary built from that exact commit, so the PR's numbers are
against the main it will merge into rather than against its branch point:
| board | mode | main 80c01390d | branch 36bc188e6 | delta |
|---|---|---|---|---|
| nrf52840 | batch | 230.0 | 158.6 | −31.0 % |
| nrf52840 | step | 1365.1 | 1295.4 | −5.1 % |
| stm32f405 | batch | 229.2 | 157.7 | −31.2 % |
| stm32f405 | step | 973.1 | 903.4 | −7.2 % |
| esp32c3 | batch | 402.2 | 262.2 | −34.8 % |
The differential harness was re-captured on 80c01390d as well (the
[batched] summary line changed in #1145, so the older hashes no longer
apply) and all 21 hashes match between that binary and the branch.