From d36c56d3f5cb165323b563e17d65e4c0c64f16d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:29:32 -0500 Subject: [PATCH 1/2] [core] Trim scheduler freelist of post-boot peak 10s after setup Boot churn (component init, first sensor reads, retries) inflates the freelist beyond the steady-state high-water mark. Without a one-shot trim that peak would be retained forever. Adds Scheduler::trim_freelist() and schedules it from Application::setup() to fire SCHEDULER_FREELIST_TRIM_DELAY_MS (10s) after setup completes -- well past the bulk of post-setup async work. Items currently in items_/to_add_/defer_queue_ are untouched; only the freelist's recycled items are deleted. Post-trim, the freelist regrows to the new (post-startup) high-water mark. --- esphome/core/application.cpp | 7 +++++++ esphome/core/scheduler.cpp | 22 ++++++++++++++++++++++ esphome/core/scheduler.h | 7 +++++++ 3 files changed, 36 insertions(+) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d03696fbb65..38d3503c2c3 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -25,6 +25,10 @@ namespace esphome { static const char *const TAG = "app"; +// Delay after setup() finishes before trimming the scheduler freelist of its post-boot peak. +// 10 s is well past the bulk of post-setup async work (Wi-Fi/MQTT connects, first-read latency). +static constexpr uint32_t SCHEDULER_FREELIST_TRIM_DELAY_MS = 10000; + // Helper function for insertion sort of components by priority // Using insertion sort instead of std::stable_sort saves ~1.3KB of flash // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) @@ -112,6 +116,9 @@ void Application::setup() { ESP_LOGI(TAG, "setup() finished successfully!"); + // Trim the scheduler freelist of its post-boot peak once startup churn settles. + this->scheduler.set_timeout(this, SCHEDULER_FREELIST_TRIM_DELAY_MS, [this]() { this->scheduler.trim_freelist(); }); + #ifdef USE_SETUP_PRIORITY_OVERRIDE // Clear setup priority overrides to free memory clear_setup_priority_overrides(); diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c3cbfc84380..c9ed69a80ce 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -899,6 +899,28 @@ void Scheduler::recycle_item_main_loop_(SchedulerItem *item) { #endif } +void Scheduler::trim_freelist() { + LockGuard guard{this->lock_}; + SchedulerItem *item = this->scheduler_item_pool_head_; + size_t freed = 0; + while (item != nullptr) { + SchedulerItem *next = item->next_free; + delete item; +#ifdef ESPHOME_DEBUG_SCHEDULER + this->debug_live_items_--; +#endif + item = next; + freed++; + } + this->scheduler_item_pool_head_ = nullptr; + this->scheduler_item_pool_size_ = 0; +#ifdef ESPHOME_DEBUG_SCHEDULER + ESP_LOGD(TAG, "Freelist trimmed (%zu items freed)", freed); +#else + (void) freed; +#endif +} + #ifdef ESPHOME_DEBUG_SCHEDULER void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, uint32_t delay, uint64_t now) { diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index aa41a161be4..da15279ae98 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -132,6 +132,13 @@ class Scheduler { // @return Timestamp of the last item that ran, or `now` unchanged if none ran. uint32_t call(uint32_t now); + // Free every SchedulerItem currently sitting in the recycle freelist. Items in + // items_/to_add_/defer_queue_ are untouched. Intended to claim the post-boot + // peak: boot churn (component setup, first sensor reads, retries) inflates the + // freelist beyond what steady-state needs, and without a one-shot trim that + // peak would be retained forever. + void trim_freelist(); + // Move items from to_add_ into the main heap. // IMPORTANT: This method should only be called from the main thread (loop task). // Inlined: the fast path (nothing to add) is just an atomic load / empty check. From 8b840e8517ec2a60b35818782f2a3dd140f7a5bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 11:31:13 -0500 Subject: [PATCH 2/2] [core] Also shrink items_/to_add_/defer_queue_ vector capacity in trim_freelist() The freelist holds the boot-peak count of recycled SchedulerItem*; items_, to_add_, and defer_queue_ hold the boot-peak vector *capacity* of live SchedulerItem*. std::vector grows by doubling and retains capacity even when items drain, so post-boot the vector slack can be larger than the freelist itself. Swap each with a same-content copy to size them exactly to their current contents. Live items are preserved -- the swap-copy idiom builds the new vector from the existing pointers, then the old (over-capacity) vector is destroyed. --- esphome/core/scheduler.cpp | 10 ++++++++++ esphome/core/scheduler.h | 9 ++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index c9ed69a80ce..2cf6644c3ec 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -914,6 +914,16 @@ void Scheduler::trim_freelist() { } this->scheduler_item_pool_head_ = nullptr; this->scheduler_item_pool_size_ = 0; + + // The vectors that back items_/to_add_/defer_queue_ also retain their boot-peak + // capacity (std::vector grows by doubling). Swap each with a same-content copy to + // reclaim the slack -- the new vector is sized exactly to its current contents. + std::vector(this->items_).swap(this->items_); + std::vector(this->to_add_).swap(this->to_add_); +#ifndef ESPHOME_THREAD_SINGLE + std::vector(this->defer_queue_).swap(this->defer_queue_); +#endif + #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Freelist trimmed (%zu items freed)", freed); #else diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index da15279ae98..7cdb29aae0e 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -132,11 +132,10 @@ class Scheduler { // @return Timestamp of the last item that ran, or `now` unchanged if none ran. uint32_t call(uint32_t now); - // Free every SchedulerItem currently sitting in the recycle freelist. Items in - // items_/to_add_/defer_queue_ are untouched. Intended to claim the post-boot - // peak: boot churn (component setup, first sensor reads, retries) inflates the - // freelist beyond what steady-state needs, and without a one-shot trim that - // peak would be retained forever. + // Reclaim memory held by the post-boot peak. Frees every SchedulerItem in the + // recycle freelist and shrinks items_/to_add_/defer_queue_ vector capacity to + // their current sizes (std::vector grows by doubling and otherwise retains the + // peak). Live items in those vectors are preserved. void trim_freelist(); // Move items from to_add_ into the main heap.