From 777e162070d089113aaf973804c11aab54948755 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 17:08:52 -1000 Subject: [PATCH 01/11] [benchmark] Add BLE raw advertisement proto encode benchmarks Add CodSpeed benchmarks for BluetoothLERawAdvertisementsResponse (12 advertisements) covering calculate_size, encode, calc+encode, and fresh-buffer paths. Includes a lightweight bluetooth_proxy stub header in tests/benchmarks/stubs/ so the api component can compile with USE_BLUETOOTH_PROXY on the host platform without pulling in ESP32 BLE dependencies. --- script/cpp_benchmark.py | 5 ++ tests/benchmarks/components/api/__init__.py | 14 +++ .../components/api/bench_proto_encode.cpp | 89 +++++++++++++++++++ .../bluetooth_proxy/bluetooth_proxy.h | 38 ++++++++ 4 files changed, 146 insertions(+) create mode 100644 tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index a54d3752df6..92faa05819a 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -21,6 +21,10 @@ BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" # Path to /tests/benchmarks/core (always included, not a component) CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" +# Stub headers for ESP32-only components (e.g. bluetooth_proxy) that +# allow benchmarks to compile on the host platform. +STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs" + PLATFORMIO_OPTIONS = { "build_unflags": [ "-Os", # remove default size-opt @@ -29,6 +33,7 @@ PLATFORMIO_OPTIONS = { "-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo) "-g", # debug symbols for profiling "-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish() + f"-I{STUBS_DIR}", # stub headers for ESP32-only components ], # Use deep+ LDF mode to ensure PlatformIO detects the benchmark # library dependency from nested includes. diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0687c3f87fb..eb86492964d 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -1,3 +1,4 @@ +import esphome.codegen as cg from tests.testing_helpers import ComponentManifestOverride @@ -5,3 +6,16 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # api must run its to_code to define USE_API, USE_API_PLAINTEXT, # and add the noise-c library dependency. manifest.enable_codegen() + + original_to_code = manifest.to_code + + async def to_code(config): + await original_to_code(config) + # Enable BLE proto message types for benchmarks. The real + # bluetooth_proxy component is ESP32-only; a lightweight stub + # header in tests/benchmarks/stubs/ satisfies the include. + cg.add_define("USE_BLUETOOTH_PROXY") + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3) + cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) + + manifest.to_code = to_code diff --git a/tests/benchmarks/components/api/bench_proto_encode.cpp b/tests/benchmarks/components/api/bench_proto_encode.cpp index 656c1e17dba..1e2efcd2813 100644 --- a/tests/benchmarks/components/api/bench_proto_encode.cpp +++ b/tests/benchmarks/components/api/bench_proto_encode.cpp @@ -295,4 +295,93 @@ static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) { } BENCHMARK(CalcAndEncode_DeviceInfoResponse_Fresh); +// --- BluetoothLERawAdvertisementsResponse (12 adverts, highest-volume BLE message) --- + +#ifdef USE_BLUETOOTH_PROXY + +static BluetoothLERawAdvertisementsResponse make_ble_raw_advs_12() { + static const uint8_t fake_adv_data[] = { + 0x02, 0x01, 0x06, 0x03, 0x03, 0x9F, 0xFE, 0x17, 0x16, 0x9F, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + BluetoothLERawAdvertisementsResponse msg; + msg.advertisements_len = 12; + for (int i = 0; i < 12; i++) { + auto &adv = msg.advertisements[i]; + adv.address = 0xAABBCCDD0000ULL + i; + adv.rssi = -60 - i; + adv.address_type = 1; + memcpy(adv.data, fake_adv_data, sizeof(fake_adv_data)); + adv.data_len = sizeof(fake_adv_data); + } + return msg; +} + +static void CalculateSize_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result += msg.calculate_size(); + } + benchmark::DoNotOptimize(result); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalculateSize_BLERawAdvs12); + +static void Encode_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + APIBuffer buffer; + uint32_t total_size = msg.calculate_size(); + buffer.resize(total_size); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Encode_BLERawAdvs12); + +static void CalcAndEncode_BLERawAdvs12(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + APIBuffer buffer; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_BLERawAdvs12); + +static void CalcAndEncode_BLERawAdvs12_Fresh(benchmark::State &state) { + auto msg = make_ble_raw_advs_12(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_BLERawAdvs12_Fresh); + +#endif // USE_BLUETOOTH_PROXY + } // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h new file mode 100644 index 00000000000..0934e0d4ed6 --- /dev/null +++ b/tests/benchmarks/stubs/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -0,0 +1,38 @@ +// Stub for benchmark builds — provides the minimal interface that +// api_connection.cpp needs when USE_BLUETOOTH_PROXY is defined, +// without pulling in ESP32 BLE dependencies. +#pragma once + +#include "esphome/components/api/api_pb2.h" + +namespace esphome { +namespace api { +class APIConnection; +} // namespace api + +namespace bluetooth_proxy { + +class BluetoothProxy { + public: + api::APIConnection *get_api_connection() const { return nullptr; } + void subscribe_api_connection(api::APIConnection *conn, uint32_t flags) {} + void unsubscribe_api_connection(api::APIConnection *conn) {} + void bluetooth_device_request(const api::BluetoothDeviceRequest &msg) {} + void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) {} + void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) {} + void bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) {} + void bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) {} + void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) {} + void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) {} + void send_connections_free(api::APIConnection *conn) {} + void bluetooth_scanner_set_mode(bool active) {} + void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) {} + uint32_t get_feature_flags() const { return 0; } + void get_bluetooth_mac_address_pretty(char *buf) const { buf[0] = '\0'; } +}; + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern BluetoothProxy *global_bluetooth_proxy; + +} // namespace bluetooth_proxy +} // namespace esphome From 52897fd0673e0005bf7d5377a6c4b1964b788228 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 17:27:40 -1000 Subject: [PATCH 02/11] [api] Add 2-byte inline fast path for sint32 varint encode/size Add encode_varint_raw_short() and ProtoSize::varint_short() that inline both the 1-byte and 2-byte varint paths, falling back to the noinline slow path for 3+ bytes. Use these for sint32 fields (zigzag encoding), where values like RSSI (-100 to 0) produce zigzag values that are 1-2 bytes. This avoids a function call for the common case without bloating the generic encode_varint_raw fast path. --- esphome/components/api/api_pb2.cpp | 2 +- esphome/components/api/proto.h | 31 +++++++++++++++++++++++++++-- script/api_protobuf/api_protobuf.py | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ae2cd2bae8d..9515b9c3b6f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2252,7 +2252,7 @@ void BluetoothLERawAdvertisement::encode(ProtoWriteBuffer &buffer) const { buffer.write_raw_byte(8); buffer.encode_varint_raw_64(this->address); buffer.write_raw_byte(16); - buffer.encode_varint_raw(encode_zigzag32(this->rssi)); + buffer.encode_varint_raw_short(encode_zigzag32(this->rssi)); buffer.encode_uint32(3, this->address_type); buffer.write_raw_byte(34); buffer.encode_varint_raw(this->data_len); diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index b629018a919..efec7af7071 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -207,6 +207,22 @@ class ProtoWriteBuffer { } this->encode_varint_raw_slow_(value); } + /// Encode a varint that is expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths). + /// Inlines both the 1-byte and 2-byte paths; falls back to slow path for 3+ bytes. + inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_short(uint32_t value) { + if (value < 128) [[likely]] { + this->debug_check_bounds_(1); + *this->pos_++ = static_cast(value); + return; + } + if (value < 16384) [[likely]] { + this->debug_check_bounds_(2); + *this->pos_++ = static_cast(value | 0x80); + *this->pos_++ = static_cast(value >> 7); + return; + } + this->encode_varint_raw_slow_(value); + } void encode_varint_raw_64(uint64_t value) { while (value > 0x7F) { this->debug_check_bounds_(1); @@ -531,6 +547,17 @@ class ProtoSize { return varint_wide(value); return varint_slow(value); } + /// Size of a varint expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths). + /// Inlines both checks; falls back to slow path for 3+ bytes. + static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE varint_short(uint32_t value) { + if (value < VARINT_THRESHOLD_1_BYTE) [[likely]] + return 1; + if (value < VARINT_THRESHOLD_2_BYTE) [[likely]] + return 2; + if (__builtin_is_constant_evaluated()) + return varint_wide(value); + return varint_slow(value); + } private: // Slow path for varint >= 128, outlined to keep fast path small @@ -645,10 +672,10 @@ class ProtoSize { return value ? field_id_size + 4 : 0; } static constexpr uint32_t calc_sint32(uint32_t field_id_size, int32_t value) { - return value ? field_id_size + varint(encode_zigzag32(value)) : 0; + return value ? field_id_size + varint_short(encode_zigzag32(value)) : 0; } static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_sint32_force(uint32_t field_id_size, int32_t value) { - return field_id_size + varint(encode_zigzag32(value)); + return field_id_size + varint_short(encode_zigzag32(value)); } static constexpr uint32_t calc_int64(uint32_t field_id_size, int64_t value) { return value ? field_id_size + varint(value) : 0; diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index f2a11141af0..811e7d12d75 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -229,7 +229,7 @@ class TypeInfo(ABC): RAW_ENCODE_MAP: dict[str, str] = { "encode_uint32": "buffer.encode_varint_raw({value});", "encode_uint64": "buffer.encode_varint_raw_64({value});", - "encode_sint32": "buffer.encode_varint_raw(encode_zigzag32({value}));", + "encode_sint32": "buffer.encode_varint_raw_short(encode_zigzag32({value}));", "encode_sint64": "buffer.encode_varint_raw_64(encode_zigzag64({value}));", "encode_int64": "buffer.encode_varint_raw_64(static_cast({value}));", "encode_bool": "buffer.write_raw_byte({value} ? 0x01 : 0x00);", From 2137e4600acd1bcef97d715b99511b28e76cf102 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 17:37:45 -1000 Subject: [PATCH 03/11] [api] Hoist pos_ to local in encode_varint_raw_64 to avoid reload per byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a __restrict__ local pointer for the varint write loop so the compiler can keep it in a register instead of reloading pos_ from memory on each iteration. Eliminates the store→load dependency chain that was causing 7 load/store pairs for a typical 48-bit BLE address varint. --- esphome/components/api/proto.h | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index efec7af7071..3989c7c424a 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -224,13 +224,15 @@ class ProtoWriteBuffer { this->encode_varint_raw_slow_(value); } void encode_varint_raw_64(uint64_t value) { + // Use __restrict__ so the compiler knows pos doesn't alias this-> + // and can keep it in a register across the loop. + uint8_t *__restrict__ pos = this->pos_; while (value > 0x7F) { - this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value | 0x80); + *pos++ = static_cast(value | 0x80); value >>= 7; } - this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value); + *pos++ = static_cast(value); + this->pos_ = pos; } /** * Encode a field key (tag/wire type combination). From 537af1fe6b26b54d949cd8bb6ac4d25d44c626d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 19:39:40 -1000 Subject: [PATCH 04/11] [api] Hoist pos_ to local in encode_varint_raw_64 to avoid reload per byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a __restrict__ local pointer for the varint write loop so the compiler can keep it in a register instead of reloading pos_ from memory on each iteration. Eliminates the store→load dependency chain that was causing 7 load/store pairs for a typical 48-bit BLE address varint. --- esphome/components/api/proto.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 3989c7c424a..48b450e8195 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -224,8 +224,6 @@ class ProtoWriteBuffer { this->encode_varint_raw_slow_(value); } void encode_varint_raw_64(uint64_t value) { - // Use __restrict__ so the compiler knows pos doesn't alias this-> - // and can keep it in a register across the loop. uint8_t *__restrict__ pos = this->pos_; while (value > 0x7F) { *pos++ = static_cast(value | 0x80); From 3886751662901df642438c82092396af50d62285 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 19:45:07 -1000 Subject: [PATCH 05/11] [api] Restore comment on __restrict__ local in encode_varint_raw_64 --- esphome/components/api/proto.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 48b450e8195..3989c7c424a 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -224,6 +224,8 @@ class ProtoWriteBuffer { this->encode_varint_raw_slow_(value); } void encode_varint_raw_64(uint64_t value) { + // Use __restrict__ so the compiler knows pos doesn't alias this-> + // and can keep it in a register across the loop. uint8_t *__restrict__ pos = this->pos_; while (value > 0x7F) { *pos++ = static_cast(value | 0x80); From 83e4478bc266e89a96966b8fdeb5af7105459578 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 19:45:49 -1000 Subject: [PATCH 06/11] [api] Apply __restrict__ local hoist to encode_varint_raw_slow_ Same optimization as encode_varint_raw_64: hoist pos_ into a __restrict__ local so the compiler keeps it in a register across the loop instead of reloading from memory each iteration. --- esphome/components/api/proto.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 4f5b3f0918f..6e348dde44e 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -11,13 +11,15 @@ static const char *const TAG = "api.proto"; uint32_t ProtoSize::varint_slow(uint32_t value) { return varint_wide(value); } void ProtoWriteBuffer::encode_varint_raw_slow_(uint32_t value) { + // Use __restrict__ so the compiler knows pos doesn't alias this-> + // and can keep it in a register across the loop. + uint8_t *__restrict__ pos = this->pos_; do { - this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value | 0x80); + *pos++ = static_cast(value | 0x80); value >>= 7; } while (value > 0x7F); - this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value); + *pos++ = static_cast(value); + this->pos_ = pos; } ProtoVarIntResult ProtoVarInt::parse_slow(const uint8_t *buffer, uint32_t len) { From c6938adb610fe5d5e204330fba35e33ac4592ade Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 19:47:40 -1000 Subject: [PATCH 07/11] [api] Apply __restrict__ local hoist to all ProtoWriteBuffer pos_ accessors Apply the same __restrict__ local pointer pattern proven in encode_varint_raw_64 to all remaining methods that write through pos_: encode_varint_raw, encode_varint_raw_short, write_raw_byte, encode_raw, write_tag_and_fixed32, encode_string, encode_bool, and encode_fixed32. Each method now hoists pos_ into a __restrict__ local before writing and stores back once at the end. When the compiler inlines these into a generated encode() method, it can keep pos_ in a register across consecutive calls instead of reloading from memory after every write. --- esphome/components/api/proto.h | 61 +++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 3989c7c424a..7de630daeb2 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -202,7 +202,9 @@ class ProtoWriteBuffer { inline void ESPHOME_ALWAYS_INLINE encode_varint_raw(uint32_t value) { if (value < 128) [[likely]] { this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value); + uint8_t *__restrict__ pos = this->pos_; + *pos++ = static_cast(value); + this->pos_ = pos; return; } this->encode_varint_raw_slow_(value); @@ -212,13 +214,17 @@ class ProtoWriteBuffer { inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_short(uint32_t value) { if (value < 128) [[likely]] { this->debug_check_bounds_(1); - *this->pos_++ = static_cast(value); + uint8_t *__restrict__ pos = this->pos_; + *pos++ = static_cast(value); + this->pos_ = pos; return; } if (value < 16384) [[likely]] { this->debug_check_bounds_(2); - *this->pos_++ = static_cast(value | 0x80); - *this->pos_++ = static_cast(value >> 7); + uint8_t *__restrict__ pos = this->pos_; + *pos++ = static_cast(value | 0x80); + *pos++ = static_cast(value >> 7); + this->pos_ = pos; return; } this->encode_varint_raw_slow_(value); @@ -250,28 +256,32 @@ class ProtoWriteBuffer { /// Write a single precomputed tag byte. Tag must be < 128. inline void write_raw_byte(uint8_t b) ESPHOME_ALWAYS_INLINE { this->debug_check_bounds_(1); - *this->pos_++ = b; + uint8_t *__restrict__ pos = this->pos_; + *pos++ = b; + this->pos_ = pos; } /// Write raw bytes to the buffer (no tag, no length prefix). inline void encode_raw(const void *data, size_t len) ESPHOME_ALWAYS_INLINE { this->debug_check_bounds_(len); - std::memcpy(this->pos_, data, len); - this->pos_ += len; + uint8_t *__restrict__ pos = this->pos_; + std::memcpy(pos, data, len); + this->pos_ = pos + len; } /// Write a precomputed tag byte + 32-bit value in one operation. /// Tag must be a single-byte varint (< 128). No zero check. inline void write_tag_and_fixed32(uint8_t tag, uint32_t value) ESPHOME_ALWAYS_INLINE { this->debug_check_bounds_(5); - this->pos_[0] = tag; + uint8_t *__restrict__ pos = this->pos_; + pos[0] = tag; #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - std::memcpy(this->pos_ + 1, &value, 4); + std::memcpy(pos + 1, &value, 4); #else - this->pos_[1] = static_cast(value & 0xFF); - this->pos_[2] = static_cast((value >> 8) & 0xFF); - this->pos_[3] = static_cast((value >> 16) & 0xFF); - this->pos_[4] = static_cast((value >> 24) & 0xFF); + pos[1] = static_cast(value & 0xFF); + pos[2] = static_cast((value >> 8) & 0xFF); + pos[3] = static_cast((value >> 16) & 0xFF); + pos[4] = static_cast((value >> 24) & 0xFF); #endif - this->pos_ += 5; + this->pos_ = pos + 5; } void encode_string(uint32_t field_id, const char *string, size_t len, bool force = false) { if (len == 0 && !force) @@ -282,8 +292,9 @@ class ProtoWriteBuffer { // Direct memcpy into pre-sized buffer — avoids push_back() per-byte capacity checks // and vector::insert() iterator overhead. ~10-11x faster for 16-32 byte strings. this->debug_check_bounds_(len); - std::memcpy(this->pos_, string, len); - this->pos_ += len; + uint8_t *__restrict__ pos = this->pos_; + std::memcpy(pos, string, len); + this->pos_ = pos + len; } void encode_string(uint32_t field_id, const std::string &value, bool force = false) { this->encode_string(field_id, value.data(), value.size(), force); @@ -311,7 +322,9 @@ class ProtoWriteBuffer { return; this->encode_field_raw(field_id, 0); // type 0: Varint - bool this->debug_check_bounds_(1); - *this->pos_++ = value ? 0x01 : 0x00; + uint8_t *__restrict__ pos = this->pos_; + *pos++ = value ? 0x01 : 0x00; + this->pos_ = pos; } void encode_fixed32(uint32_t field_id, uint32_t value, bool force = false) { if (value == 0 && !force) @@ -319,15 +332,17 @@ class ProtoWriteBuffer { this->encode_field_raw(field_id, 5); // type 5: 32-bit fixed32 this->debug_check_bounds_(4); + uint8_t *__restrict__ pos = this->pos_; #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ // Protobuf fixed32 is little-endian, so direct copy works - std::memcpy(this->pos_, &value, 4); - this->pos_ += 4; + std::memcpy(pos, &value, 4); + this->pos_ = pos + 4; #else - *this->pos_++ = (value >> 0) & 0xFF; - *this->pos_++ = (value >> 8) & 0xFF; - *this->pos_++ = (value >> 16) & 0xFF; - *this->pos_++ = (value >> 24) & 0xFF; + *pos++ = (value >> 0) & 0xFF; + *pos++ = (value >> 8) & 0xFF; + *pos++ = (value >> 16) & 0xFF; + *pos++ = (value >> 24) & 0xFF; + this->pos_ = pos; #endif } // NOTE: Wire type 1 (64-bit fixed: double, fixed64, sfixed64) is intentionally From 3a76f9d5d223e65cddecf5b46a874400fc213c47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 19:51:55 -1000 Subject: [PATCH 08/11] [api] Merge varint length + memcpy into single pos scope in encode_string Previously encode_string called encode_varint_raw(len) then encode_raw(data, len) as separate methods, each with their own __restrict__ pos scope. This caused a redundant store-load pair of pos_ between the two operations. Inline the length varint write and memcpy under a single local pos variable so the compiler can keep pos_ in a register across both operations. Eliminates one load-store pair per string encode. --- esphome/components/api/proto.h | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 7de630daeb2..a669707a1a4 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -288,11 +288,17 @@ class ProtoWriteBuffer { return; this->encode_field_raw(field_id, 2); // type 2: Length-delimited string - this->encode_varint_raw(len); - // Direct memcpy into pre-sized buffer — avoids push_back() per-byte capacity checks - // and vector::insert() iterator overhead. ~10-11x faster for 16-32 byte strings. - this->debug_check_bounds_(len); + // Inline the length varint + memcpy under a single __restrict__ pos + // to avoid a store-load pair between encode_varint_raw and encode_raw. + this->debug_check_bounds_(1 + len); uint8_t *__restrict__ pos = this->pos_; + if (len < 128) [[likely]] { + *pos++ = static_cast(len); + } else { + // Length >= 128: use slow path for the length varint, then re-hoist pos + this->encode_varint_raw_slow_(len); + pos = this->pos_; + } std::memcpy(pos, string, len); this->pos_ = pos + len; } From 0603190c3c31b118d8c671b05df1e50a1772f21b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 19:55:50 -1000 Subject: [PATCH 09/11] [api] Restore debug bounds checks in __restrict__ varint loops Add sync_debug_check_bounds_() that syncs pos_ from a local pointer before checking bounds. Use it in encode_varint_raw_64 and encode_varint_raw_slow_ to restore per-byte bounds checking in debug mode without breaking the __restrict__ optimization in production (where it's a no-op). --- esphome/components/api/proto.cpp | 2 ++ esphome/components/api/proto.h | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 6e348dde44e..dde7f0b32d2 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -15,9 +15,11 @@ void ProtoWriteBuffer::encode_varint_raw_slow_(uint32_t value) { // and can keep it in a register across the loop. uint8_t *__restrict__ pos = this->pos_; do { + this->sync_debug_check_bounds_(pos, 1); *pos++ = static_cast(value | 0x80); value >>= 7; } while (value > 0x7F); + this->sync_debug_check_bounds_(pos, 1); *pos++ = static_cast(value); this->pos_ = pos; } diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index a669707a1a4..9db03d62895 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -234,9 +234,11 @@ class ProtoWriteBuffer { // and can keep it in a register across the loop. uint8_t *__restrict__ pos = this->pos_; while (value > 0x7F) { + this->sync_debug_check_bounds_(pos, 1); *pos++ = static_cast(value | 0x80); value >>= 7; } + this->sync_debug_check_bounds_(pos, 1); *pos++ = static_cast(value); this->pos_ = pos; } @@ -290,14 +292,15 @@ class ProtoWriteBuffer { this->encode_field_raw(field_id, 2); // type 2: Length-delimited string // Inline the length varint + memcpy under a single __restrict__ pos // to avoid a store-load pair between encode_varint_raw and encode_raw. - this->debug_check_bounds_(1 + len); uint8_t *__restrict__ pos = this->pos_; if (len < 128) [[likely]] { + this->debug_check_bounds_(1 + len); *pos++ = static_cast(len); } else { // Length >= 128: use slow path for the length varint, then re-hoist pos this->encode_varint_raw_slow_(len); pos = this->pos_; + this->debug_check_bounds_(len); } std::memcpy(pos, string, len); this->pos_ = pos + len; @@ -405,9 +408,16 @@ class ProtoWriteBuffer { #ifdef ESPHOME_DEBUG_API void debug_check_bounds_(size_t bytes, const char *caller = __builtin_FUNCTION()); + /// Sync pos_ from a local pointer, then check bounds. For use in __restrict__ loops + /// where pos_ is hoisted into a local but debug checks need the current position. + void sync_debug_check_bounds_(uint8_t *pos, size_t bytes, const char *caller = __builtin_FUNCTION()) { + this->pos_ = pos; + this->debug_check_bounds_(bytes, caller); + } void debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual); #else void debug_check_bounds_([[maybe_unused]] size_t bytes) {} + void sync_debug_check_bounds_(uint8_t *pos, [[maybe_unused]] size_t bytes) {} #endif APIBuffer *buffer_; From fe012272f88bd24b356a423b6d86212a73e3d788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 22:06:31 -1000 Subject: [PATCH 10/11] [api] Merge tag + value into single __restrict__ scope in encode_uint32/uint64/bool/fixed32 Each of these methods called encode_field_raw() then a value encoder, causing a store-load pair on pos_ between the tag and value writes. Inline the tag write into the same __restrict__ local scope as the value write so the compiler can emit tag + value with a single pos_ load at start and store at end. Verified on Xtensa: encode_uint32 now does one load + two writes + one store (was load-store-load-store). --- esphome/components/api/proto.h | 68 +++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 9db03d62895..f5c64583c9a 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -317,21 +317,64 @@ class ProtoWriteBuffer { void encode_uint32(uint32_t field_id, uint32_t value, bool force = false) { if (value == 0 && !force) return; - this->encode_field_raw(field_id, 0); // type 0: Varint - uint32 - this->encode_varint_raw(value); + // Inline tag + value under a single __restrict__ pos to avoid + // a store-load pair between encode_field_raw and encode_varint_raw. + uint32_t tag = (field_id << 3) | 0; // wire type 0: varint + this->debug_check_bounds_(2); // at least tag + 1 byte value + uint8_t *__restrict__ pos = this->pos_; + if (tag < 128) [[likely]] { + *pos++ = static_cast(tag); + } else { + this->encode_varint_raw_slow_(tag); + pos = this->pos_; + } + if (value < 128) [[likely]] { + *pos++ = static_cast(value); + this->pos_ = pos; + } else { + this->pos_ = pos; + this->encode_varint_raw_slow_(value); + } } void encode_uint64(uint32_t field_id, uint64_t value, bool force = false) { if (value == 0 && !force) return; - this->encode_field_raw(field_id, 0); // type 0: Varint - uint64 - this->encode_varint_raw_64(value); + // Inline tag under same __restrict__ scope as varint64 to avoid + // a store-load pair between encode_field_raw and encode_varint_raw_64. + uint32_t tag = (field_id << 3) | 0; // wire type 0: varint + this->debug_check_bounds_(1); + uint8_t *__restrict__ pos = this->pos_; + if (tag < 128) [[likely]] { + *pos++ = static_cast(tag); + } else { + this->pos_ = pos; + this->encode_varint_raw_slow_(tag); + pos = this->pos_; + } + // Continue with varint64 in same pos scope + while (value > 0x7F) { + this->sync_debug_check_bounds_(pos, 1); + *pos++ = static_cast(value | 0x80); + value >>= 7; + } + this->sync_debug_check_bounds_(pos, 1); + *pos++ = static_cast(value); + this->pos_ = pos; } void encode_bool(uint32_t field_id, bool value, bool force = false) { if (!value && !force) return; - this->encode_field_raw(field_id, 0); // type 0: Varint - bool - this->debug_check_bounds_(1); + // Inline tag + bool byte under single __restrict__ scope + uint32_t tag = (field_id << 3) | 0; // wire type 0: varint + this->debug_check_bounds_(2); uint8_t *__restrict__ pos = this->pos_; + if (tag < 128) [[likely]] { + *pos++ = static_cast(tag); + } else { + this->pos_ = pos; + this->encode_varint_raw_slow_(tag); + pos = this->pos_; + } *pos++ = value ? 0x01 : 0x00; this->pos_ = pos; } @@ -339,11 +382,18 @@ class ProtoWriteBuffer { if (value == 0 && !force) return; - this->encode_field_raw(field_id, 5); // type 5: 32-bit fixed32 - this->debug_check_bounds_(4); + // Inline tag + fixed32 under single __restrict__ scope + uint32_t tag = (field_id << 3) | 5; // wire type 5: 32-bit + this->debug_check_bounds_(5); // tag + 4 bytes uint8_t *__restrict__ pos = this->pos_; + if (tag < 128) [[likely]] { + *pos++ = static_cast(tag); + } else { + this->pos_ = pos; + this->encode_varint_raw_slow_(tag); + pos = this->pos_; + } #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ - // Protobuf fixed32 is little-endian, so direct copy works std::memcpy(pos, &value, 4); this->pos_ = pos + 4; #else From 35491f2649d96d5a1ee590c125d34d104daaadca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 22:15:28 -1000 Subject: [PATCH 11/11] Revert "[api] Merge tag + value into single __restrict__ scope in encode_uint32/uint64/bool/fixed32" This reverts commit fe012272f88bd24b356a423b6d86212a73e3d788. --- esphome/components/api/proto.h | 68 +++++----------------------------- 1 file changed, 9 insertions(+), 59 deletions(-) diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index f5c64583c9a..9db03d62895 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -317,64 +317,21 @@ class ProtoWriteBuffer { void encode_uint32(uint32_t field_id, uint32_t value, bool force = false) { if (value == 0 && !force) return; - // Inline tag + value under a single __restrict__ pos to avoid - // a store-load pair between encode_field_raw and encode_varint_raw. - uint32_t tag = (field_id << 3) | 0; // wire type 0: varint - this->debug_check_bounds_(2); // at least tag + 1 byte value - uint8_t *__restrict__ pos = this->pos_; - if (tag < 128) [[likely]] { - *pos++ = static_cast(tag); - } else { - this->encode_varint_raw_slow_(tag); - pos = this->pos_; - } - if (value < 128) [[likely]] { - *pos++ = static_cast(value); - this->pos_ = pos; - } else { - this->pos_ = pos; - this->encode_varint_raw_slow_(value); - } + this->encode_field_raw(field_id, 0); // type 0: Varint - uint32 + this->encode_varint_raw(value); } void encode_uint64(uint32_t field_id, uint64_t value, bool force = false) { if (value == 0 && !force) return; - // Inline tag under same __restrict__ scope as varint64 to avoid - // a store-load pair between encode_field_raw and encode_varint_raw_64. - uint32_t tag = (field_id << 3) | 0; // wire type 0: varint - this->debug_check_bounds_(1); - uint8_t *__restrict__ pos = this->pos_; - if (tag < 128) [[likely]] { - *pos++ = static_cast(tag); - } else { - this->pos_ = pos; - this->encode_varint_raw_slow_(tag); - pos = this->pos_; - } - // Continue with varint64 in same pos scope - while (value > 0x7F) { - this->sync_debug_check_bounds_(pos, 1); - *pos++ = static_cast(value | 0x80); - value >>= 7; - } - this->sync_debug_check_bounds_(pos, 1); - *pos++ = static_cast(value); - this->pos_ = pos; + this->encode_field_raw(field_id, 0); // type 0: Varint - uint64 + this->encode_varint_raw_64(value); } void encode_bool(uint32_t field_id, bool value, bool force = false) { if (!value && !force) return; - // Inline tag + bool byte under single __restrict__ scope - uint32_t tag = (field_id << 3) | 0; // wire type 0: varint - this->debug_check_bounds_(2); + this->encode_field_raw(field_id, 0); // type 0: Varint - bool + this->debug_check_bounds_(1); uint8_t *__restrict__ pos = this->pos_; - if (tag < 128) [[likely]] { - *pos++ = static_cast(tag); - } else { - this->pos_ = pos; - this->encode_varint_raw_slow_(tag); - pos = this->pos_; - } *pos++ = value ? 0x01 : 0x00; this->pos_ = pos; } @@ -382,18 +339,11 @@ class ProtoWriteBuffer { if (value == 0 && !force) return; - // Inline tag + fixed32 under single __restrict__ scope - uint32_t tag = (field_id << 3) | 5; // wire type 5: 32-bit - this->debug_check_bounds_(5); // tag + 4 bytes + this->encode_field_raw(field_id, 5); // type 5: 32-bit fixed32 + this->debug_check_bounds_(4); uint8_t *__restrict__ pos = this->pos_; - if (tag < 128) [[likely]] { - *pos++ = static_cast(tag); - } else { - this->pos_ = pos; - this->encode_varint_raw_slow_(tag); - pos = this->pos_; - } #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + // Protobuf fixed32 is little-endian, so direct copy works std::memcpy(pos, &value, 4); this->pos_ = pos + 4; #else