From c6c01f85af9290843f99146c2509231eb6cbdabb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Feb 2026 19:29:41 -0600 Subject: [PATCH] [setup_heap_stats] Add component to track per-component heap allocation during boot Tracks heap usage per component during both construction and setup() phases. Logs deltas inline as components are registered and set up, then prints a sorted summary after setup() completes. Storage is freed after the summary. This is a diagnostic tool - not intended for production use. It was used to discover the logger's unused UART event queue allocation (#14168). --- .../components/setup_heap_stats/__init__.py | 13 ++ .../setup_heap_stats/setup_heap_stats.cpp | 124 ++++++++++++++++++ .../setup_heap_stats/setup_heap_stats.h | 56 ++++++++ esphome/core/application.cpp | 23 ++++ esphome/core/application.h | 10 ++ esphome/core/component.cpp | 13 ++ esphome/core/defines.h | 1 + tests/components/setup_heap_stats/common.yaml | 1 + .../setup_heap_stats/test.bk72xx-ard.yaml | 1 + .../setup_heap_stats/test.esp32-idf.yaml | 1 + .../setup_heap_stats/test.esp8266-ard.yaml | 1 + .../setup_heap_stats/test.rp2040-ard.yaml | 1 + .../setup_heap_stats/test.rtl87xx-ard.yaml | 1 + 13 files changed, 246 insertions(+) create mode 100644 esphome/components/setup_heap_stats/__init__.py create mode 100644 esphome/components/setup_heap_stats/setup_heap_stats.cpp create mode 100644 esphome/components/setup_heap_stats/setup_heap_stats.h create mode 100644 tests/components/setup_heap_stats/common.yaml create mode 100644 tests/components/setup_heap_stats/test.bk72xx-ard.yaml create mode 100644 tests/components/setup_heap_stats/test.esp32-idf.yaml create mode 100644 tests/components/setup_heap_stats/test.esp8266-ard.yaml create mode 100644 tests/components/setup_heap_stats/test.rp2040-ard.yaml create mode 100644 tests/components/setup_heap_stats/test.rtl87xx-ard.yaml diff --git a/esphome/components/setup_heap_stats/__init__.py b/esphome/components/setup_heap_stats/__init__.py new file mode 100644 index 0000000000..9422390a4f --- /dev/null +++ b/esphome/components/setup_heap_stats/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +import esphome.config_validation as cv + +CODEOWNERS = ["@bdraco"] + +CONFIG_SCHEMA = cv.All( + cv.Schema({}), + cv.only_on(["esp32", "esp8266", "rp2040", "bk72xx", "rtl87xx", "ln882x"]), +) + + +async def to_code(config): + cg.add_define("USE_SETUP_HEAP_STATS") diff --git a/esphome/components/setup_heap_stats/setup_heap_stats.cpp b/esphome/components/setup_heap_stats/setup_heap_stats.cpp new file mode 100644 index 0000000000..e908cefff4 --- /dev/null +++ b/esphome/components/setup_heap_stats/setup_heap_stats.cpp @@ -0,0 +1,124 @@ +#include "setup_heap_stats.h" + +#ifdef USE_SETUP_HEAP_STATS + +#include +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 +#include +#endif +#ifdef USE_ESP8266 +#include +#endif +#ifdef USE_RP2040 +#include +#endif + +namespace esphome { + +namespace setup_heap_stats { + +static const char *const TAG = "setup_heap_stats"; + +SetupHeapStatsCollector::SetupHeapStatsCollector(uint32_t initial_baseline) { + global_setup_heap_stats = this; + uint32_t before_alloc = get_free_internal_heap(); + this->entries_ = new ComponentHeapEntry[ESPHOME_COMPONENT_COUNT]; // NOLINT + uint32_t after_alloc = get_free_internal_heap(); + // Use the pre_setup() baseline but subtract our own entries allocation + // so the collector's overhead is not attributed to the first component + this->last_heap_snapshot_ = initial_baseline - (before_alloc - after_alloc); +} + +uint32_t SetupHeapStatsCollector::get_free_internal_heap() { +#ifdef USE_ESP32 + return heap_caps_get_free_size(MALLOC_CAP_INTERNAL); +#elif defined(USE_ESP8266) + return ESP.getFreeHeap(); // NOLINT(readability-static-accessed-through-instance) +#elif defined(USE_RP2040) + return rp2040.getFreeHeap(); +#elif defined(USE_LIBRETINY) + return lt_heap_get_free(); +#else + return 0; +#endif +} + +void SetupHeapStatsCollector::record_component_registered(Component *comp) { + uint32_t current_heap = get_free_internal_heap(); + int32_t delta = static_cast(this->last_heap_snapshot_) - static_cast(current_heap); + this->last_heap_snapshot_ = current_heap; + + if (this->entry_count_ < ESPHOME_COMPONENT_COUNT) { + this->entries_[this->entry_count_].component = comp; + this->entries_[this->entry_count_].construction_delta = delta; + this->entries_[this->entry_count_].setup_delta = 0; + this->entry_count_++; + } + + ESP_LOGI(TAG, "Constructed %s: %" PRId32 " bytes (free: %" PRIu32 ")", LOG_STR_ARG(comp->get_component_log_str()), + delta, current_heap); +} + +void SetupHeapStatsCollector::record_before_setup(Component *comp) { + this->setup_component_ = comp; + this->setup_before_heap_ = get_free_internal_heap(); +} + +void SetupHeapStatsCollector::record_after_setup(Component *comp) { + if (this->setup_component_ != comp) + return; + + uint32_t current_heap = get_free_internal_heap(); + int32_t delta = static_cast(this->setup_before_heap_) - static_cast(current_heap); + + // Find the entry for this component and update setup delta + for (uint16_t i = 0; i < this->entry_count_; i++) { + if (this->entries_[i].component == comp) { + this->entries_[i].setup_delta = delta; + break; + } + } + + ESP_LOGI(TAG, "Setup %s: %" PRId32 " bytes (free: %" PRIu32 ")", LOG_STR_ARG(comp->get_component_log_str()), delta, + current_heap); + + this->setup_component_ = nullptr; +} + +void SetupHeapStatsCollector::log_summary() { + if (this->entries_ == nullptr || this->entry_count_ == 0) + return; + + // Sort by total (construction + setup) descending + std::sort(this->entries_, this->entries_ + this->entry_count_, + [](const ComponentHeapEntry &a, const ComponentHeapEntry &b) { + return (a.construction_delta + a.setup_delta) > (b.construction_delta + b.setup_delta); + }); + + ESP_LOGI(TAG, "Setup Heap Stats Summary (sorted by total, construction + setup):"); + for (uint16_t i = 0; i < this->entry_count_; i++) { + const auto &entry = this->entries_[i]; + int32_t total = entry.construction_delta + entry.setup_delta; + if (total == 0 && entry.construction_delta == 0 && entry.setup_delta == 0) + continue; + ESP_LOGI(TAG, " %s: %" PRId32 " bytes (construction: %" PRId32 ", setup: %" PRId32 ")", + LOG_STR_ARG(entry.component->get_component_log_str()), total, entry.construction_delta, entry.setup_delta); + } + + // Free storage + delete[] this->entries_; // NOLINT + this->entries_ = nullptr; + this->entry_count_ = 0; +} + +} // namespace setup_heap_stats + +setup_heap_stats::SetupHeapStatsCollector *global_setup_heap_stats = + nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome + +#endif // USE_SETUP_HEAP_STATS diff --git a/esphome/components/setup_heap_stats/setup_heap_stats.h b/esphome/components/setup_heap_stats/setup_heap_stats.h new file mode 100644 index 0000000000..10ca91146d --- /dev/null +++ b/esphome/components/setup_heap_stats/setup_heap_stats.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_SETUP_HEAP_STATS + +#include + +namespace esphome { + +class Component; // Forward declaration + +namespace setup_heap_stats { + +struct ComponentHeapEntry { + Component *component; + int32_t construction_delta; + int32_t setup_delta; +}; + +class SetupHeapStatsCollector { + public: + explicit SetupHeapStatsCollector(uint32_t initial_baseline); + + /// Called from register_component_() to record heap delta since last registration + void record_component_registered(Component *comp); + + /// Called before component->call_setup() + void record_before_setup(Component *comp); + + /// Called after component->call_setup() + void record_after_setup(Component *comp); + + /// Log sorted summary and free storage + void log_summary(); + + /// Get free internal heap size (platform-specific) + static uint32_t get_free_internal_heap(); + + protected: + ComponentHeapEntry *entries_{nullptr}; + uint16_t entry_count_{0}; + uint32_t last_heap_snapshot_{0}; + // Temporary storage for before/after setup measurement + Component *setup_component_{nullptr}; + uint32_t setup_before_heap_{0}; +}; + +} // namespace setup_heap_stats + +extern setup_heap_stats::SetupHeapStatsCollector + *global_setup_heap_stats; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome + +#endif // USE_SETUP_HEAP_STATS diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index b216233f9b..7e9b90a80b 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -21,6 +21,9 @@ #ifdef USE_STATUS_LED #include "esphome/components/status_led/status_led.h" #endif +#ifdef USE_SETUP_HEAP_STATS +#include "esphome/components/setup_heap_stats/setup_heap_stats.h" +#endif #if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) #include "esphome/components/socket/socket.h" @@ -86,6 +89,14 @@ void Application::register_component_(Component *comp) { return; } this->components_.push_back(comp); +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats == nullptr) { + // Lazily create collector on first component registration + // Pass the baseline captured in pre_setup() so the first component's cost is measured + new setup_heap_stats::SetupHeapStatsCollector(this->setup_heap_stats_baseline_); + } + global_setup_heap_stats->record_component_registered(comp); +#endif } void Application::setup() { ESP_LOGI(TAG, "Running through setup()"); @@ -137,6 +148,12 @@ void Application::setup() { ESP_LOGI(TAG, "setup() finished successfully!"); +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats != nullptr) { + global_setup_heap_stats->log_summary(); + } +#endif + #ifdef USE_SETUP_PRIORITY_OVERRIDE // Clear setup priority overrides to free memory clear_setup_priority_overrides(); @@ -747,4 +764,10 @@ void Application::get_build_time_string(std::span buf buffer[buffer.size() - 1] = '\0'; } +#ifdef USE_SETUP_HEAP_STATS +void Application::init_setup_heap_stats_baseline_() { + this->setup_heap_stats_baseline_ = setup_heap_stats::SetupHeapStatsCollector::get_free_internal_heap(); +} +#endif + } // namespace esphome diff --git a/esphome/core/application.h b/esphome/core/application.h index 5b3e3dfed6..6cca5f197d 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -134,6 +134,9 @@ class Application { this->name_ = name; this->friendly_name_ = friendly_name; } +#ifdef USE_SETUP_HEAP_STATS + this->init_setup_heap_stats_baseline_(); +#endif } #ifdef USE_DEVICES @@ -548,6 +551,10 @@ class Application { inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) #endif +#ifdef USE_SETUP_HEAP_STATS + void init_setup_heap_stats_baseline_(); +#endif + // === Member variables ordered by size to minimize padding === // Pointer-sized members first @@ -585,6 +592,9 @@ class Application { // 4-byte members uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; +#ifdef USE_SETUP_HEAP_STATS + uint32_t setup_heap_stats_baseline_{0}; +#endif #ifdef USE_SOCKET_SELECT_SUPPORT int max_fd_{-1}; // Highest file descriptor number for select() diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index b458ea2a84..66e72472f4 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -12,6 +12,9 @@ #ifdef USE_RUNTIME_STATS #include "esphome/components/runtime_stats/runtime_stats.h" #endif +#ifdef USE_SETUP_HEAP_STATS +#include "esphome/components/setup_heap_stats/setup_heap_stats.h" +#endif namespace esphome { @@ -241,8 +244,18 @@ void Component::call() { ESP_LOGV(TAG, "Setup %s", LOG_STR_ARG(this->get_component_log_str())); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t start_time = millis(); +#endif +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats != nullptr) { + global_setup_heap_stats->record_before_setup(this); + } #endif this->call_setup(); +#ifdef USE_SETUP_HEAP_STATS + if (global_setup_heap_stats != nullptr) { + global_setup_heap_stats->record_after_setup(this); + } +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t setup_time = millis() - start_time; // Only log at CONFIG level if setup took longer than the blocking threshold diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5109dd36f4..57005ac414 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -110,6 +110,7 @@ #define USE_SAFE_MODE_CALLBACK #define USE_SELECT #define USE_SENSOR +#define USE_SETUP_HEAP_STATS #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR diff --git a/tests/components/setup_heap_stats/common.yaml b/tests/components/setup_heap_stats/common.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/common.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.bk72xx-ard.yaml b/tests/components/setup_heap_stats/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.esp32-idf.yaml b/tests/components/setup_heap_stats/test.esp32-idf.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.esp32-idf.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.esp8266-ard.yaml b/tests/components/setup_heap_stats/test.esp8266-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.esp8266-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.rp2040-ard.yaml b/tests/components/setup_heap_stats/test.rp2040-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.rp2040-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: diff --git a/tests/components/setup_heap_stats/test.rtl87xx-ard.yaml b/tests/components/setup_heap_stats/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..2fb72f29d3 --- /dev/null +++ b/tests/components/setup_heap_stats/test.rtl87xx-ard.yaml @@ -0,0 +1 @@ +setup_heap_stats: