From b659eaf494aeb057f8d66f9ddce65007fbc4d863 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:45:15 -0500 Subject: [PATCH] [core] Hoist Scheduler::next_schedule_in zero-check to caller Follow-up to the CallResult threading: move the 0-sentinel fallback (if now_64 == 0, read the clock fresh) out of next_schedule_in and into the single main-loop caller. Effect on next_schedule_in: drops the uint32_t now parameter entirely and removes the cold millis_64_from_(now) inlined clock read from the function body. Verified in disassembly: Scheduler::next_schedule_in 159 B -> 86 B (-46%) The hot path is now just defer_empty + cleanup_ + top-of-heap compare + return. No clock read anywhere in the function. Application::loop handles the 0-sentinel with a single inline branch right before the call; when call() fired items it invokes the public Scheduler::millis_64_from() wrapper (new, exposes the existing protected millis_64_from_ so callers can do 64-bit extension without platform knowledge). Fast path (no items fired) stays branch-free. Net code size: loop_task +64 B, next_schedule_in -73 B, Scheduler::call unchanged -> -9 B total. --- esphome/core/application.h | 6 +++++- esphome/core/scheduler.cpp | 11 ++++------- esphome/core/scheduler.h | 17 +++++++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index a6f626fc83..4f06bc3ebb 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -664,7 +664,11 @@ 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, sched_result.now_64).value_or(until_phase); + // Resolve the sched_result.now_64 sentinel here so next_schedule_in() can assume a valid + // non-zero input and keep the cold clock-read path out of its body. When call() fired items + // sched_result.now_64 is 0 (stale) and we read the clock fresh; otherwise we reuse it. + const uint64_t sched_now_64 = sched_result.now_64 != 0 ? sched_result.now_64 : this->scheduler.millis_64_from(now); + const uint32_t until_sched = this->scheduler.next_schedule_in(sched_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 f0fd77c5b2..6656ba76fc 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, uint64_t now_64) { +optional HOT Scheduler::next_schedule_in(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,12 +441,9 @@ optional HOT Scheduler::next_schedule_in(uint32_t now, uint64_t now_64 return {}; SchedulerItem *item = this->items_[0]; - // 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); + // Caller is responsible for passing a fresh non-zero now_64 (see header doc). + // Hoisting the 0-sentinel resolution to the caller keeps the cold fallback + // clock-read out of this function's fast path. const uint64_t next_exec = item->get_next_execution(); if (next_exec < now_64) return 0; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 7073507a99..c169d458d7 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -120,16 +120,21 @@ class Scheduler { /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) uint64_t ESPHOME_ALWAYS_INLINE millis_64() { return esphome::millis_64(); } + // Extend a 32-bit `now` timestamp to a 64-bit one. Public wrapper so callers that need to + // materialize a valid now_64 for next_schedule_in() (after call() returned the 0 sentinel) + // can do so without knowing the platform-specific 64-bit extension strategy. + // See millis_64_from_ for platform notes. + uint64_t ESPHOME_ALWAYS_INLINE millis_64_from(uint32_t now) { return this->millis_64_from_(now); } + // 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. + // @param now_64 64-bit millisecond timestamp. Must be non-zero and fresh — the caller is responsible + // for resolving the 0 sentinel that Scheduler::call() may return (via millis_64_from above). + // Hoisting the zero-check out of this function keeps the cold clock-read path out of the + // hot-path's icache line and simplifies register allocation. // 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, uint64_t now_64 = 0); + optional next_schedule_in(uint64_t now_64); // Result of Scheduler::call(). // now – advanced uint32 millis timestamp (monotonic with the caller's wdt feed).