From dcac526a349672a506014fd11bc004b60df082d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 07:42:58 -1000 Subject: [PATCH 01/19] [api] Add send_sensor_state benchmarks Add benchmarks for the full send_sensor_state path covering both immediate send and batch paths. This measures the end-to-end cost of sending a sensor state update through an APIConnection including entity field population, proto encode, framing, and TCP write. --- esphome/components/api/api_connection.h | 4 + .../api/bench_send_sensor_state.cpp | 140 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/benchmarks/components/api/bench_send_sensor_state.cpp diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3d8563b1ae..5071b82752 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -44,10 +44,14 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); +class APIConnection; +void bench_enable_immediate_send(APIConnection *conn); // tests/benchmarks + class APIConnection final : public APIServerConnectionBase { public: friend class APIServer; friend class ListEntitiesIterator; + friend void bench_enable_immediate_send(APIConnection *conn); APIConnection(std::unique_ptr socket, APIServer *parent); ~APIConnection(); diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp new file mode 100644 index 0000000000..39fa3246ac --- /dev/null +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -0,0 +1,140 @@ +#include "esphome/core/defines.h" +#if defined(USE_API_PLAINTEXT) && defined(USE_SENSOR) + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::api { + +// Friend function declared in APIConnection to enable immediate send path for benchmarking. +void bench_enable_immediate_send(APIConnection *conn) { conn->flags_.should_try_send_immediately = true; } + +} // namespace esphome::api + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Helper to drain accumulated data from the read side of a socket +// to prevent the write side from blocking. +static void drain_socket(int fd) { + char buf[65536]; + while (::read(fd, buf, sizeof(buf)) > 0) { + } +} + +// Helper to create a TCP loopback connection with an APIConnection. +// Returns the connection and the read-side fd for draining. +static std::pair, int> create_api_connection() { + // Create a TCP listener on loopback + int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); + int opt = 1; + ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // OS-assigned port + ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); + ::listen(listen_fd, 1); + + // Get the assigned port + socklen_t addr_len = sizeof(addr); + ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); + + // Connect from client side + int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); + ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); + + // Accept on server side (this is our read fd) + int read_fd = ::accept(listen_fd, nullptr, nullptr); + ::close(listen_fd); + + // Make both ends non-blocking + int flags = ::fcntl(write_fd, F_GETFL, 0); + ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); + flags = ::fcntl(read_fd, F_GETFL, 0); + ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); + + // Increase socket buffer sizes to reduce drain frequency + int bufsize = 1024 * 1024; + ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); + ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); + + auto sock = std::make_unique(write_fd); + auto conn = std::make_unique(std::move(sock), global_api_server); + conn->start(); + + return {std::move(conn), read_fd}; +} + +// Test subclass to access protected configure_entity_() for benchmark setup. +class TestSensor : public sensor::Sensor { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } +}; + +// --- send_sensor_state: immediate send path --- +// Measures: send_message_smart_ → prepare buffer → dispatch_message_ → +// try_send_sensor_state → fill key/device_id + proto encode → frame write → +// TCP send. This is the per-client cost when batch_delay=0 and initial states +// have been sent. + +static void SendSensorState_Immediate(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + bench_enable_immediate_send(conn.get()); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + + if ((i & 0xFF) == 0) + drain_socket(read_fd); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(SendSensorState_Immediate); + +// --- send_sensor_state: batch path (default for new connections) --- +// Measures: send_message_smart_ → schedule_message_ → deferred batch add. +// This is the default path until initial states are sent. + +static void SendSensorState_Batch(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(SendSensorState_Batch); + +} // namespace esphome::api::benchmarks + +#endif // USE_API_PLAINTEXT && USE_SENSOR From d3f3f89c5946771f2f1d1b2d38f78a5bf41292d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 07:48:12 -1000 Subject: [PATCH 02/19] [api] Extract shared TCP loopback helper for API benchmarks Deduplicate the TCP loopback socket setup between bench_plaintext_frame and bench_send_sensor_state into a shared bench_helpers.h header. --- .../benchmarks/components/api/bench_helpers.h | 66 +++++++++++++++++++ .../components/api/bench_plaintext_frame.cpp | 52 +-------------- .../api/bench_send_sensor_state.cpp | 51 +------------- 3 files changed, 70 insertions(+), 99 deletions(-) create mode 100644 tests/benchmarks/components/api/bench_helpers.h diff --git a/tests/benchmarks/components/api/bench_helpers.h b/tests/benchmarks/components/api/bench_helpers.h new file mode 100644 index 0000000000..53987f0eab --- /dev/null +++ b/tests/benchmarks/components/api/bench_helpers.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "esphome/components/socket/socket.h" + +namespace esphome::api::benchmarks { + +// Helper to drain accumulated data from the read side of a socket +// to prevent the write side from blocking. +inline void drain_socket(int fd) { + char buf[65536]; + while (::read(fd, buf, sizeof(buf)) > 0) { + } +} + +// Create a TCP loopback socket pair. Returns the write-side Socket +// (wrapped for ESPHome) and the raw read-side fd for draining. +// Both ends are non-blocking with 1MB buffers. +inline std::pair, int> create_tcp_loopback() { + // Create a TCP listener on loopback + int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); + int opt = 1; + ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // OS-assigned port + ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); + ::listen(listen_fd, 1); + + // Get the assigned port + socklen_t addr_len = sizeof(addr); + ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); + + // Connect from client side + int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); + ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); + + // Accept on server side (this is our read fd) + int read_fd = ::accept(listen_fd, nullptr, nullptr); + ::close(listen_fd); + + // Make both ends non-blocking + int flags = ::fcntl(write_fd, F_GETFL, 0); + ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); + flags = ::fcntl(read_fd, F_GETFL, 0); + ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); + + // Increase socket buffer sizes to reduce drain frequency + int bufsize = 1024 * 1024; + ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); + ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); + + return {std::make_unique(write_fd), read_fd}; +} + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 79bffaf953..6347cf0a43 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -2,12 +2,9 @@ #ifdef USE_API_PLAINTEXT #include -#include -#include -#include -#include #include +#include "bench_helpers.h" #include "esphome/components/api/api_frame_helper_plaintext.h" #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_buffer.h" @@ -16,57 +13,12 @@ namespace esphome::api::benchmarks { static constexpr int kInnerIterations = 2000; -// Helper to drain accumulated data from the read side of a socket -// to prevent the write side from blocking. -static void drain_socket(int fd) { - char buf[65536]; - while (::read(fd, buf, sizeof(buf)) > 0) { - } -} - // Helper to create a TCP loopback connection with an APIPlaintextFrameHelper // on the write end. Returns the helper and the read-side fd. -// Uses real TCP sockets so TCP_NODELAY succeeds during init(). static std::pair, int> create_plaintext_helper() { - // Create a TCP listener on loopback - int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); - int opt = 1; - ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); - - struct sockaddr_in addr {}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; // OS-assigned port - ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); - ::listen(listen_fd, 1); - - // Get the assigned port - socklen_t addr_len = sizeof(addr); - ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); - - // Connect from client side - int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); - ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); - - // Accept on server side (this is our read fd) - int read_fd = ::accept(listen_fd, nullptr, nullptr); - ::close(listen_fd); - - // Make both ends non-blocking - int flags = ::fcntl(write_fd, F_GETFL, 0); - ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); - flags = ::fcntl(read_fd, F_GETFL, 0); - ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); - - // Increase socket buffer sizes to reduce drain frequency - int bufsize = 1024 * 1024; - ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); - ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); - - auto sock = std::make_unique(write_fd); + auto [sock, read_fd] = create_tcp_loopback(); auto helper = std::make_unique(std::move(sock)); helper->init(); - return {std::move(helper), read_fd}; } diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp index 39fa3246ac..d904dd7750 100644 --- a/tests/benchmarks/components/api/bench_send_sensor_state.cpp +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -2,12 +2,9 @@ #if defined(USE_API_PLAINTEXT) && defined(USE_SENSOR) #include -#include -#include -#include -#include #include +#include "bench_helpers.h" #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_server.h" #include "esphome/components/sensor/sensor.h" @@ -23,56 +20,12 @@ namespace esphome::api::benchmarks { static constexpr int kInnerIterations = 2000; -// Helper to drain accumulated data from the read side of a socket -// to prevent the write side from blocking. -static void drain_socket(int fd) { - char buf[65536]; - while (::read(fd, buf, sizeof(buf)) > 0) { - } -} - // Helper to create a TCP loopback connection with an APIConnection. // Returns the connection and the read-side fd for draining. static std::pair, int> create_api_connection() { - // Create a TCP listener on loopback - int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); - int opt = 1; - ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); - - struct sockaddr_in addr {}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; // OS-assigned port - ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); - ::listen(listen_fd, 1); - - // Get the assigned port - socklen_t addr_len = sizeof(addr); - ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); - - // Connect from client side - int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); - ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); - - // Accept on server side (this is our read fd) - int read_fd = ::accept(listen_fd, nullptr, nullptr); - ::close(listen_fd); - - // Make both ends non-blocking - int flags = ::fcntl(write_fd, F_GETFL, 0); - ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); - flags = ::fcntl(read_fd, F_GETFL, 0); - ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); - - // Increase socket buffer sizes to reduce drain frequency - int bufsize = 1024 * 1024; - ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); - ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); - - auto sock = std::make_unique(write_fd); + auto [sock, read_fd] = create_tcp_loopback(); auto conn = std::make_unique(std::move(sock), global_api_server); conn->start(); - return {std::move(conn), read_fd}; } From 3a4a88df294845fc657a2046e18a7755dea80975 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 08:43:38 -1000 Subject: [PATCH 03/19] [api] Add warm batch benchmark and bench_clear_batch friend Add SendSensorState_Batch_Warm that pre-warms the deferred batch vector before benchmarking to isolate steady-state cost from allocation. --- esphome/components/api/api_connection.h | 2 + .../api/bench_send_sensor_state.cpp | 39 ++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 5071b82752..a71b8e4d90 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -46,12 +46,14 @@ static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, class APIConnection; void bench_enable_immediate_send(APIConnection *conn); // tests/benchmarks +void bench_clear_batch(APIConnection *conn); // tests/benchmarks class APIConnection final : public APIServerConnectionBase { public: friend class APIServer; friend class ListEntitiesIterator; friend void bench_enable_immediate_send(APIConnection *conn); + friend void bench_clear_batch(APIConnection *conn); APIConnection(std::unique_ptr socket, APIServer *parent); ~APIConnection(); diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp index d904dd7750..476cc4d42e 100644 --- a/tests/benchmarks/components/api/bench_send_sensor_state.cpp +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -11,8 +11,9 @@ namespace esphome::api { -// Friend function declared in APIConnection to enable immediate send path for benchmarking. +// Friend functions declared in APIConnection for benchmark access. void bench_enable_immediate_send(APIConnection *conn) { conn->flags_.should_try_send_immediately = true; } +void bench_clear_batch(APIConnection *conn) { conn->clear_batch_(); } } // namespace esphome::api @@ -65,11 +66,11 @@ static void SendSensorState_Immediate(benchmark::State &state) { } BENCHMARK(SendSensorState_Immediate); -// --- send_sensor_state: batch path (default for new connections) --- +// --- send_sensor_state: batch path (cold — first call allocates) --- // Measures: send_message_smart_ → schedule_message_ → deferred batch add. -// This is the default path until initial states are sent. +// Includes one-time vector allocation cost. -static void SendSensorState_Batch(benchmark::State &state) { +static void SendSensorState_Batch_Cold(benchmark::State &state) { auto [conn, read_fd] = create_api_connection(); TestSensor sensor; @@ -86,7 +87,35 @@ static void SendSensorState_Batch(benchmark::State &state) { ::close(read_fd); } -BENCHMARK(SendSensorState_Batch); +BENCHMARK(SendSensorState_Batch_Cold); + +// --- send_sensor_state: batch path (warm — buffer already allocated) --- +// Measures steady-state batch cost after the vector has been allocated +// and cleared at least once. This is the typical path during normal +// operation after the first batch has been processed. + +static void SendSensorState_Batch_Warm(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + // Warm up: send once to allocate, then clear to keep capacity + conn->send_sensor_state(&sensor); + bench_clear_batch(conn.get()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(SendSensorState_Batch_Warm); } // namespace esphome::api::benchmarks From f469a7ced29570d4b86742b591409c2b54415df6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 08:49:33 -1000 Subject: [PATCH 04/19] [api] Add process_batch benchmarks for single and multi-sensor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ProcessBatch_SingleSensor and ProcessBatch_5Sensors benchmarks that measure the full batch processing path: queue → process_batch_ → dispatch → encode → frame → TCP write. --- esphome/components/api/api_connection.h | 2 + .../api/bench_send_sensor_state.cpp | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index a71b8e4d90..841aa55423 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -47,6 +47,7 @@ static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, class APIConnection; void bench_enable_immediate_send(APIConnection *conn); // tests/benchmarks void bench_clear_batch(APIConnection *conn); // tests/benchmarks +void bench_process_batch(APIConnection *conn); // tests/benchmarks class APIConnection final : public APIServerConnectionBase { public: @@ -54,6 +55,7 @@ class APIConnection final : public APIServerConnectionBase { friend class ListEntitiesIterator; friend void bench_enable_immediate_send(APIConnection *conn); friend void bench_clear_batch(APIConnection *conn); + friend void bench_process_batch(APIConnection *conn); APIConnection(std::unique_ptr socket, APIServer *parent); ~APIConnection(); diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp index 476cc4d42e..e0d6336e38 100644 --- a/tests/benchmarks/components/api/bench_send_sensor_state.cpp +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -14,6 +14,7 @@ namespace esphome::api { // Friend functions declared in APIConnection for benchmark access. void bench_enable_immediate_send(APIConnection *conn) { conn->flags_.should_try_send_immediately = true; } void bench_clear_batch(APIConnection *conn) { conn->clear_batch_(); } +void bench_process_batch(APIConnection *conn) { conn->process_batch_(); } } // namespace esphome::api @@ -117,6 +118,80 @@ static void SendSensorState_Batch_Warm(benchmark::State &state) { } BENCHMARK(SendSensorState_Batch_Warm); +// --- process_batch_: single sensor state (encode + frame + write) --- +// Measures the deferred batch processing path: dispatch_message_ → +// try_send_sensor_state → fill + proto encode → send_buffer → frame write. +// This is the cost paid on the next loop() after batching. + +static void ProcessBatch_SingleSensor(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + // Warm up batch vector + conn->send_sensor_state(&sensor); + bench_process_batch(conn.get()); + drain_socket(read_fd); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + // Queue the sensor state, then process the batch + conn->send_sensor_state(&sensor); + bench_process_batch(conn.get()); + + if ((i & 0xFF) == 0) + drain_socket(read_fd); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(ProcessBatch_SingleSensor); + +// --- process_batch_: 5 different sensors --- +// Measures batch processing with multiple items queued. +// This exercises the multi-message path in process_batch_. + +static void ProcessBatch_5Sensors(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensors[5]; + for (int i = 0; i < 5; i++) { + char name[20]; + snprintf(name, sizeof(name), "sensor_%d", i); + sensors[i].configure(name); + sensors[i].publish_state(23.5f + static_cast(i)); + } + + // Warm up batch vector + for (auto &s : sensors) + conn->send_sensor_state(&s); + bench_process_batch(conn.get()); + drain_socket(read_fd); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + for (auto &s : sensors) + conn->send_sensor_state(&s); + bench_process_batch(conn.get()); + + if ((i & 0xFF) == 0) + drain_socket(read_fd); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(ProcessBatch_5Sensors); + } // namespace esphome::api::benchmarks #endif // USE_API_PLAINTEXT && USE_SENSOR From d717c7b46c25f80888693b0163a09466f4669dc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 08:54:30 -1000 Subject: [PATCH 05/19] [api] Use large socket buffers and remove mid-loop draining Increase socket buffer to 16MB so benchmarks never hit WOULD_BLOCK during an inner loop iteration. Remove per-iteration drain_socket calls that were adding noise and causing the immediate path to fall back to batching. Drain only between outer iterations. --- tests/benchmarks/components/api/bench_helpers.h | 5 +++-- .../components/api/bench_plaintext_frame.cpp | 6 ------ .../components/api/bench_send_sensor_state.cpp | 10 ---------- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/tests/benchmarks/components/api/bench_helpers.h b/tests/benchmarks/components/api/bench_helpers.h index 53987f0eab..c25fe57177 100644 --- a/tests/benchmarks/components/api/bench_helpers.h +++ b/tests/benchmarks/components/api/bench_helpers.h @@ -55,8 +55,9 @@ inline std::pair, int> create_tcp_loopback() { flags = ::fcntl(read_fd, F_GETFL, 0); ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); - // Increase socket buffer sizes to reduce drain frequency - int bufsize = 1024 * 1024; + // Use large socket buffers so benchmarks never hit WOULD_BLOCK + // during a single outer iteration (2000 × ~15 byte messages = ~30KB). + int bufsize = 16 * 1024 * 1024; ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 6347cf0a43..0caa50c748 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -49,9 +49,6 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) { msg.encode(writer); helper->write_protobuf_packet(SensorStateResponse::MESSAGE_TYPE, writer); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(helper.get()); @@ -96,9 +93,6 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { } helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span(messages, 5)); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(helper.get()); diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp index e0d6336e38..815d612049 100644 --- a/tests/benchmarks/components/api/bench_send_sensor_state.cpp +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -54,9 +54,6 @@ static void SendSensorState_Immediate(benchmark::State &state) { for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { conn->send_sensor_state(&sensor); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(conn.get()); @@ -137,12 +134,8 @@ static void ProcessBatch_SingleSensor(benchmark::State &state) { for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { - // Queue the sensor state, then process the batch conn->send_sensor_state(&sensor); bench_process_batch(conn.get()); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(conn.get()); @@ -179,9 +172,6 @@ static void ProcessBatch_5Sensors(benchmark::State &state) { for (auto &s : sensors) conn->send_sensor_state(&s); bench_process_batch(conn.get()); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(conn.get()); From c09d5d783cc478f73e7230cfeda101d00d307557 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:00:59 -1000 Subject: [PATCH 06/19] [api] Fix immediate benchmark to set batch_delay=0 should_send_immediately_ requires both should_try_send_immediately flag AND batch_delay==0. Without setting batch_delay to 0, all sends were falling back to the batch path. --- tests/benchmarks/components/api/bench_send_sensor_state.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp index 815d612049..815081374a 100644 --- a/tests/benchmarks/components/api/bench_send_sensor_state.cpp +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -46,6 +46,9 @@ class TestSensor : public sensor::Sensor { static void SendSensorState_Immediate(benchmark::State &state) { auto [conn, read_fd] = create_api_connection(); bench_enable_immediate_send(conn.get()); + // batch_delay must be 0 for should_send_immediately_ to return true + uint16_t saved_delay = global_api_server->get_batch_delay(); + global_api_server->set_batch_delay(0); TestSensor sensor; sensor.configure("test_sensor"); @@ -60,6 +63,7 @@ static void SendSensorState_Immediate(benchmark::State &state) { } state.SetItemsProcessed(state.iterations() * kInnerIterations); + global_api_server->set_batch_delay(saved_delay); ::close(read_fd); } BENCHMARK(SendSensorState_Immediate); From 9fb8594a16bb5b23090ed600dd11911c0cb35553 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:06:07 -1000 Subject: [PATCH 07/19] [api] Fix lint false positive on 'byte' in comment --- tests/benchmarks/components/api/bench_helpers.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/benchmarks/components/api/bench_helpers.h b/tests/benchmarks/components/api/bench_helpers.h index c25fe57177..04fe9324da 100644 --- a/tests/benchmarks/components/api/bench_helpers.h +++ b/tests/benchmarks/components/api/bench_helpers.h @@ -56,7 +56,7 @@ inline std::pair, int> create_tcp_loopback() { ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); // Use large socket buffers so benchmarks never hit WOULD_BLOCK - // during a single outer iteration (2000 × ~15 byte messages = ~30KB). + // during a single outer iteration (2000 × ~15B messages = ~30KB). int bufsize = 16 * 1024 * 1024; ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); From 34cbe0abe1c3ff2893a96a8ff92c5353d24a84e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:40:25 -1000 Subject: [PATCH 08/19] =?UTF-8?q?[api]=20Fix=20stale=20comment:=201MB=20?= =?UTF-8?q?=E2=86=92=2016MB=20socket=20buffers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/benchmarks/components/api/bench_helpers.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/benchmarks/components/api/bench_helpers.h b/tests/benchmarks/components/api/bench_helpers.h index 04fe9324da..73e51bce3d 100644 --- a/tests/benchmarks/components/api/bench_helpers.h +++ b/tests/benchmarks/components/api/bench_helpers.h @@ -23,7 +23,7 @@ inline void drain_socket(int fd) { // Create a TCP loopback socket pair. Returns the write-side Socket // (wrapped for ESPHome) and the raw read-side fd for draining. -// Both ends are non-blocking with 1MB buffers. +// Both ends are non-blocking with 16MB buffers. inline std::pair, int> create_tcp_loopback() { // Create a TCP listener on loopback int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); From d82cc0c08ab308fc1d47a272b9ed020ddcceb645 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:43:47 -1000 Subject: [PATCH 09/19] [api] Guard benchmark friend declarations with USE_BENCHMARK --- esphome/components/api/api_connection.h | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 841aa55423..4ce1335650 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -44,18 +44,22 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); +#ifdef USE_BENCHMARK class APIConnection; -void bench_enable_immediate_send(APIConnection *conn); // tests/benchmarks -void bench_clear_batch(APIConnection *conn); // tests/benchmarks -void bench_process_batch(APIConnection *conn); // tests/benchmarks +void bench_enable_immediate_send(APIConnection *conn); +void bench_clear_batch(APIConnection *conn); +void bench_process_batch(APIConnection *conn); +#endif class APIConnection final : public APIServerConnectionBase { public: friend class APIServer; friend class ListEntitiesIterator; +#ifdef USE_BENCHMARK friend void bench_enable_immediate_send(APIConnection *conn); friend void bench_clear_batch(APIConnection *conn); friend void bench_process_batch(APIConnection *conn); +#endif APIConnection(std::unique_ptr socket, APIServer *parent); ~APIConnection(); From 2b8d4af1e7d70eb2cf41565bc04901938806a8ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:46:04 -1000 Subject: [PATCH 10/19] [api] Inline DeferredBatch::push_item to avoid call overhead push_item was defined out-of-line in the .cpp to avoid duplicate _M_realloc_insert instantiation, but the function call overhead on every add_item was visible in benchmarks. Move it inline since it's a one-liner wrapper around push_back. --- esphome/components/api/api_connection.cpp | 2 -- esphome/components/api/api_connection.h | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 79df85ada3..6bcaf52ba6 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,8 +2072,6 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -void __attribute__((flatten)) APIConnection::DeferredBatch::push_item(const BatchItem &item) { items.push_back(item); } - void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { // Check if we already have a message of this type for this entity diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4ce1335650..3187a6df05 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -647,8 +647,7 @@ class APIConnection final : public APIServerConnectionBase { uint8_t aux_data_index = AUX_DATA_UNUSED); // Add item to the front of the batch (for high priority messages like ping) void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); - // Single push_back site to avoid duplicate _M_realloc_insert instantiation - void push_item(const BatchItem &item); + void push_item(const BatchItem &item) { items.push_back(item); } // Clear all items void clear() { From 18b00fb3fcf3912ab660b54a2242a42292b05706 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:51:55 -1000 Subject: [PATCH 11/19] =?UTF-8?q?[api]=20Revert=20push=5Fitem=20inline=20?= =?UTF-8?q?=E2=80=94=20CodSpeed=20shows=2046%=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inlining push_item into add_item bloated the dedup loop, causing the compiler to make worse inlining decisions. Total time went from 44µs to 64.5µs. Restore out-of-line with __attribute__((flatten)) which keeps add_item's dedup loop tight while still inlining push_back's callees into push_item. --- esphome/components/api/api_connection.cpp | 2 ++ esphome/components/api/api_connection.h | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 6bcaf52ba6..79df85ada3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,6 +2072,8 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } +void __attribute__((flatten)) APIConnection::DeferredBatch::push_item(const BatchItem &item) { items.push_back(item); } + void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { // Check if we already have a message of this type for this entity diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3187a6df05..295346bb05 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -647,7 +647,10 @@ class APIConnection final : public APIServerConnectionBase { uint8_t aux_data_index = AUX_DATA_UNUSED); // Add item to the front of the batch (for high priority messages like ping) void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); - void push_item(const BatchItem &item) { items.push_back(item); } + // Out-of-line with flatten: inlines push_back callees into push_item, + // but keeps push_item itself as a call from add_item. This prevents + // the compiler from bloating add_item's dedup loop and degrading icache. + void push_item(const BatchItem &item); // Clear all items void clear() { From 44be29789ac300f7d257bd840e7850e21502ce46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:53:37 -1000 Subject: [PATCH 12/19] [api] Inline push_item to allow push_back to be inlined into caller The out-of-line push_item with __attribute__((flatten)) inlined push_back's callees into push_item, but push_item itself remained a function call barrier. Moving push_item inline lets the compiler inline push_back into the actual call sites (add_item, add_item_front). --- esphome/components/api/api_connection.cpp | 2 -- esphome/components/api/api_connection.h | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 79df85ada3..6bcaf52ba6 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,8 +2072,6 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -void __attribute__((flatten)) APIConnection::DeferredBatch::push_item(const BatchItem &item) { items.push_back(item); } - void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { // Check if we already have a message of this type for this entity diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 295346bb05..3187a6df05 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -647,10 +647,7 @@ class APIConnection final : public APIServerConnectionBase { uint8_t aux_data_index = AUX_DATA_UNUSED); // Add item to the front of the batch (for high priority messages like ping) void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); - // Out-of-line with flatten: inlines push_back callees into push_item, - // but keeps push_item itself as a call from add_item. This prevents - // the compiler from bloating add_item's dedup loop and degrading icache. - void push_item(const BatchItem &item); + void push_item(const BatchItem &item) { items.push_back(item); } // Clear all items void clear() { From 83f8a95e2875b06b6340cd9fc18a806d3e32a1ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:55:02 -1000 Subject: [PATCH 13/19] [api] Flatten add_item and add_item_front to inline push_back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add __attribute__((flatten)) to add_item and add_item_front so the compiler inlines push_item → push_back and all their callees directly into these functions, eliminating the function call barrier. --- esphome/components/api/api_connection.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 6bcaf52ba6..bbd6d4f049 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,8 +2072,8 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, - uint8_t aux_data_index) { +void __attribute__((flatten)) APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, + uint8_t estimated_size, uint8_t aux_data_index) { // Check if we already have a message of this type for this entity // This provides deduplication per entity/message_type combination // O(n) but optimized for RAM and not performance. @@ -2091,7 +2091,8 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_ this->push_item({entity, message_type, estimated_size, aux_data_index}); } -void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { +void __attribute__((flatten)) +APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { // Add high priority message and swap to front // This avoids expensive vector::insert which shifts all elements // Note: We only ever have one high-priority message at a time (ping OR disconnect) From b236d097342fed4be0152e59a20c70294688bc59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:56:44 -1000 Subject: [PATCH 14/19] [api] Move add_item/add_item_front inline and remove push_item Both are only called from one site each. Moving them inline lets the compiler inline push_back directly into the caller without a function call barrier. Removes the now-unnecessary push_item wrapper. --- esphome/components/api/api_connection.cpp | 32 ----------------------- esphome/components/api/api_connection.h | 24 ++++++++++++++--- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index bbd6d4f049..c882d48112 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,38 +2072,6 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -void __attribute__((flatten)) APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, - uint8_t estimated_size, uint8_t aux_data_index) { - // Check if we already have a message of this type for this entity - // This provides deduplication per entity/message_type combination - // O(n) but optimized for RAM and not performance. - // Skip deduplication for events - they are edge-triggered, every occurrence matters -#ifdef USE_EVENT - if (message_type != EventResponse::MESSAGE_TYPE) -#endif - { - for (const auto &item : items) { - if (item.entity == entity && item.message_type == message_type) - return; // Already queued - } - } - // No existing item found (or event), add new one - this->push_item({entity, message_type, estimated_size, aux_data_index}); -} - -void __attribute__((flatten)) -APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { - // Add high priority message and swap to front - // This avoids expensive vector::insert which shifts all elements - // Note: We only ever have one high-priority message at a time (ping OR disconnect) - // If we're disconnecting, pings are blocked, so this simple swap is sufficient - this->push_item({entity, message_type, estimated_size, AUX_DATA_UNUSED}); - if (items.size() > 1) { - // Swap the new high-priority item to the front - std::swap(items.front(), items.back()); - } -} - bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3187a6df05..972871b076 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -644,10 +644,28 @@ class APIConnection final : public APIServerConnectionBase { // Add item to the batch (with deduplication) void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, - uint8_t aux_data_index = AUX_DATA_UNUSED); + uint8_t aux_data_index = AUX_DATA_UNUSED) { + // Dedup: O(n) scan but optimized for RAM over performance + // Skip deduplication for events - they are edge-triggered, every occurrence matters +#ifdef USE_EVENT + if (message_type != EventResponse::MESSAGE_TYPE) +#endif + { + for (const auto &item : items) { + if (item.entity == entity && item.message_type == message_type) + return; // Already queued + } + } + items.push_back({entity, message_type, estimated_size, aux_data_index}); + } // Add item to the front of the batch (for high priority messages like ping) - void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); - void push_item(const BatchItem &item) { items.push_back(item); } + void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + // Swap to front avoids expensive vector::insert which shifts all elements + items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); + if (items.size() > 1) { + std::swap(items.front(), items.back()); + } + } // Clear all items void clear() { From 6d184222162e412fbba9b461bd147f2b0df7b535 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 09:57:29 -1000 Subject: [PATCH 15/19] [api] Use this-> for member access in inline methods --- esphome/components/api/api_connection.h | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 972871b076..776b67738c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -651,19 +651,19 @@ class APIConnection final : public APIServerConnectionBase { if (message_type != EventResponse::MESSAGE_TYPE) #endif { - for (const auto &item : items) { + for (const auto &item : this->items) { if (item.entity == entity && item.message_type == message_type) return; // Already queued } } - items.push_back({entity, message_type, estimated_size, aux_data_index}); + this->items.push_back({entity, message_type, estimated_size, aux_data_index}); } // Add item to the front of the batch (for high priority messages like ping) void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { // Swap to front avoids expensive vector::insert which shifts all elements - items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); - if (items.size() > 1) { - std::swap(items.front(), items.back()); + this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); + if (this->items.size() > 1) { + std::swap(this->items.front(), this->items.back()); } } From 4f385fc2ec9bb70a68890d68fa1ed17dcf1fea2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 10:07:09 -1000 Subject: [PATCH 16/19] [api] Keep add_item_front out-of-line to avoid bloating cold callers add_item_front is only called from on_shutdown and check_keepalive_ which are cold paths. Keeping it out-of-line prevents inlining push_back into those callers. --- esphome/components/api/api_connection.cpp | 8 ++++++++ esphome/components/api/api_connection.h | 9 ++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c882d48112..a6e4e8bf9c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,6 +2072,14 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } +void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + // Swap to front avoids expensive vector::insert which shifts all elements + this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); + if (this->items.size() > 1) { + std::swap(this->items.front(), this->items.back()); + } +} + bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 776b67738c..c39ae47bc9 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -659,13 +659,8 @@ class APIConnection final : public APIServerConnectionBase { this->items.push_back({entity, message_type, estimated_size, aux_data_index}); } // Add item to the front of the batch (for high priority messages like ping) - void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { - // Swap to front avoids expensive vector::insert which shifts all elements - this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); - if (this->items.size() > 1) { - std::swap(this->items.front(), this->items.back()); - } - } + // Out-of-line: called from cold paths (on_shutdown, check_keepalive_) + void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); // Clear all items void clear() { From 475927e3c47409510bf6ab3bc75c51787fb81af9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 10:08:24 -1000 Subject: [PATCH 17/19] =?UTF-8?q?[api]=20Revert=20add=5Fitem=5Ffront=20bac?= =?UTF-8?q?k=20to=20inline=20=E2=80=94=20single=20call=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esphome/components/api/api_connection.cpp | 8 -------- esphome/components/api/api_connection.h | 9 +++++++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a6e4e8bf9c..c882d48112 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,14 +2072,6 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { - // Swap to front avoids expensive vector::insert which shifts all elements - this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); - if (this->items.size() > 1) { - std::swap(this->items.front(), this->items.back()); - } -} - bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index c39ae47bc9..776b67738c 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -659,8 +659,13 @@ class APIConnection final : public APIServerConnectionBase { this->items.push_back({entity, message_type, estimated_size, aux_data_index}); } // Add item to the front of the batch (for high priority messages like ping) - // Out-of-line: called from cold paths (on_shutdown, check_keepalive_) - void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); + void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + // Swap to front avoids expensive vector::insert which shifts all elements + this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); + if (this->items.size() > 1) { + std::swap(this->items.front(), this->items.back()); + } + } // Clear all items void clear() { From e2e2b7d699bd5a471a43053edc198c8bb7743383 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 10:09:20 -1000 Subject: [PATCH 18/19] [api] Move schedule_message_front_ out-of-line to avoid cold path bloat schedule_message_front_ is only called from cold paths (on_shutdown, check_keepalive_). Moving it out-of-line prevents add_item_front and push_back from being inlined into those callers. --- esphome/components/api/api_connection.cpp | 5 +++++ esphome/components/api/api_connection.h | 6 ++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index c882d48112..a3e899b32a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2072,6 +2072,11 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } +bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + this->deferred_batch_.add_item_front(entity, message_type, estimated_size); + return this->schedule_batch_(); +} + bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 776b67738c..433b13bf2a 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -797,10 +797,8 @@ class APIConnection final : public APIServerConnectionBase { } // Helper function to schedule a high priority message at the front of the batch - bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { - this->deferred_batch_.add_item_front(entity, message_type, estimated_size); - return this->schedule_batch_(); - } + // Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths + bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); // Helper function to log client messages with name and peername void log_client_(int level, const LogString *message); From 702093fae6eeb461e39658df88e148c2e3b9284e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 10:14:08 -1000 Subject: [PATCH 19/19] =?UTF-8?q?[api]=20Inline=20get=5Fbatch=5Fdelay=5Fms?= =?UTF-8?q?=5F=20=E2=80=94=20trivial=20forwarder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esphome/components/api/api_connection.cpp | 2 -- esphome/components/api/api_connection.h | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a3e899b32a..aa64ced64c 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -132,8 +132,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa #endif } -uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } - void APIConnection::start() { this->last_traffic_ = App.get_loop_component_start_time(); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 433b13bf2a..13d5273ecb 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -730,7 +730,7 @@ class APIConnection final : public APIServerConnectionBase { ActiveIterator active_iterator_{ActiveIterator::NONE}; // Total: 2 (flags) + 2 + 2 + 1 = 7 bytes, then 1 byte padding to next 4-byte boundary - uint32_t get_batch_delay_ms_() const; + uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4