Merge remote-tracking branch 'upstream-ssh/scheduler-call-fastpath' into integration

# Conflicts:
#	esphome/core/application.h
This commit is contained in:
J. Nick Koston
2026-04-24 15:34:18 -05:00
3 changed files with 50 additions and 18 deletions
+13 -11
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 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
@@ -513,10 +513,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();
@@ -561,11 +562,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;
#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
@@ -685,7 +687,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.
+17 -4
View File
@@ -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<uint32_t> HOT Scheduler::next_schedule_in(uint32_t now) {
optional<uint32_t> 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.
@@ -455,7 +455,12 @@ optional<uint32_t> 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;
@@ -566,7 +571,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 */
@@ -577,6 +582,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;
@@ -689,6 +698,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_};
@@ -745,7 +755,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_};
+20 -3
View File
@@ -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<uint32_t> next_schedule_in(uint32_t now);
optional<uint32_t> 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).