From c2e6787e23854de00bce7194cce93ee1e4100bc7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 15:16:03 -0500 Subject: [PATCH] [core] Trim Scheduler::call fast path Two small wins on the per-loop Scheduler::call path: 1. Mark the scheduler's `millis_64()` wrapper `ESPHOME_ALWAYS_INLINE`. The inner `esphome::millis_64()` is already always_inline, but the wrapper was not, so GCC emitted an out-of-line `$isra$0` clone with its own `entry`/`retw` register-window prologue/epilogue at every call site. Inlining removes the wrapper's call overhead and the register-window thunk on ESP32. 2. Collapse the two atomic reads of `to_remove_` in `Scheduler::call` into one. The previous sequence cleanup_(); // loads to_remove_ if (to_remove_count_() >= MAX_...) ... // reloads to_remove_ produced `memw; l32i; beqz; memw; l32i; bltui` on the fast path because the compiler cannot CSE across the `memw` barriers that std::atomic::load emits on Xtensa. Reading the counter once and branching on the result leaves a single `memw; l32i; beqz` on the common zero-case; the slow path (cleanup_slow_path_ + re-read + optional full_cleanup) pays an extra read but already holds the scheduler mutex. --- esphome/core/scheduler.cpp | 21 ++++++++++++++------- esphome/core/scheduler.h | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d83d67d6e4..5a96b529e8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -601,14 +601,21 @@ uint32_t HOT Scheduler::call(uint32_t now) { } #endif /* ESPHOME_DEBUG_SCHEDULER */ - // Cleanup removed items before processing - // First try to clean items from the top of the heap (fast path) - this->cleanup_(); + // Cleanup removed items before processing. Read the counter once so the + // common zero-case is a single atomic load + branch; the old code called + // cleanup_() (which loads to_remove_) and then to_remove_count_() again for + // the MAX check, producing a redundant memw+load pair on the fast path. + if (this->to_remove_count_() != 0) { + // First try to clean items from the top of the heap (fast path). + this->cleanup_slow_path_(); - // If we still have too many cancelled items, do a full cleanup - // This only happens if cancelled items are stuck in the middle/bottom of the heap - if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { - this->full_cleanup_removed_items_(); + // If we still have too many cancelled items, do a full cleanup. + // This only happens if cancelled items are stuck in the middle/bottom + // of the heap. Re-read to_remove_ because cleanup_slow_path_ may have + // decremented it. + if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) { + this->full_cleanup_removed_items_(); + } } // IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators. // This is intentional — fired intervals are pushed back into items_ via diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 46b19855c3..590c23503b 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -118,7 +118,7 @@ class Scheduler { bool cancel_retry(Component *component, uint32_t id); /// Get 64-bit millisecond timestamp (handles 32-bit millis() rollover) - uint64_t millis_64() { return esphome::millis_64(); } + uint64_t ESPHOME_ALWAYS_INLINE millis_64() { return esphome::millis_64(); } // 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.