[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.
This commit is contained in:
J. Nick Koston
2026-04-24 15:59:38 -05:00
parent 30ff363095
commit 4d7033df4f
4 changed files with 32 additions and 34 deletions
+2 -1
View File
@@ -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};
+14 -12
View File
@@ -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);
}
+6 -5
View File
@@ -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_};
+10 -16
View File
@@ -136,23 +136,17 @@ class Scheduler {
// IMPORTANT: This method should only be called from the main thread (loop task).
optional<uint32_t> 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).