Skip to content

perf: walk-free STM32 timers — lazy CNT + scheduled update/compare events#520

Merged
w1ne merged 4 commits into
mainfrom
perf/walk-free-stm32-timers
Jul 12, 2026
Merged

perf: walk-free STM32 timers — lazy CNT + scheduled update/compare events#520
w1ne merged 4 commits into
mainfrom
perf/walk-free-stm32-timers

Conversation

@w1ne

@w1ne w1ne commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Walk-free plan batches B2/B3: migrate the shared STM32 TIMx model (all 11 instances on the L476 invaders bus) off the per-cycle legacy walk, following the B1 SysTick exemplar.

Design

Lazy CNT/SR (closed-form walk replay). cnt/sr/psc_cnt move into Cells; advance_to(now) replays the walk exactly in O(#channels) from the bus-published CycleClock: prescaler phase (k1 = PSC − psc_cnt + 1, then every PSC+1), counter trajectory, UIF + CC1..4IF (+ CC5/6IF on advanced) latching, and — critically — the enabled-flag counter freeze: the legacy tick() returns the held IRQ level before counting, so the counter stops on the tick an enabled flag latches and stays frozen until firmware clears SR/DIER. The replay freezes at exactly the same tick. &self reads sync first, so polled CNT/SR observe fresh time with no walk.

Scheduled update/compare events. Every MMIO write (bus sync+collect chokes) cancels the in-flight chain (arm_seq token) and re-arms at the exact first-pend tick computed in closed form: an overflow with UIE pends on the overflow tick; a compare match latches on the match tick and first pends one tick later (the walk's level check); a held level pends every tick via a delay-1 reschedule chain that dies when SR/DIER clears — reproducing the walk's per-tick level re-pend (including the faithful NVIC double-entry artifact when firmware clears the flag late in the ISR). Deadlines use the current_cycle + 1 + delay write identity / now + delay reschedule identity from #511.

NVIC routing: reused, with one timing fix. Events pend via the existing EventResult::raise_own_irqpend_irq_for_event choke (no new mechanism). One real skew fixed in that choke: the legacy walk pends ISPR and scans ISPR&ISER into the CPU in the same tick_peripherals call, while the event drain runs after that scan — an event-raised IRQ left only in ISPR became CPU-visible one tick late. pend_irq_for_event now also pushes ISER-enabled pends as their exception number into the caller's set_exception_pending fan-out (ISPR-only when not enabled, picked up by the per-tick scan exactly like the walk). This aligns uart/spi event IRQs too; full suite is green.

Tick-cost normalization (same as B1). The legacy tick charged cycles: 1 on overflow ticks and on every held-level tick — a sim artifact incompatible with walk deletion. Both modes now charge zero.

Preserved limitations (deliberately, bit-for-bit): PSC applies immediately (no buffered reload — silicon buffers until the update event; the walk did not, and the phase across a mid-count PSC rewrite, incl. psc_cnt > PSC, is reproduced); no ARPE preload; always up-counts (CR1 DIR/CMS/OPM inert); 32-bit ARR == 0xFFFF_FFFF never wraps (the walk's cnt > arr u32 check can never fire — free-run mod 2^32, no UIF); input-capture channels (CCxS≠0) never latch/fire; EGR/SR write quirks (persistent EGR readback, SR write clearing bit 17 unconditionally) untouched.

No config kept on the walk: SMCR (external clock/encoder) is an inert register bit the walk equally ignores, so scheduler mode is exactly as expressive as the walk for everything this model implements. needs_legacy_walk() is false only in scheduler mode; without a clock (featureless / hand-built buses) the model stays on the legacy walk with exact historical semantics (force_legacy_walk() test knob, as B1).

Fidelity evidence

  • Model-level per-cycle identity (unit, timer.rs::scheduler_mode): a harness drives the scheduler path exactly like Machine at interval 1 (publish/drain/write-choke identities) against the legacy tick() oracle, comparing full state every cycle and the exact IRQ-pend cycle set across a PSC×ARR grid, compare matches (incl. unreachable CCR > ARR), DIER=0 polling, mid-run PSC/ARR/CNT/CCR rewrites, EGR.UG, PSC-phase rewrites, basic timers, advanced CC5/6, 32-bit ARR=MAX free-run, input-capture gating.
  • Machine-level differentials (tests/stm32_timer_walk_differential.rs, B1 style — hand-built Cortex-M + real NVIC + hand-assembled Thumb): update-IRQ, compare-IRQ, mid-run reconfig, CNT/SR-poll firmwares probed after every instruction at interval 1 — total_cycles, PC, all 16 registers, RAM counters byte-identical walk vs scheduler. At interval 64: update-event count exact over a fixed window (UIF-poll shape), total_cycles equal.
  • Invaders lab: splash_framebuffer_matches_across_tick_intervals and derived_and_explicit_walk_flag_are_output_identical (20M cycles, walk-on vs walk-deleted) pass on the branch.
  • Silicon-anchored oracle: h563 TIM1 PWM settle case passes; the harness now advances the bus cycle clock alongside its walk loop (one cycle per tick — the Machine interval-1 contract), keeping it honest for both drive modes.
  • Full cargo test -p labwired-core --features jit,event-scheduler: green except the known machine-local intmatrix_alarm flake. Featureless lanes: green (3 known WFI-FF feature-unification failures in a bare -p labwired-core --lib run only).

Honest quantization notes (interval > 1)

  • Lazy CNT/SR reads and IRQ pends quantise to the batch grid — ≤ one interval, the same documented sync_to bound (and strictly better than the pre-migration walk at interval 64, which slowed the whole counter 64×).
  • The level re-pend chain is sampled on the grid, so mid-interval re-pends that vanish before the boundary (flag cleared inside the ISR) collapse at interval 64 — the double-entry artifact is an interval-1 behaviour, pinned byte-exact there. The interval-64 count gate therefore uses the latched-flag (UIF-poll) shape, which cannot miss events.

Numbers (invaders lab firmware, release, native, 40M cycles)

config before (47bc943) after
hand walk_deleted: true, interval 64 14.34 MIPS 14.09 MIPS (unchanged, noise)
hand walk_deleted: true, interval 1 7.18 MIPS 7.26 MIPS
walk-stripped (derived), interval 1 2.08 MIPS 2.47 MIPS (+19%)

The remaining pinned walkers (dma1/2, i2c1-3, adc1, exti, can1, dwt) still hold derive_walk_deletable() == false, so the derived bus stays clamped at interval 1 — the batching unlock lands only when the remaining Class-B batches (B4+) migrate. The pinned-surface test now expects exactly that 9-walker set.

Validation drift

timer.rs is tracked by 5 silicon-tier boards (stm32h563, nucleo-l476rg, nucleo-l073rz, stm32f103, stm32f407): register layout/reset/MMIO byte paths are unchanged, dated drift_ack: 2026-07-12 added per entry, VALIDATION_STATUS.md regenerated (--check --drift green).

w1ne and others added 4 commits July 12, 2026 03:06
…ents

Migrate the shared STM32 TIMx model (11 instances on the L476 invaders bus)
off the per-cycle legacy walk onto the event scheduler (walk-free plan
batches B2/B3), following the B1 SysTick exemplar:

- Lazy CNT/SR: closed-form replay of the walk (prescaler phase, counter
  trajectory, UIF/CCxIF latching, and the enabled-flag counter freeze)
  from the bus-published CycleClock, advanced from &self reads via Cell
  state. Preserves the model's exact semantics including its pinned
  limitations: immediate PSC apply (no buffered reload), no ARPE, always
  up-count, 32-bit ARR=0xFFFFFFFF never wraps.
- Update/compare IRQs ride scheduled events: arming writes compute the
  exact first-pend tick in closed form (update pends on the overflow tick,
  compare pends one tick after the latch, held level pends every tick via
  a delay-1 chain); every MMIO write cancels + re-arms the chain (arm_seq
  token). EventResult::raise_own_irq pends the peripheral's configured
  NVIC line.
- Same-tick CPU visibility for event-raised NVIC IRQs: pend_irq_for_event
  now also pushes ISER-enabled pends into the CPU pending set, matching
  the legacy walk's same-call ISPR scan (event IRQs were one tick late).
- Tick-cost normalization: the legacy tick's cycles:1 charges (overflow +
  held-level ticks) are removed in both modes, as B1 did for SysTick.
- needs_legacy_walk() is false in scheduler mode: everything the model's
  tick() can do is event-expressible (SMCR external-clock/encoder modes
  are inert register bits the walk equally ignores).
- Unpin the 11 timers from the STM32 pinned walk-forcing surface.

Fidelity gates: per-cycle walk-identity property tests over a PSC/ARR/CCR
grid incl. mid-run reconfiguration (unit), plus machine-level differential
gates (hand-assembled Thumb firmware, real NVIC): update-IRQ, compare-IRQ,
mid-run reconfig, CNT/SR polling — per-instruction byte-identity at tick
interval 1 — and exact update-event counts at interval 64.
… + clippy

The h563 mmio-diff harness drives peripheral time by calling
tick_peripherals_fully() directly without advancing current_cycle. The
walk-free timers derive their state lazily from the published cycle clock,
so the settle loop now advances the clock one cycle per tick — exactly the
Machine run-loop contract at tick interval 1 — keeping the TIM1 PWM
silicon-anchored case honest for both drive modes.
STM32 TIMx walk-free migration touches the shared timer.rs tracked by the
stm32h563 / nucleo-l476rg / nucleo-l073rz / stm32f103 / stm32f407 entries:
register layout, reset values and MMIO byte paths are unchanged (mmio
diffs/conformance unaffected), so a dated drift_ack covers it; no live
re-capture this session.
@w1ne w1ne merged commit c1e5d72 into main Jul 12, 2026
2 checks passed
@w1ne w1ne deleted the perf/walk-free-stm32-timers branch July 12, 2026 01:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant