From 71235f133521441dbb1393a70617bdc363882405 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 24 Aug 2026 16:01:19 +0200 Subject: [PATCH] fix(idle-spin): an interpreter's dispatch loop is not a busy-wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdleSpinDetector reported a spin from two signals: the same backward branch closing over and over, and no GPIO change while it did. A bytecode interpreter matches both — MicroPython's dispatch closes the same loop millions of times and touches no pin — and eliding it does not skip the work, it only races the simulated clock ahead of work that is really happening. On the RP2350 that cost the Badger 2350 its frame budget: BadgeOS executed 500 k guest instructions per second of guest time where the emulator can do four million. Two more things now have to hold before a loop counts as idle: its body must be narrower than 128 bytes, and one turn of it must take fewer than 24 instructions. A delay() that polls the timer is a handful of instructions in a handful of bytes; a dispatch loop is neither. Both are ceilings, so this can only ever elide LESS than before — the RP2040's own delay() elision is unaffected. --- frontend/src/simulation/RP2040Simulator.ts | 63 ++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index 56c659e6..057863c2 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -92,8 +92,42 @@ export class IdleSpinDetector { constructor( private readonly threshold = 32, private readonly maxStride = 256, + /** + * How wide the loop body may be, in bytes of PC range, and still count as + * an idle spin. + * + * A `delay()` that polls the timer is a handful of instructions. An + * INTERPRETER's dispatch loop also closes the same backward branch over and + * over with no GPIO change — MicroPython's does — but it is hundreds of + * bytes wide and it is doing real work. Eliding that does not skip the + * work, it only races the clock ahead of it: on BadgeOS the guest executed + * 107 k instructions per second of guest time instead of 1.4 M, so the + * badge took ten times longer to reach its menu. + * + * 128 bytes is comfortably more than any busy-wait and comfortably less + * than a bytecode dispatch. + */ + private readonly maxLoopSpan = 128, + /** + * How many instructions one iteration may take and still be a busy-wait. + * + * The PC-span test above is necessary and not sufficient: an interpreter's + * dispatch can be tight in ADDRESS and long in WORK, and a loop that is + * really executing bytecode must never have its clock jumped over. A + * `delay()` iteration is a handful of instructions — read the timer, + * compare, branch. Twenty-four leaves room for the compare-and-branch + * variants without admitting a VM. + */ + private readonly maxIterInstructions = 24, ) {} + /** Widest PC seen since this loop started, so a big loop body can be told + * from a tight spin. */ + private loopLow = 0; + private loopHigh = 0; + /** Instructions seen since the last time this loop closed. */ + private sinceIter = 0; + /** * @param pc program counter about to execute * @param gpio thunk returning the current GPIO snapshot (called only on a @@ -104,15 +138,41 @@ export class IdleSpinDetector { const prev = this.prevPc; this.prevPc = pc; if (prev === -1) return false; + if (pc < this.loopLow) this.loopLow = pc; + if (pc > this.loopHigh) this.loopHigh = pc; + this.sinceIter++; if (pc < prev) { // Backward branch — one loop iteration just closed. const g = gpio(); + const iterLength = this.sinceIter; + this.sinceIter = 0; + if (iterLength > this.maxIterInstructions) { + // Too much work in one turn of the loop to be a wait. + this.loopTarget = pc; + this.gpioAtLastIter = g; + this.iters = 1; + this.loopLow = pc; + this.loopHigh = prev; + return false; + } if (this.loopTarget !== pc) { // First time we land on this loop top (or the loop moved): start over. this.loopTarget = pc; this.gpioAtLastIter = g; this.iters = 1; + this.loopLow = pc; + this.loopHigh = prev; + return false; + } + if (this.loopHigh - this.loopLow > this.maxLoopSpan) { + // Too wide to be a busy-wait. An interpreter's dispatch loop looks + // exactly like a spin from the outside, and eliding it races the clock + // ahead of work that is really happening. + this.iters = 1; + this.loopLow = pc; + this.loopHigh = prev; + this.gpioAtLastIter = g; return false; } if (g !== this.gpioAtLastIter) { @@ -144,6 +204,9 @@ export class IdleSpinDetector { this.loopTarget = -1; this.iters = 0; this.gpioAtLastIter = -1; + this.loopLow = 0; + this.loopHigh = 0; + this.sinceIter = 0; } }