From 4d7033df4f1abecd0b811ed80a337a070f9303ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:59:38 -0500 Subject: [PATCH] [core] Switch Scheduler::call now_64 plumbing from struct to out-param Replace the CallResult struct return with a uint64_t &now_64_out reference parameter. The struct return forced GCC on Xtensa to emit 8 redundant s32i stores after every call() (once into the sched_result local plus once into a compiler-chosen temp slot), and the extra ABI shuffling around the 16-byte return value was tight enough with the esp_timer_get_time() MMIOs bracketing the wdt bucket that the wdt measurement regressed from ~21 us/hit to ~36 us/hit on ESP32. Out-param keeps the scheduler's now_64 write local to Scheduler::call (written once to *now_64_out) and leaves the return path a single uint32 in a10. The caller passes &sched_now_64_raw directly; no struct copy. loop_task shrinks 988 B -> 960 B. Disassembly verified: after Scheduler::call returns the only instructions before the next micros() capture are: mov.n a6, a10 ; save now call8 esp_timer_get_time ; loop_after_sched_us No duplicate struct stores. --- esphome/core/application.cpp | 3 ++- esphome/core/application.h | 26 ++++++++++++++------------ esphome/core/scheduler.cpp | 11 ++++++----- esphome/core/scheduler.h | 26 ++++++++++---------------- 4 files changed, 32 insertions(+), 34 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index c390b26cd4..b35db45e26 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -83,7 +83,8 @@ void Application::setup() { // Service scheduler and process pending loop enables to handle GPIO // interrupts during setup. During setup we always run the component // phase (no loop_interval_ gate), so call both helpers unconditionally. - this->scheduler_tick_(MillisInternal::get()); + uint64_t setup_sched_now_64_unused; + this->scheduler_tick_(MillisInternal::get(), setup_sched_now_64_unused); { ComponentPhaseGuard phase_guard{*this}; diff --git a/esphome/core/application.h b/esphome/core/application.h index c2f52bf9f1..6ff8b152e4 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 Scheduler::CallResult ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now); + inline uint32_t ESPHOME_ALWAYS_INLINE scheduler_tick_(uint32_t now, uint64_t &now_64_out); // RAII guard for a component loop phase. Constructor processes any pending // enable_loop requests from ISRs and marks in_loop_ so reentrant @@ -517,12 +517,12 @@ extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-vari // 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) { +inline uint32_t ESPHOME_ALWAYS_INLINE Application::scheduler_tick_(uint32_t now, uint64_t &now_64_out) { #ifdef USE_HOST // Drain wake notifications first to clear socket for next wake. wake_drain_notifications(); #endif - return this->scheduler.call(now); + return this->scheduler.call(now, now_64_out); } // Phase B entry: only invoked when a component loop phase is about to run. @@ -562,12 +562,14 @@ 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 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; + // scheduler_tick_ returns the advanced `now` (for wdt monotonicity) and fills + // sched_now_64 with the 64-bit timestamp 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". + // Out-param keeps the return path in a single register and avoids a 16-byte + // struct-copy on the caller's stack. + uint64_t sched_now_64_raw; + uint32_t now = this->scheduler_tick_(MillisInternal::get(), sched_now_64_raw); #ifdef USE_RUNTIME_STATS // Capture immediately after scheduler_tick_ returns so the post-scheduler // feed_wdt_with_time slice can be separated from the scheduler slice in @@ -687,10 +689,10 @@ 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); - // Resolve the sched_result.now_64 sentinel here so next_schedule_in() can assume a valid + // Resolve the sched_now_64_raw 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); + // sched_now_64_raw is 0 (stale) and we read the clock fresh; otherwise we reuse it. + const uint64_t sched_now_64 = sched_now_64_raw != 0 ? sched_now_64_raw : 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); } diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 46c798eee0..803807d707 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -568,7 +568,7 @@ void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) { } #endif /* not ESPHOME_THREAD_SINGLE */ -Scheduler::CallResult HOT Scheduler::call(uint32_t now) { +uint32_t HOT Scheduler::call(uint32_t now, uint64_t &now_64_out) { #ifndef ESPHOME_THREAD_SINGLE this->process_defer_queue_(now); #endif /* not ESPHOME_THREAD_SINGLE */ @@ -752,10 +752,11 @@ Scheduler::CallResult 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_. - // 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}; + // The now_64 we pass back 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. + now_64_out = executed_any_item ? 0 : now_64; + return now; } 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 c169d458d7..2cf559cc9c 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -136,23 +136,17 @@ class Scheduler { // IMPORTANT: This method should only be called from the main thread (loop task). optional next_schedule_in(uint64_t now_64); - // 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 + // Execute all scheduled items that are ready. // @param now Fresh timestamp from millis() - must not be stale/cached - // @return Both advanced `now` (for wdt monotonicity) and `now_64` (for pipe-through - // to next_schedule_in()). See CallResult. - CallResult call(uint32_t now); + // @param now_64_out Out-param set to the 64-bit `now` that Scheduler used for item + // dispatch, OR 0 if any item fired (in that case the value 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. + // Out-param (rather than struct return) keeps the return path in a single + // register and avoids a 16-byte struct-copy on the loop caller's stack. + // @return Advanced `now` (monotonic with the caller's wdt feed). + uint32_t call(uint32_t now, uint64_t &now_64_out); // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task).