[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.
This commit is contained in:
J. Nick Koston
2026-04-24 15:45:15 -05:00
parent 75f5b17937
commit b659eaf494
3 changed files with 20 additions and 14 deletions
+5 -1
View File
@@ -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.
+4 -7
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, uint64_t now_64) {
optional<uint32_t> 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<uint32_t> 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;
+11 -6
View File
@@ -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<uint32_t> next_schedule_in(uint32_t now, uint64_t now_64 = 0);
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).