From d8e586609fd8a8dd15001579d64f3d004702e5d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 20:32:31 -1000 Subject: [PATCH] [core] Optimize value_accuracy_to_buf to avoid snprintf Replace snprintf("%.*f") with integer-based formatting for finite float values with accuracy_decimals 0-3 (covers virtually all sensor usage). Falls back to snprintf for higher accuracy or NaN/Inf. Uses lrint() with double cast for the multiply to match snprintf's rounding behavior exactly. The fast path avoids snprintf's heavy float formatting machinery entirely. Also optimizes value_accuracy_with_uom_to_buf to append the UOM string directly instead of going through snprintf. Adds C++ unit tests that verify output matches snprintf for a range of values including edge cases. Benchmark: 92,961ns -> 6,484ns (14.3x faster, 2000 iterations). --- esphome/core/helpers.cpp | 81 ++++++++-- tests/components/core/test_value_accuracy.cpp | 151 ++++++++++++++++++ 2 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 tests/components/core/test_value_accuracy.cpp diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 5940f6ec98..48ed45477f 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -526,28 +526,87 @@ std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { return std::string(buf); } +// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). +// Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. +static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals) { + char *p = buf; + if (std::signbit(value)) { + *p++ = '-'; + value = -value; + } + uint32_t mult = 1; + if (accuracy_decimals == 1) + mult = 10; + else if (accuracy_decimals == 2) + mult = 100; + else if (accuracy_decimals == 3) + mult = 1000; + // Cast to double for the multiply to match snprintf's precision. + // float*int loses bits at exact-half boundaries (e.g. 23.45f*10 = 234.5 in float, + // but snprintf sees 234.500007... via double promotion and rounds differently). + uint32_t scaled = static_cast(lrint(static_cast(value) * mult)); + uint32_t int_part = scaled / mult; + // Write integer part in reverse, then flip + char *start = p; + if (int_part == 0) { + *p++ = '0'; + } else { + while (int_part > 0) { + *p++ = '0' + (int_part % 10); + int_part /= 10; + } + std::reverse(start, p); + } + if (accuracy_decimals > 0) { + *p++ = '.'; + uint32_t frac = scaled % mult; + uint32_t d = mult / 10; + while (d > 0) { + *p++ = '0' + static_cast(frac / d); + frac %= d; + d /= 10; + } + } + *p = '\0'; + return static_cast(p - buf); +} + size_t value_accuracy_to_buf(std::span buf, float value, int8_t accuracy_decimals) { normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error + + // Fast path for accuracy 0-3 and finite values + if (accuracy_decimals <= 3 && std::isfinite(value)) { + return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals); + } + + // Fallback for NaN/Inf/high accuracy int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, value); if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 + return 0; return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); } size_t value_accuracy_with_uom_to_buf(std::span buf, float value, int8_t accuracy_decimals, StringRef unit_of_measurement) { + size_t len = value_accuracy_to_buf(buf, value, accuracy_decimals); if (unit_of_measurement.empty()) { - return value_accuracy_to_buf(buf, value, accuracy_decimals); + return len; } - normalize_accuracy_decimals(value, accuracy_decimals); - // snprintf returns chars that would be written (excluding null), or negative on error - int len = snprintf(buf.data(), buf.size(), "%.*f %s", accuracy_decimals, value, unit_of_measurement.c_str()); - if (len < 0) - return 0; // encoding error - // On truncation, snprintf returns would-be length; actual written is buf.size() - 1 - return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); + // Append " " directly + char *p = buf.data() + len; + size_t remaining = buf.size() - len; + size_t uom_len = unit_of_measurement.size(); + // Need space for: ' ' + uom + '\0' + if (remaining < 2) { + return len; + } + *p++ = ' '; + remaining--; + size_t copy_len = std::min(uom_len, remaining - 1); + memcpy(p, unit_of_measurement.c_str(), copy_len); + p += copy_len; + *p = '\0'; + return static_cast(p - buf.data()); } int8_t step_to_accuracy_decimals(float step) { diff --git a/tests/components/core/test_value_accuracy.cpp b/tests/components/core/test_value_accuracy.cpp new file mode 100644 index 0000000000..a1fba90acf --- /dev/null +++ b/tests/components/core/test_value_accuracy.cpp @@ -0,0 +1,151 @@ +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::testing { + +// Helper to call value_accuracy_to_buf and return as string +static std::string va_to_string(float value, int8_t accuracy_decimals) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + size_t len = value_accuracy_to_buf(sp, value, accuracy_decimals); + return std::string(buf, len); +} + +// Helper: reference implementation using snprintf for comparison +static std::string va_reference(float value, int8_t accuracy_decimals) { + // Replicate normalize_accuracy_decimals logic + if (accuracy_decimals < 0) { + float divisor; + if (accuracy_decimals == -1) { + divisor = 10.0f; + } else if (accuracy_decimals == -2) { + divisor = 100.0f; + } else { + divisor = pow10_int(-accuracy_decimals); + } + value = roundf(value / divisor) * divisor; + accuracy_decimals = 0; + } + char buf[VALUE_ACCURACY_MAX_LEN]; + snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value); + return std::string(buf); +} + +// --- Basic formatting --- + +TEST(ValueAccuracyToBuf, ZeroDecimals) { + EXPECT_EQ(va_to_string(23.456f, 0), "23"); + EXPECT_EQ(va_to_string(0.0f, 0), "0"); + EXPECT_EQ(va_to_string(100.0f, 0), "100"); + EXPECT_EQ(va_to_string(1.0f, 0), "1"); +} + +TEST(ValueAccuracyToBuf, OneDecimal) { + EXPECT_EQ(va_to_string(23.456f, 1), "23.5"); + EXPECT_EQ(va_to_string(0.0f, 1), "0.0"); + EXPECT_EQ(va_to_string(1.05f, 1), va_reference(1.05f, 1)); +} + +TEST(ValueAccuracyToBuf, TwoDecimals) { + EXPECT_EQ(va_to_string(23.456f, 2), "23.46"); + EXPECT_EQ(va_to_string(0.0f, 2), "0.00"); + EXPECT_EQ(va_to_string(1.005f, 2), va_reference(1.005f, 2)); +} + +TEST(ValueAccuracyToBuf, ThreeDecimals) { + EXPECT_EQ(va_to_string(23.456f, 3), "23.456"); + EXPECT_EQ(va_to_string(0.0f, 3), "0.000"); +} + +// --- Negative values --- + +TEST(ValueAccuracyToBuf, NegativeValues) { + EXPECT_EQ(va_to_string(-23.456f, 2), "-23.46"); + EXPECT_EQ(va_to_string(-0.5f, 1), "-0.5"); + EXPECT_EQ(va_to_string(-100.0f, 0), "-100"); +} + +// --- Negative accuracy_decimals (rounding to tens/hundreds) --- + +TEST(ValueAccuracyToBuf, NegativeAccuracy) { + EXPECT_EQ(va_to_string(1234.0f, -1), va_reference(1234.0f, -1)); + EXPECT_EQ(va_to_string(1234.0f, -2), va_reference(1234.0f, -2)); + EXPECT_EQ(va_to_string(56.0f, -1), va_reference(56.0f, -1)); +} + +// --- Special float values --- + +TEST(ValueAccuracyToBuf, NaN) { + std::string result = va_to_string(NAN, 2); + EXPECT_EQ(result, va_reference(NAN, 2)); +} + +TEST(ValueAccuracyToBuf, Infinity) { + std::string result = va_to_string(INFINITY, 2); + EXPECT_EQ(result, va_reference(INFINITY, 2)); +} + +TEST(ValueAccuracyToBuf, NegativeInfinity) { + std::string result = va_to_string(-INFINITY, 2); + EXPECT_EQ(result, va_reference(-INFINITY, 2)); +} + +// --- Edge cases --- + +TEST(ValueAccuracyToBuf, VerySmallValues) { + EXPECT_EQ(va_to_string(0.001f, 3), "0.001"); + EXPECT_EQ(va_to_string(0.001f, 2), "0.00"); + EXPECT_EQ(va_to_string(0.009f, 2), "0.01"); +} + +TEST(ValueAccuracyToBuf, LargeValues) { + EXPECT_EQ(va_to_string(999999.0f, 0), va_reference(999999.0f, 0)); + EXPECT_EQ(va_to_string(1013.25f, 2), "1013.25"); +} + +TEST(ValueAccuracyToBuf, Rounding) { + // 0.5 rounds up + EXPECT_EQ(va_to_string(23.5f, 0), "24"); + EXPECT_EQ(va_to_string(23.45f, 1), "23.5"); // float: 23.45 -> 23.4 or 23.5 + EXPECT_EQ(va_to_string(23.45f, 1), va_reference(23.45f, 1)); +} + +// --- Match snprintf for a range of typical sensor values --- + +TEST(ValueAccuracyToBuf, MatchesSnprintf) { + float test_values[] = {0.0f, 1.0f, -1.0f, 23.456f, -23.456f, 100.0f, 0.1f, 0.01f, 99.99f, 1013.25f, -40.0f}; + int8_t test_accuracies[] = {0, 1, 2, 3}; + + for (float value : test_values) { + for (int8_t acc : test_accuracies) { + EXPECT_EQ(va_to_string(value, acc), va_reference(value, acc)) + << "Mismatch for value=" << value << " accuracy=" << static_cast(acc); + } + } +} + +// --- Return value (length) --- + +TEST(ValueAccuracyToBuf, ReturnsCorrectLength) { + char buf[VALUE_ACCURACY_MAX_LEN]; + std::span sp(buf); + + size_t len = value_accuracy_to_buf(sp, 23.456f, 2); + EXPECT_EQ(len, 5u); // "23.46" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, 0.0f, 0); + EXPECT_EQ(len, 1u); // "0" + EXPECT_EQ(strlen(buf), len); + + len = value_accuracy_to_buf(sp, -100.0f, 1); + EXPECT_EQ(len, 6u); // "-100.0" + EXPECT_EQ(strlen(buf), len); +} + +} // namespace esphome::testing