From 9bd5d91e61e6cf6e61cab0d06adc785f51b2b099 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 15:53:58 -1000 Subject: [PATCH 1/9] [benchmark] Add plaintext API frame write benchmarks --- .../components/api/bench_plaintext_frame.cpp | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tests/benchmarks/components/api/bench_plaintext_frame.cpp diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp new file mode 100644 index 00000000000..ea1f890989b --- /dev/null +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -0,0 +1,124 @@ +#include +#include +#include +#include + +#include "esphome/components/api/api_frame_helper_plaintext.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Helper to drain accumulated data from the read side of a socketpair +// 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 non-blocking socketpair with an APIPlaintextFrameHelper +// on the write end. Returns the helper and the read-side fd. +static std::pair, int> create_plaintext_helper() { + int fds[2]; + ::socketpair(AF_UNIX, SOCK_STREAM, 0, fds); + + // Make both ends non-blocking + int flags0 = ::fcntl(fds[0], F_GETFL, 0); + ::fcntl(fds[0], F_SETFL, flags0 | O_NONBLOCK); + int flags1 = ::fcntl(fds[1], F_GETFL, 0); + ::fcntl(fds[1], F_SETFL, flags1 | O_NONBLOCK); + + // Increase socket buffer sizes to reduce drain frequency + int bufsize = 1024 * 1024; + ::setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); + ::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); + + auto sock = std::make_unique(fds[0]); + auto helper = std::make_unique(std::move(sock)); + helper->init(); + + return {std::move(helper), fds[1]}; +} + +// --- Write a single SensorStateResponse through plaintext framing --- +// Measures the full write path: header construction, varint encoding, +// iovec assembly, and socket write. + +static void PlaintextFrame_WriteSensorState(benchmark::State &state) { + auto [helper, read_fd] = create_plaintext_helper(); + uint8_t padding = helper->frame_header_padding(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + SensorStateResponse msg; + msg.key = 0x12345678; + msg.state = 23.5f; + msg.missing_state = false; + + uint32_t size = msg.calculate_size(); + buffer.resize(padding + size); + ProtoWriteBuffer writer(&buffer, padding); + msg.encode(writer); + + helper->write_protobuf_packet(38, writer); + + if ((i & 0xFF) == 0) + drain_socket(read_fd); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(helper.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(PlaintextFrame_WriteSensorState); + +// --- Write a batch of 5 SensorStateResponses in one call --- +// Measures batched write: multiple messages assembled into one writev. + +static void PlaintextFrame_WriteBatch5(benchmark::State &state) { + auto [helper, read_fd] = create_plaintext_helper(); + uint8_t padding = helper->frame_header_padding(); + uint8_t footer = helper->frame_footer_size(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + StaticVector messages; + + for (int j = 0; j < 5; j++) { + uint16_t offset = buffer.size(); + SensorStateResponse msg; + msg.key = static_cast(j); + msg.state = 23.5f + static_cast(j); + msg.missing_state = false; + + uint32_t size = msg.calculate_size(); + buffer.resize(offset + padding + size + footer); + ProtoWriteBuffer writer(&buffer, offset + padding); + msg.encode(writer); + + messages.push_back(MessageInfo(38, offset, size)); + } + + helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), + std::span(messages.data(), messages.size())); + + if ((i & 0xFF) == 0) + drain_socket(read_fd); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(helper.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(PlaintextFrame_WriteBatch5); + +} // namespace esphome::api::benchmarks From 328ef819e6d4b4445bacb708cad5a425ec2a0f27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 16:02:44 -1000 Subject: [PATCH 2/9] [benchmark] Fix plaintext frame benchmark build: enable api codegen --- tests/benchmarks/components/api/__init__.py | 8 ++++++++ tests/benchmarks/components/api/bench_plaintext_frame.cpp | 5 +++++ 2 files changed, 13 insertions(+) create mode 100644 tests/benchmarks/components/api/__init__.py diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py new file mode 100644 index 00000000000..c0b5788993b --- /dev/null +++ b/tests/benchmarks/components/api/__init__.py @@ -0,0 +1,8 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # api must run its to_code during benchmark builds because it + # defines USE_API, USE_API_PLAINTEXT, and USE_API_NOISE which + # are needed by the frame helper headers. + manifest.enable_codegen() diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index ea1f890989b..85de6ee347d 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -1,3 +1,6 @@ +#include "esphome/core/defines.h" +#ifdef USE_API_PLAINTEXT + #include #include #include @@ -122,3 +125,5 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { BENCHMARK(PlaintextFrame_WriteBatch5); } // namespace esphome::api::benchmarks + +#endif // USE_API_PLAINTEXT From bc2654379bacc89b952145f189ec9897d5d7b935 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 16:05:25 -1000 Subject: [PATCH 3/9] [benchmark] Fix api init: use build flag instead of enable_codegen --- tests/benchmarks/components/api/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index c0b5788993b..0f18c1b1082 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -1,8 +1,12 @@ +import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # api must run its to_code during benchmark builds because it - # defines USE_API, USE_API_PLAINTEXT, and USE_API_NOISE which - # are needed by the frame helper headers. - manifest.enable_codegen() + # Add USE_API_PLAINTEXT so frame helper headers compile. + # We cannot use enable_codegen() because the full api to_code + # brings in socket/network setup that fails in benchmark builds. + async def to_code(config): + cg.add_define("USE_API_PLAINTEXT") + + manifest.to_code = to_code From cf7b63da6005bad068ac8c68decd8d6c5e736591 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 16:07:30 -1000 Subject: [PATCH 4/9] [benchmark] Add both USE_API_PLAINTEXT and USE_API_NOISE defines --- tests/benchmarks/components/api/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0f18c1b1082..069df670c10 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -1,12 +1,17 @@ import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride +# Keep in sync with esphome/components/api/__init__.py +NOISE_C_LIB = ("esphome/noise-c", "0.1.11") + def override_manifest(manifest: ComponentManifestOverride) -> None: - # Add USE_API_PLAINTEXT so frame helper headers compile. - # We cannot use enable_codegen() because the full api to_code - # brings in socket/network setup that fails in benchmark builds. + # Add defines for frame helper headers without running the full api + # to_code (which brings in socket/network setup that fails in + # benchmark builds). async def to_code(config): cg.add_define("USE_API_PLAINTEXT") + cg.add_define("USE_API_NOISE") + cg.add_library(*NOISE_C_LIB) manifest.to_code = to_code From 4bf84b6c61081b6cc978d30d133633736e0deeb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Mar 2026 16:21:31 -1000 Subject: [PATCH 5/9] [benchmark] Enable codegen for api dependency chain (socket, network, mdns) --- tests/benchmarks/components/api/__init__.py | 16 +++------------- .../components/api/bench_plaintext_frame.cpp | 7 +++---- tests/benchmarks/components/mdns/__init__.py | 5 +++++ tests/benchmarks/components/network/__init__.py | 5 +++++ tests/benchmarks/components/socket/__init__.py | 7 +++++++ 5 files changed, 23 insertions(+), 17 deletions(-) create mode 100644 tests/benchmarks/components/mdns/__init__.py create mode 100644 tests/benchmarks/components/network/__init__.py create mode 100644 tests/benchmarks/components/socket/__init__.py diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 069df670c10..0687c3f87fb 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -1,17 +1,7 @@ -import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride -# Keep in sync with esphome/components/api/__init__.py -NOISE_C_LIB = ("esphome/noise-c", "0.1.11") - def override_manifest(manifest: ComponentManifestOverride) -> None: - # Add defines for frame helper headers without running the full api - # to_code (which brings in socket/network setup that fails in - # benchmark builds). - async def to_code(config): - cg.add_define("USE_API_PLAINTEXT") - cg.add_define("USE_API_NOISE") - cg.add_library(*NOISE_C_LIB) - - manifest.to_code = to_code + # api must run its to_code to define USE_API, USE_API_PLAINTEXT, + # and add the noise-c library dependency. + manifest.enable_codegen() diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 85de6ee347d..54e9ac84092 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -92,7 +92,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { APIBuffer buffer; - StaticVector messages; + MessageInfo messages[5] = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}}; for (int j = 0; j < 5; j++) { uint16_t offset = buffer.size(); @@ -106,11 +106,10 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { ProtoWriteBuffer writer(&buffer, offset + padding); msg.encode(writer); - messages.push_back(MessageInfo(38, offset, size)); + messages[j] = MessageInfo(38, offset, size); } - helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), - std::span(messages.data(), messages.size())); + helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span(messages, 5)); if ((i & 0xFF) == 0) drain_socket(read_fd); diff --git a/tests/benchmarks/components/mdns/__init__.py b/tests/benchmarks/components/mdns/__init__.py new file mode 100644 index 00000000000..b08f67a0956 --- /dev/null +++ b/tests/benchmarks/components/mdns/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/network/__init__.py b/tests/benchmarks/components/network/__init__.py new file mode 100644 index 00000000000..b08f67a0956 --- /dev/null +++ b/tests/benchmarks/components/network/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/socket/__init__.py b/tests/benchmarks/components/socket/__init__.py new file mode 100644 index 00000000000..7a20f9f2300 --- /dev/null +++ b/tests/benchmarks/components/socket/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # socket must run its to_code to define USE_SOCKET_IMPL_BSD_SOCKETS + # which is needed by the api frame helper benchmarks. + manifest.enable_codegen() From 8dd69207ea09f8ca8c2d90d9437c7396ae0dc2aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:24:56 +0000 Subject: [PATCH 6/9] Bump aioesphomeapi from 44.6.2 to 44.7.0 (#15052) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 10e56c3b49d..2e09e2ed994 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.6.2 +aioesphomeapi==44.7.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 135c599561765f0a7fa4e3b74aae885b0b52fcd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Mar 2026 08:48:15 -1000 Subject: [PATCH 7/9] [benchmark] Pre-init APIBuffer to 1460 bytes in plaintext frame benchmarks Avoid benchmarking heap allocation by pre-reserving the buffer to typical TCP MSS size and reusing it across iterations, matching real-world usage where the buffer persists across writes. --- .../components/api/bench_plaintext_frame.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 54e9ac84092..391361fb243 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -54,9 +54,14 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) { auto [helper, read_fd] = create_plaintext_helper(); uint8_t padding = helper->frame_header_padding(); + // Pre-init buffer to typical TCP MSS size to avoid benchmarking + // heap allocation — in real use the buffer is reused across writes. + APIBuffer buffer; + buffer.reserve(1460); + for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { - APIBuffer buffer; + buffer.clear(); SensorStateResponse msg; msg.key = 0x12345678; msg.state = 23.5f; @@ -89,9 +94,14 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { uint8_t padding = helper->frame_header_padding(); uint8_t footer = helper->frame_footer_size(); + // Pre-init buffer to typical TCP MSS size to avoid benchmarking + // heap allocation — in real use the buffer is reused across writes. + APIBuffer buffer; + buffer.reserve(1460); + for (auto _ : state) { for (int i = 0; i < kInnerIterations; i++) { - APIBuffer buffer; + buffer.clear(); MessageInfo messages[5] = {{0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}, {0, 0, 0}}; for (int j = 0; j < 5; j++) { From 44e1a86819e2ddcc0e31b93869b61719813b1b20 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Mar 2026 09:02:17 -1000 Subject: [PATCH 8/9] [benchmark] Use TCP loopback sockets and correct message type Use real TCP sockets instead of AF_UNIX socketpair so TCP_NODELAY succeeds during init() and the benchmark exercises the full write path. Replace hardcoded message type 38 with SensorStateResponse::MESSAGE_TYPE. --- .../components/api/bench_plaintext_frame.cpp | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 391361fb243..79bffaf9534 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include @@ -14,7 +16,7 @@ namespace esphome::api::benchmarks { static constexpr int kInnerIterations = 2000; -// Helper to drain accumulated data from the read side of a socketpair +// 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]; @@ -22,28 +24,50 @@ static void drain_socket(int fd) { } } -// Helper to create a non-blocking socketpair with an APIPlaintextFrameHelper +// 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() { - int fds[2]; - ::socketpair(AF_UNIX, SOCK_STREAM, 0, fds); + // 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 flags0 = ::fcntl(fds[0], F_GETFL, 0); - ::fcntl(fds[0], F_SETFL, flags0 | O_NONBLOCK); - int flags1 = ::fcntl(fds[1], F_GETFL, 0); - ::fcntl(fds[1], F_SETFL, flags1 | O_NONBLOCK); + 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(fds[0], SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); - ::setsockopt(fds[1], SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); + ::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(fds[0]); + auto sock = std::make_unique(write_fd); auto helper = std::make_unique(std::move(sock)); helper->init(); - return {std::move(helper), fds[1]}; + return {std::move(helper), read_fd}; } // --- Write a single SensorStateResponse through plaintext framing --- @@ -72,7 +96,7 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) { ProtoWriteBuffer writer(&buffer, padding); msg.encode(writer); - helper->write_protobuf_packet(38, writer); + helper->write_protobuf_packet(SensorStateResponse::MESSAGE_TYPE, writer); if ((i & 0xFF) == 0) drain_socket(read_fd); @@ -116,7 +140,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { ProtoWriteBuffer writer(&buffer, offset + padding); msg.encode(writer); - messages[j] = MessageInfo(38, offset, size); + messages[j] = MessageInfo(SensorStateResponse::MESSAGE_TYPE, offset, size); } helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span(messages, 5)); From 21ca02dd3d7cbd1848e7348ea4dae194f66f420b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Mar 2026 09:23:46 -1000 Subject: [PATCH 9/9] [api] Peel first iteration of write_protobuf_messages for single-message fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-message case (via write_protobuf_packet) is the most common path. Peeling the first loop iteration and outlining the multi-message batch path avoids the ~300-byte StaticVector stack allocation on the hot path. Plaintext write_protobuf_messages: - Stack frame: 352 → 64 bytes - Code size: 246 → 127 bytes Noise write_protobuf_messages: - Extracted encrypt_noise_message_ helper for reuse - Same peeling pattern with outlined batch path --- .../components/api/api_frame_helper_noise.cpp | 108 ++++++++------ .../components/api/api_frame_helper_noise.h | 2 + .../api/api_frame_helper_plaintext.cpp | 132 +++++++++++------- .../api/api_frame_helper_plaintext.h | 1 + 4 files changed, 146 insertions(+), 97 deletions(-) diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 78e87793fc8..5b60cee143a 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -452,6 +452,61 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = type; return APIError::OK; } +// Encrypt a single noise message in place and populate the iovec. +// Returns APIError::OK on success. +APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, const MessageInfo &msg, + struct iovec &iov_out) { + // Write noise header + buf_start[0] = 0x01; // indicator + // buf_start[1], buf_start[2] to be set after encryption + + // Write message header (to be encrypted) + constexpr uint8_t msg_offset = 3; + buf_start[msg_offset] = static_cast(msg.message_type >> 8); // type high byte + buf_start[msg_offset + 1] = static_cast(msg.message_type); // type low byte + buf_start[msg_offset + 2] = static_cast(msg.payload_size >> 8); // data_len high byte + buf_start[msg_offset + 3] = static_cast(msg.payload_size); // data_len low byte + // payload data is already in the buffer starting at offset + 7 + + // Encrypt the message in place + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + msg.payload_size, 4 + msg.payload_size + frame_footer_size_); + + int err = noise_cipherstate_encrypt(send_cipher_, &mbuf); + APIError aerr = handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); + if (aerr != APIError::OK) + return aerr; + + // Fill in the encrypted size + buf_start[1] = static_cast(mbuf.size >> 8); + buf_start[2] = static_cast(mbuf.size); + + // Populate iovec for this encrypted message + size_t msg_len = static_cast(3 + mbuf.size); // indicator + size + encrypted data + iov_out = {buf_start, msg_len}; + return APIError::OK; +} + +// Outlined multi-message path to keep the single-message fast path's stack frame small. +APIError __attribute__((noinline)) +APINoiseFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { + StaticVector iovs; + uint16_t total_write_len = 0; + + for (const auto &msg : messages) { + uint8_t *buf_start = buffer_data + msg.offset; + struct iovec iov; + APIError aerr = this->encrypt_noise_message_(buf_start, msg, iov); + if (aerr != APIError::OK) + return aerr; + iovs.push_back(iov); + total_write_len += iov.iov_len; + } + + return this->write_raw_(iovs.data(), iovs.size(), total_write_len); +} + APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { APIError aerr = this->check_data_state_(); if (aerr != APIError::OK) @@ -463,54 +518,19 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s uint8_t *buffer_data = buffer.get_buffer()->data(); - // Stack-allocated iovec array - no heap allocation - StaticVector iovs; - uint16_t total_write_len = 0; - - // We need to encrypt each message in place - for (const auto &msg : messages) { - // The buffer already has padding at offset - uint8_t *buf_start = buffer_data + msg.offset; - - // Write noise header - buf_start[0] = 0x01; // indicator - // buf_start[1], buf_start[2] to be set after encryption - - // Write message header (to be encrypted) - constexpr uint8_t msg_offset = 3; - buf_start[msg_offset] = static_cast(msg.message_type >> 8); // type high byte - buf_start[msg_offset + 1] = static_cast(msg.message_type); // type low byte - buf_start[msg_offset + 2] = static_cast(msg.payload_size >> 8); // data_len high byte - buf_start[msg_offset + 3] = static_cast(msg.payload_size); // data_len low byte - // payload data is already in the buffer starting at offset + 7 - - // Make sure we have space for MAC - // The buffer should already have been sized appropriately - - // Encrypt the message in place - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_inout(mbuf, buf_start + msg_offset, 4 + msg.payload_size, - 4 + msg.payload_size + frame_footer_size_); - - int err = noise_cipherstate_encrypt(send_cipher_, &mbuf); - APIError aerr = - handle_noise_error_(err, LOG_STR("noise_cipherstate_encrypt"), APIError::CIPHERSTATE_ENCRYPT_FAILED); + if (messages.size() == 1) [[likely]] { + // Peeled first iteration: single-message case (most common path via write_protobuf_packet) + // avoids StaticVector stack allocation and loop overhead + const auto &first = messages[0]; + struct iovec iov; + aerr = this->encrypt_noise_message_(buffer_data + first.offset, first, iov); if (aerr != APIError::OK) return aerr; - - // Fill in the encrypted size - buf_start[1] = static_cast(mbuf.size >> 8); - buf_start[2] = static_cast(mbuf.size); - - // Add iovec for this encrypted message - size_t msg_len = static_cast(3 + mbuf.size); // indicator + size + encrypted data - iovs.push_back({buf_start, msg_len}); - total_write_len += msg_len; + return this->write_raw_(&iov, 1, static_cast(iov.iov_len)); } - // Send all encrypted messages in one writev call - return this->write_raw_(iovs.data(), iovs.size(), total_write_len); + // Multiple messages: outlined to avoid large stack frame on single-message path + return this->write_protobuf_messages_batch_(buffer_data, messages); } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index a6b17ff3b92..7a81d2032e5 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -28,6 +28,8 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_(); APIError try_read_frame_(); APIError write_frame_(const uint8_t *data, uint16_t len); + APIError encrypt_noise_message_(uint8_t *buf_start, const MessageInfo &msg, struct iovec &iov_out); + APIError write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages); APIError init_handshake_(); APIError check_handshake_finished_(); void send_explicit_handshake_reject_(const LogString *reason); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 9e669b31ee2..64c8882afa1 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -237,6 +237,74 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = this->rx_header_parsed_type_; return APIError::OK; } +// Write plaintext header into pre-allocated padding before payload. +// Returns pointer to start of frame (header + payload are contiguous). +static inline uint8_t *write_plaintext_header(uint8_t *buf_start, const MessageInfo &msg, + uint8_t frame_header_padding) { + // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead + uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE + ? 1 + : (msg.payload_size < ProtoSize::VARINT_THRESHOLD_2_BYTE ? 2 : 3); + uint8_t type_varint_len = msg.message_type < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 : 2; + uint8_t total_header_len = 1 + size_varint_len + type_varint_len; + + // Calculate where to start writing the header + // The header starts at the latest possible position to minimize unused padding + // + // Example 1 (small values): total_header_len = 3, header_offset = 6 - 3 = 3 + // [0-2] - Unused padding + // [3] - 0x00 indicator byte + // [4] - Payload size varint (1 byte, for sizes 0-127) + // [5] - Message type varint (1 byte, for types 0-127) + // [6...] - Actual payload data + // + // Example 2 (medium values): total_header_len = 4, header_offset = 6 - 4 = 2 + // [0-1] - Unused padding + // [2] - 0x00 indicator byte + // [3-4] - Payload size varint (2 bytes, for sizes 128-16383) + // [5] - Message type varint (1 byte, for types 0-127) + // [6...] - Actual payload data + // + // Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0 + // [0] - 0x00 indicator byte + // [1-3] - Payload size varint (3 bytes, for sizes 16384-65535) + // [4-5] - Message type varint (2 bytes, for types 128-16383) + // [6...] - Actual payload data + // + // The message starts at offset + frame_header_padding + // So we write the header starting at offset + frame_header_padding - total_header_len + uint32_t header_offset = frame_header_padding - total_header_len; + + // Write the plaintext header + buf_start[header_offset] = 0x00; // indicator + + // Encode varints directly into buffer + encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1); + encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len); + + return buf_start + header_offset; +} + +// Outlined multi-message path to keep the single-message fast path's stack frame small. +// The StaticVector would force a ~300-byte stack frame +// even when only sending one message if it were in the same function. +APIError __attribute__((noinline)) +APIPlaintextFrameHelper::write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages) { + StaticVector iovs; + uint16_t total_write_len = 0; + const uint8_t padding = frame_header_padding_; + + for (const auto &msg : messages) { + uint8_t *msg_start = write_plaintext_header(buffer_data + msg.offset, msg, padding); + uint8_t msg_header_len = static_cast((buffer_data + msg.offset + padding) - msg_start); + size_t msg_len = static_cast(msg_header_len + msg.payload_size); + iovs.push_back({msg_start, msg_len}); + total_write_len += msg_len; + } + + return write_raw_(iovs.data(), iovs.size(), total_write_len); +} + APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) { APIError aerr = this->check_data_state_(); @@ -249,61 +317,19 @@ APIError APIPlaintextFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffe uint8_t *buffer_data = buffer.get_buffer()->data(); - // Stack-allocated iovec array - no heap allocation - StaticVector iovs; - uint16_t total_write_len = 0; - - for (const auto &msg : messages) { - // Calculate varint sizes for header layout using inline ternary to avoid varint_slow call overhead - uint8_t size_varint_len = msg.payload_size < ProtoSize::VARINT_THRESHOLD_1_BYTE - ? 1 - : (msg.payload_size < ProtoSize::VARINT_THRESHOLD_2_BYTE ? 2 : 3); - uint8_t type_varint_len = msg.message_type < ProtoSize::VARINT_THRESHOLD_1_BYTE ? 1 : 2; - uint8_t total_header_len = 1 + size_varint_len + type_varint_len; - - // Calculate where to start writing the header - // The header starts at the latest possible position to minimize unused padding - // - // Example 1 (small values): total_header_len = 3, header_offset = 6 - 3 = 3 - // [0-2] - Unused padding - // [3] - 0x00 indicator byte - // [4] - Payload size varint (1 byte, for sizes 0-127) - // [5] - Message type varint (1 byte, for types 0-127) - // [6...] - Actual payload data - // - // Example 2 (medium values): total_header_len = 4, header_offset = 6 - 4 = 2 - // [0-1] - Unused padding - // [2] - 0x00 indicator byte - // [3-4] - Payload size varint (2 bytes, for sizes 128-16383) - // [5] - Message type varint (1 byte, for types 0-127) - // [6...] - Actual payload data - // - // Example 3 (large values): total_header_len = 6, header_offset = 6 - 6 = 0 - // [0] - 0x00 indicator byte - // [1-3] - Payload size varint (3 bytes, for sizes 16384-65535) - // [4-5] - Message type varint (2 bytes, for types 128-16383) - // [6...] - Actual payload data - // - // The message starts at offset + frame_header_padding_ - // So we write the header starting at offset + frame_header_padding_ - total_header_len - uint8_t *buf_start = buffer_data + msg.offset; - uint32_t header_offset = frame_header_padding_ - total_header_len; - - // Write the plaintext header - buf_start[header_offset] = 0x00; // indicator - - // Encode varints directly into buffer - encode_varint_to_buffer(msg.payload_size, buf_start + header_offset + 1); - encode_varint_to_buffer(msg.message_type, buf_start + header_offset + 1 + size_varint_len); - - // Add iovec for this message (header + payload) - size_t msg_len = static_cast(total_header_len + msg.payload_size); - iovs.push_back({buf_start + header_offset, msg_len}); - total_write_len += msg_len; + if (messages.size() == 1) [[likely]] { + // Peeled first iteration: single-message case (most common path via write_protobuf_packet) + // avoids StaticVector stack allocation and loop overhead + const auto &first = messages[0]; + uint8_t *first_start = write_plaintext_header(buffer_data + first.offset, first, frame_header_padding_); + uint8_t first_header_len = static_cast((buffer_data + first.offset + frame_header_padding_) - first_start); + size_t first_len = static_cast(first_header_len + first.payload_size); + struct iovec iov = {first_start, first_len}; + return write_raw_(&iov, 1, static_cast(first_len)); } - // Send all messages in one writev call - return write_raw_(iovs.data(), iovs.size(), total_write_len); + // Multiple messages: outlined to avoid large stack frame on single-message path + return write_protobuf_messages_batch_(buffer_data, messages); } } // namespace esphome::api diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index f8161c039d3..84311cb93a7 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -23,6 +23,7 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { protected: APIError try_read_frame_(); + APIError write_protobuf_messages_batch_(uint8_t *buffer_data, std::span messages); // Group 2-byte aligned types uint16_t rx_header_parsed_type_ = 0;