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; 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 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..79bffaf9534 --- /dev/null +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -0,0 +1,162 @@ +#include "esphome/core/defines.h" +#ifdef USE_API_PLAINTEXT + +#include +#include +#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 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 helper = std::make_unique(std::move(sock)); + helper->init(); + + return {std::move(helper), read_fd}; +} + +// --- 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(); + + // 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++) { + buffer.clear(); + 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(SensorStateResponse::MESSAGE_TYPE, 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(); + + // 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++) { + 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++) { + 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[j] = MessageInfo(SensorStateResponse::MESSAGE_TYPE, offset, size); + } + + 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()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(PlaintextFrame_WriteBatch5); + +} // namespace esphome::api::benchmarks + +#endif // USE_API_PLAINTEXT