Repository navigation
kestrel: microsecond delays that hold on Windows - #486
Merged
josephnef merged 2 commits intoOct 14, 2026
Merged
Conversation
## The problem
On Windows a Kestrel bring-up takes about a minute instead of a few
seconds. Both `delay_us` helpers (`HalKestrel.cpp`, `KestrelFw.cpp`) are
`std::this_thread::sleep_for(microseconds(us))`, and on Windows no sleep
ends before the next scheduler tick: 15.6 ms by default, 1 ms at best
with `timeBeginPeriod(1)`. A 50 us settle becomes 15.6 ms, a 300x
overshoot.
The bring-up waits a few microseconds thousands of times (the 48.5 s
difference below is ~3,100 ticks). The MAC
init and firmware download poll in short steps, and every delay the
vendored halbb/halrf code makes (`_os_delay_us` ->
`kestrel_halbb_bridge::delay_us` -> `HalKestrel::halbb_delay`) lands in
the same helper, so the BB/RF table load and the RF calibrations pay
the tick on every step.
## The change
- New `src/PreciseDelay.h`: `devourer::precise_delay_us(us)`.
- On `_WIN32`, a wait under 2 ms spins on `steady_clock` (QPC-backed),
yielding each pass. A longer wait sleeps for all but the last 2 ms
and spins the rest, so it is never shorter than asked and never
burns a core for long.
- Everywhere else it is the same `sleep_for` as before. Linux and
macOS builds are unchanged.
- Both Kestrel `delay_us` helpers call it. `KestrelFw.cpp` drops the
`<chrono>`/`<thread>` includes it no longer uses.
Scope is Kestrel only, because that is what I measured. Jaguar1/2 and
`HalmacCfgParam.cpp` have the same `sleep_for(microseconds(..))`
pattern and would likely benefit too. The 1 ms polls in `HalKestrel.cpp`
are left alone: they are poll intervals, not settle times.
## Measured
`kestrelprobe phy --pid 0x0108` (power -> EFUSE -> FWDL -> MAC -> PHY),
TP-Link Archer TX20U Nano (RTL8852BU), Windows 11, MSVC 19.43 Release,
WinUSB, same adapter and USB port for every run, two runs each:
| build | bring-up |
|---|---|
| `origin/master` 74c6b37 | 54.0 s, 53.9 s |
| this branch | 5.5 s, 5.4 s |
Every run ends with `kestrel.id`, `kestrel.power` and `kestrel.phy` all
`ok:true`. The probe does not call `timeBeginPeriod`, so `master` sees
the default 15.6 ms tick, as any application linking devourer would.
|
/review |
Code Review by Qodo
1.
|
precise_delay_us slept for all but the last 2 ms of any wait longer than 2 ms. Under the default 15.6 ms tick that sleep still runs to the next tick, so the 5 ms settle before the EDCCA report in HalKestrel took ~15.5 ms (p50, standalone bench), the overshoot the helper exists to remove. The wait now spins and only sleeps, 1 ms at a time, while more time is left than one sleep can take plus a 0.5 ms margin. The bound is learned from the sleeps themselves because the tick is process-global state: it starts at one default tick (16 ms), decays toward shorter sleeps once timeBeginPeriod(1) is in effect, and jumps back up at once when a sleep runs long. A wait shorter than the current tick never sleeps. Bench, default tick: 5 ms request p50/max 5.0/5.0 ms (was 15.5/16.0), 20 ms 20.0/20.2 ms (was 31.5/33.4). With a 1 ms tick both versions hold. Known limit: when the tick coarsens mid-run, the first wait long enough to sleep can still overshoot by one tick before the bound catches up (seen once: 3 ms -> 15.5 ms). kestrelprobe phy on the TX20U Nano: 5.46 s, 5.45 s (was 5.5 s, 5.4 s), all stages ok.
josephnef
approved these changes
Oct 14, 2026
josephnef
left a comment
Collaborator
There was a problem hiding this comment.
Approving. Reviewed cc0f895 (two commits, learned-bound revision).
What I checked
- The Windows branch is pure
std::chrono/std::thread, so I compiled it on Linux with_WIN32forced and timed 40 calls per request size. Idle: 1 us → 1–2 us, 50 us → 50–51 us, 3/5 ms → exact, 20 ms → 20.000–20.004 ms, 100 ms → 100.000–100.004 ms, zero underruns, and the bound learned down from 16000 to ~1100 us once the 20 ms waits started sleeping. Under a-j32build on the same box the yield-spin overshot (a 50 us request up to 9 ms) — expected for a yield loop on a saturated host, and no different from whatsleep_forwould do there. - Linux/macOS branch is byte-identical
sleep_for; the Linux build +ctest(82/82) and the full CI matrix (MSVC, MinGW, macOS, Linux, per-chip configs, sanitizers) are green. - Merges cleanly with #487 (both touch
KestrelFw.cpp).
Notes, none blocking
- Under the default 15.6 ms tick every wait ≤ ~16.5 ms is now a 100%-of-a-core yield-spin, and the bound only learns down after a wait long enough to sleep (> bound + 0.5 ms). So on a host that has
timeBeginPeriod(1)active but never issues a >16 ms wait, all delays still spin. Correct, and bring-up is the main consumer, but worth one line in the header comment so nobody is surprised by the CPU profile. - The two
delay_us(1000)settles inenable_efuse_pwr_cut/disable_efuse_pwr_cutnow spin 1 ms each on Windows; they are settle times, fine. - Same
sleep_for(microseconds(..))pattern exists in Jaguar1/2/3, RTL8733B,HalmacCfgParam.cpp,PcieDmaUtil.h, MT7612U. Agree with keeping this PR Kestrel-scoped since that's what was measured; a follow-up issue to route those throughprecise_delay_usonce someone has a Windows number for them would be the right place.
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.
The problem
On Windows a Kestrel bring-up takes about a minute instead of a few seconds. Both
delay_ushelpers (HalKestrel.cpp,KestrelFw.cpp) arestd::this_thread::sleep_for(microseconds(us)), and on Windows no sleep ends before the next scheduler tick: 15.6 ms by default, 1 ms at best withtimeBeginPeriod(1). A 50 us settle becomes 15.6 ms, a 300x overshoot.The bring-up waits a few microseconds thousands of times (the 48.5 s difference below is ~3,100 ticks). The MAC init and firmware download poll in short steps, and every delay the vendored halbb/halrf code makes (
_os_delay_us->kestrel_halbb_bridge::delay_us->HalKestrel::halbb_delay) lands in the same helper, so the BB/RF table load and the RF calibrations pay the tick on every step.The change
src/PreciseDelay.h:devourer::precise_delay_us(us)._WIN32, the wait spins onsteady_clock(QPC-backed), yielding each pass, and only sleeps (1 ms at a time) while more time is left than one sleep can take plus a 0.5 ms margin. That bound is learned, not assumed, because the tick is process-global and any library can change it: it starts at one default tick (16 ms), follows shorter sleeps down slowly oncetimeBeginPeriod(1)is in effect, and jumps back up as soon as a sleep runs long. A wait shorter than the current tick (the 5 ms EDCCA settle under the default tick) therefore never sleeps, and no wait ends before it should.sleep_foras before. Linux and macOS builds are unchanged.delay_ushelpers call it.KestrelFw.cppdrops the<chrono>/<thread>includes it no longer uses.Scope is Kestrel only, because that is what I measured. Jaguar1/2 and
HalmacCfgParam.cpphave the samesleep_for(microseconds(..))pattern and would likely benefit too. The 1 ms polls inHalKestrel.cppare left alone: they are poll intervals, not settle times.Measured
kestrelprobe phy --pid 0x0108(power -> EFUSE -> FWDL -> MAC -> PHY), TP-Link Archer TX20U Nano (RTL8852BU), Windows 11, MSVC 19.43 Release, WinUSB, same adapter and USB port for every run, two runs each:origin/master74c6b37Every run ends with
kestrel.id,kestrel.powerandkestrel.phyallok:true. The probe does not calltimeBeginPeriod, somastersees the default 15.6 ms tick, as any application linking devourer would. The learned-bound revision (second commit) measured 5.46 s and 5.45 s on the same setup.Per-call accuracy, standalone bench (40 calls each, 10 for 20 ms+), old = first commit's fixed 2 ms spin reserve, new = this revision:
The adversarial case: when the tick goes back from 1 ms to 15.6 ms in the middle of a run (another process ends its
timeBeginPeriod), the first wait long enough to sleep can still overshoot by up to one tick (seen once: a 3 ms request took 15.5 ms) before the bound catches up. No approach that doesn't query the effective resolution avoids that, andNtQueryTimerResolutionreports the global value, not what Windows 11 actually grants a background process. On Windows 11 a windowless process'stimeBeginPeriod(1)is withdrawn within ~10 s unless it opts out of timer power throttling (measured:Sleep(1)mean 1.5 ms at start, 15.5 ms from 10 s on; the 1 ms rows used the opt-out), so the default-tick rows are what a long-running console tool linking devourer gets even if it asks for 1 ms.