perf: walk-free STM32 timers — lazy CNT + scheduled update/compare events#520
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_cntmove intoCells;advance_to(now)replays the walk exactly in O(#channels) from the bus-publishedCycleClock: prescaler phase (k1 = PSC − psc_cnt + 1, then everyPSC+1), counter trajectory, UIF + CC1..4IF (+ CC5/6IF on advanced) latching, and — critically — the enabled-flag counter freeze: the legacytick()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.&selfreads 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_seqtoken) 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 thecurrent_cycle + 1 + delaywrite identity /now + delayreschedule identity from #511.NVIC routing: reused, with one timing fix. Events pend via the existing
EventResult::raise_own_irq→pend_irq_for_eventchoke (no new mechanism). One real skew fixed in that choke: the legacy walk pends ISPR and scans ISPR&ISER into the CPU in the sametick_peripheralscall, 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_eventnow also pushes ISER-enabled pends as their exception number into the caller'sset_exception_pendingfan-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: 1on 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-bitARR == 0xFFFF_FFFFnever wraps (the walk'scnt > arru32 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()isfalseonly 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
timer.rs::scheduler_mode): a harness drives the scheduler path exactly likeMachineat interval 1 (publish/drain/write-choke identities) against the legacytick()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.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.splash_framebuffer_matches_across_tick_intervalsandderived_and_explicit_walk_flag_are_output_identical(20M cycles, walk-on vs walk-deleted) pass on the branch.cargo test -p labwired-core --features jit,event-scheduler: green except the known machine-localintmatrix_alarmflake. Featureless lanes: green (3 known WFI-FF feature-unification failures in a bare-p labwired-core --librun only).Honest quantization notes (interval > 1)
sync_tobound (and strictly better than the pre-migration walk at interval 64, which slowed the whole counter 64×).Numbers (invaders lab firmware, release, native, 40M cycles)
walk_deleted: true, interval 64walk_deleted: true, interval 1The 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.rsis tracked by 5 silicon-tier boards (stm32h563, nucleo-l476rg, nucleo-l073rz, stm32f103, stm32f407): register layout/reset/MMIO byte paths are unchanged, dateddrift_ack: 2026-07-12added per entry, VALIDATION_STATUS.md regenerated (--check --driftgreen).