From 75f5b179373c49be22222c3faff931371aa73f28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:32:09 -0500 Subject: [PATCH] [core] Thread Scheduler now_64 into next_schedule_in to skip duplicate clock read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduler::call() and Scheduler::next_schedule_in() both computed now_64 via millis_64_from_(now) at the top of every main-loop iteration. On ESP32 with USE_NATIVE_64BIT_TIME this is an esp_timer_get_time() MMIO read (~1us); doing it twice per iteration is wasted work since the two calls happen microseconds apart. Thread the value through: - Scheduler::call() now returns a CallResult { now, now_64 } struct instead of just the advanced uint32_t now. When items fired, now_64 is set to 0 (sentinel) because execute_item_() only advances the uint32 and the local now_64 is stale by the time we return. - Scheduler::next_schedule_in() takes an optional now_64 parameter (default 0). Non-zero values are trusted and used directly; the 0 sentinel triggers a fresh millis_64_from_(now) read. - Application::scheduler_tick_() forwards the CallResult through. - Application::loop() passes sched_result.now_64 to next_schedule_in(). Verified in the disassembly: on the fast path (no items fired), next_schedule_in branches over its esp_timer_get_time call entirely via `or a8, a4, a5; bnez a8, ...` on the two halves of now_64, jumping straight to the next_exec comparison. When call() returned the 0 sentinel, next_schedule_in falls through to the existing inlined micros_to_millis(esp_timer_get_time()) sequence as before. Bench callers (tests/benchmarks/core/bench_scheduler.cpp) and the external test component (tests/integration/fixtures/.../scheduler_bulk_cleanup_component) ignore the return value — no changes needed there. --- esphome/core/application.h | 24 +++++++++++++----------- esphome/core/scheduler.cpp | 21 +++++++++++++++++---- esphome/core/scheduler.h | 23 ++++++++++++++++++++--- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index e9b386038e..a6f626fc83 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -392,7 +392,7 @@ class Application { void enable_component_loop_(Component *component); void enable_pending_loops_(); void activate_looping_component_(uint16_t index); - inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); // RAII guard for a component loop phase. Constructor processes any pending // enable_loop requests from ISRs and marks in_loop_ so reentrant @@ -508,10 +508,11 @@ extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-vari // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has // raised loop_interval_ for power savings (see Application::loop()). -// Returns the timestamp of the last scheduler item that ran (or `now` -// unchanged if none ran), so the caller's WDT feed stays monotonic with the -// per-item feeds inside scheduler.call() without an extra millis(). -inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { +// Returns the advanced `now` (for monotonic wdt feed) alongside the Scheduler's +// 64-bit `now_64` (or 0 if any item fired — then stale). The main loop forwards +// now_64 to next_schedule_in() to avoid a second esp_timer_get_time() MMIO read +// per iteration; 0 means "compute fresh". +inline Scheduler::CallResult ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake. wake_drain_notifications(); @@ -556,11 +557,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Phase A: always service the scheduler. Decouples scheduler cadence from // loop_interval_ so raised intervals (for power savings) don't drag scheduled // items forward. A tick that only runs the scheduler is cheap. - // scheduler_tick_ returns the timestamp of the last scheduler item that ran - // (advanced by its per-item feeds) or `now` unchanged. We adopt it as `now` - // so the gate check and WDT feed both reflect actual elapsed time after - // scheduler dispatch, without an extra millis() call. - uint32_t now = this->scheduler_tick_(MillisInternal::get()); + // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) plus the + // 64-bit `now_64` Scheduler::call() already computed — forward that to the + // next_schedule_in() call below so we don't re-read esp_timer_get_time() a + // second time per iteration. 0 means "stale, recompute". + const Scheduler::CallResult sched_result = this->scheduler_tick_(MillisInternal::get()); + uint32_t now = sched_result.now; // Guarantee one WDT feed per tick even when the scheduler had nothing to // dispatch and the component phase is gated out — covers configs with no // looping components and no scheduler work (setup() has its own @@ -662,7 +664,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); - const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); + const uint32_t until_sched = this->scheduler.next_schedule_in(now, sched_result.now_64).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 5a96b529e8..f0fd77c5b2 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -412,7 +412,7 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { #pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings -optional HOT Scheduler::next_schedule_in(uint32_t now) { +optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64) { // IMPORTANT: This method should only be called from the main thread (loop task). // Accesses items_[0] and the fast-path empty checks without holding a lock, which // is only safe from the main thread. Other threads must not call this method. @@ -441,7 +441,12 @@ optional HOT Scheduler::next_schedule_in(uint32_t now) { return {}; SchedulerItem *item = this->items_[0]; - const auto now_64 = this->millis_64_from_(now); + // If the caller did not pass a pre-computed now_64 (or passed the 0 + // sentinel, meaning call() advanced past it by firing items), read the + // clock fresh. Otherwise reuse the passed value to avoid a second + // esp_timer_get_time() MMIO read per main-loop iteration. + if (now_64 == 0) + now_64 = this->millis_64_from_(now); const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; @@ -552,7 +557,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -uint32_t HOT Scheduler::call(uint32_t now) { +Scheduler::CallResult HOT Scheduler::call(uint32_t now) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -563,6 +568,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { // Track if any items were added to to_add_ during callbacks bool has_added_items = false; + // Track whether we advanced `now` via execute_item_(). If we did, `now_64` + // is stale by the time we return and next_schedule_in() must recompute — + // signal that by writing 0 to *now_64_out. + bool executed_any_item = false; #ifdef ESPHOME_DEBUG_SCHEDULER static uint64_t last_print = 0; @@ -675,6 +684,7 @@ uint32_t HOT Scheduler::call(uint32_t now) { // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled now = this->execute_item_(item, now); + executed_any_item = true; LockGuard guard{this->lock_}; @@ -731,7 +741,10 @@ uint32_t HOT Scheduler::call(uint32_t now) { #endif // execute_item_() advances `now` as items fire; return it so the caller // stays monotonic with last_wdt_feed_. - return now; + // The returned now_64 is only usable if NO items fired — execute_item_ only + // advances the uint32 `now`, leaving our local now_64 stale. Signal stale + // with the 0 sentinel so next_schedule_in() reads the clock fresh. + return CallResult{now, executed_any_item ? 0 : now_64}; } void HOT Scheduler::process_to_add_slow_path_() { LockGuard guard{this->lock_}; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 590c23503b..7073507a99 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -122,15 +122,32 @@ class Scheduler { // Calculate when the next scheduled item should run. // @param now On ESP32, unused for 64-bit extension (native); on other platforms, extended to 64-bit via rollover. + // @param now_64 Optional pre-computed 64-bit timestamp from a recent call() (see CallResult). + // Pass 0 (sentinel) to have this method compute now_64 freshly. Non-zero values are trusted and + // used directly, saving a millis_64() / esp_timer_get_time() MMIO read when the caller already + // has a fresh value from the same loop iteration. // Returns the time in milliseconds until the next scheduled item, or nullopt if no items. // This method performs cleanup of removed items before checking the schedule. // IMPORTANT: This method should only be called from the main thread (loop task). - optional next_schedule_in(uint32_t now); + optional next_schedule_in(uint32_t now, uint64_t now_64 = 0); + + // Result of Scheduler::call(). + // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed). + // now_64 – 64-bit millis value Scheduler used for item dispatch, OR 0 if any item + // fired (in that case now_64 is stale because execute_item_ only advances + // the uint32 `now`). A subsequent next_schedule_in() treats 0 as "compute + // fresh" and non-zero as "reuse this value", saving a second millis_64() + // / esp_timer_get_time() call per main-loop iteration. + struct CallResult { + uint32_t now; + uint64_t now_64; + }; // Execute all scheduled items that are ready // @param now Fresh timestamp from millis() - must not be stale/cached - // @return Timestamp of the last item that ran, or `now` unchanged if none ran. - uint32_t call(uint32_t now); + // @return Both advanced `now` (for wdt monotonicity) and `now_64` (for pipe-through + // to next_schedule_in()). See CallResult. + CallResult call(uint32_t now); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task).