From fccac561625e84b2b579431c48455b625ac017bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Gonz=C3=A1lez?= Date: Tue, 13 Oct 2026 18:35:13 -0300 Subject: [PATCH 1/2] kestrel: microsecond delays that hold on Windows ## 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 ``/`` 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. --- src/PreciseDelay.h | 34 ++++++++++++++++++++++++++++++++++ src/kestrel/HalKestrel.cpp | 5 ++--- src/kestrel/KestrelFw.cpp | 7 ++----- 3 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 src/PreciseDelay.h diff --git a/src/PreciseDelay.h b/src/PreciseDelay.h new file mode 100644 index 00000000..30bac031 --- /dev/null +++ b/src/PreciseDelay.h @@ -0,0 +1,34 @@ +#pragma once + +/* Microsecond delays that mean what they say on every platform. + * + * std::this_thread::sleep_for is fine for short waits on Linux, but on + * Windows no sleep ends before the next scheduler tick: 15.6 ms by default, + * 1 ms at best with timeBeginPeriod(1). The Kestrel bring-up and the vendored + * halbb/halrf calibrations wait tens of microseconds thousands of times, so a + * 50 us wait turning into 15.6 ms stretches a seconds-long bring-up into many + * minutes. On Windows, waits under kSpinUs spin on steady_clock (QPC-backed); + * longer ones sleep for all but the last tick and spin the remainder. Other + * platforms keep sleep_for unchanged. */ + +#include +#include +#include + +namespace devourer { + +inline void precise_delay_us(uint32_t us) { +#if defined(_WIN32) + constexpr uint32_t kSpinUs = 2000; + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::microseconds(us); + if (us > kSpinUs) + std::this_thread::sleep_for(std::chrono::microseconds(us - kSpinUs)); + while (std::chrono::steady_clock::now() < deadline) + std::this_thread::yield(); +#else + std::this_thread::sleep_for(std::chrono::microseconds(us)); +#endif +} + +} // namespace devourer diff --git a/src/kestrel/HalKestrel.cpp b/src/kestrel/HalKestrel.cpp index 208a8f4d..6b315351 100644 --- a/src/kestrel/HalKestrel.cpp +++ b/src/kestrel/HalKestrel.cpp @@ -8,6 +8,7 @@ #include "KestrelFw.h" #include "MacRegAx.h" +#include "PreciseDelay.h" #include "kestrel_halbb_glue.h" /* vendored halbb-G6 BB bring-up (C) */ #include "kestrel_halrf_glue.h" /* vendored halrf-G6 RF cals (C) */ @@ -16,9 +17,7 @@ namespace kestrel { namespace { namespace r = kestrel::reg; -void delay_us(uint32_t us) { - std::this_thread::sleep_for(std::chrono::microseconds(us)); -} +void delay_us(uint32_t us) { devourer::precise_delay_us(us); } } /* namespace */ diff --git a/src/kestrel/KestrelFw.cpp b/src/kestrel/KestrelFw.cpp index 2a122cc3..9da7abb3 100644 --- a/src/kestrel/KestrelFw.cpp +++ b/src/kestrel/KestrelFw.cpp @@ -1,12 +1,11 @@ #include "KestrelFw.h" -#include #include -#include #include #include "KestrelLe.h" #include "MacRegAx.h" +#include "PreciseDelay.h" #include "SignalStop.h" /* g_devourer_should_stop — set by demo signal handlers */ #if defined(DEVOURER_HAVE_KESTREL_8852B) #include "hal8852b_fw.h" @@ -20,9 +19,7 @@ namespace kestrel { namespace { namespace r = kestrel::reg; -void delay_us(uint32_t us) { - std::this_thread::sleep_for(std::chrono::microseconds(us)); -} +void delay_us(uint32_t us) { devourer::precise_delay_us(us); } /* le32 / put_le32 now live in KestrelLe.h (shared with KestrelFwSched.cpp); * pull them into this anonymous namespace so existing unqualified uses below From cc0f89526e52f4f90fd3d93a10ea04f415ff2619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Gonz=C3=A1lez?= Date: Wed, 14 Oct 2026 04:12:12 -0300 Subject: [PATCH 2/2] kestrel: learn the Windows sleep bound instead of reserving 2 ms 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. --- src/PreciseDelay.h | 54 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/src/PreciseDelay.h b/src/PreciseDelay.h index 30bac031..503727ff 100644 --- a/src/PreciseDelay.h +++ b/src/PreciseDelay.h @@ -7,24 +7,60 @@ * 1 ms at best with timeBeginPeriod(1). The Kestrel bring-up and the vendored * halbb/halrf calibrations wait tens of microseconds thousands of times, so a * 50 us wait turning into 15.6 ms stretches a seconds-long bring-up into many - * minutes. On Windows, waits under kSpinUs spin on steady_clock (QPC-backed); - * longer ones sleep for all but the last tick and spin the remainder. Other - * platforms keep sleep_for unchanged. */ + * minutes. + * + * On Windows the wait spins on steady_clock (QPC-backed) and only sleeps + * while more time is left than a sleep can take. The tick is process-global + * state that any library may change, so that bound is learned rather than + * assumed: it starts at one default tick, follows shorter sleeps down slowly + * (e.g. once timeBeginPeriod(1) is active) and jumps back up at once when a + * sleep runs long. A wait shorter than the current tick (the 5 ms EDCCA settle + * under the default 15.6 ms tick) therefore never sleeps. Other platforms keep + * sleep_for unchanged. */ +#include +#include #include #include #include namespace devourer { +#if defined(_WIN32) +namespace detail { +/* Longest a 1 ms sleep_for is expected to take right now, in microseconds. + * Starts at (and is capped to) the default 15.625 ms tick plus slack. */ +inline constexpr uint32_t kMaxSleepUs = 16000; +inline std::atomic &sleep_bound_us() { + static std::atomic bound{kMaxSleepUs}; + return bound; +} +} // namespace detail +#endif + inline void precise_delay_us(uint32_t us) { #if defined(_WIN32) - constexpr uint32_t kSpinUs = 2000; - const auto deadline = - std::chrono::steady_clock::now() + std::chrono::microseconds(us); - if (us > kSpinUs) - std::this_thread::sleep_for(std::chrono::microseconds(us - kSpinUs)); - while (std::chrono::steady_clock::now() < deadline) + using clock = std::chrono::steady_clock; + using std::chrono::microseconds; + /* Kept free after the last sleep for wakeup latency on a busy system. */ + constexpr int64_t kMarginUs = 500; + const auto deadline = clock::now() + microseconds(us); + auto &bound = detail::sleep_bound_us(); + for (;;) { + const auto start = clock::now(); + const int64_t left = + std::chrono::duration_cast(deadline - start).count(); + const uint32_t b = bound.load(std::memory_order_relaxed); + if (left <= int64_t(b) + kMarginUs) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + const auto took = static_cast(std::min( + std::chrono::duration_cast(clock::now() - start).count(), + detail::kMaxSleepUs)); + bound.store(took >= b ? took : b - (b - took) / 8, + std::memory_order_relaxed); + } + while (clock::now() < deadline) std::this_thread::yield(); #else std::this_thread::sleep_for(std::chrono::microseconds(us));