[core] Optimize value_accuracy_to_buf to avoid snprintf (#15596)

This commit is contained in:
J. Nick Koston
2026-04-22 06:31:34 +02:00
committed by GitHub
parent 67576d4879
commit 699cf9690a
4 changed files with 410 additions and 12 deletions
+42 -12
View File
@@ -447,28 +447,58 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de
// value_accuracy_to_string moved to alloc_helpers.cpp
// Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage).
// Avoids snprintf("%.*f") which pulls in heavy float formatting machinery.
// Caller must guarantee value is finite and |value| * mult fits in uint32_t.
static size_t value_accuracy_to_buf_fast(char *buf, float value, int8_t accuracy_decimals, uint32_t mult) {
char *p = buf;
if (std::signbit(value)) {
*p++ = '-';
value = -value;
}
// Cast to double for the multiply to match snprintf's rounding 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).
// llrint returns long long so the result fits even on 32-bit targets where
// long is 32-bit; caller has already bounded |value * mult| to UINT32_MAX.
uint32_t scaled = static_cast<uint32_t>(llrint(static_cast<double>(value) * mult));
p = uint32_to_str_unchecked(p, scaled / mult);
if (accuracy_decimals > 0) {
*p++ = '.';
p = frac_to_str_unchecked(p, scaled % mult, mult / 10);
}
*p = '\0';
return static_cast<size_t>(p - buf);
}
size_t value_accuracy_to_buf(std::span<char, VALUE_ACCURACY_MAX_LEN> 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, finite values whose scaled magnitude fits in uint32_t.
// For 3 decimals that's |value| < ~4.29e6; larger totals fall through to snprintf.
if (accuracy_decimals <= 3 && std::isfinite(value)) {
const uint32_t mult = small_pow10(accuracy_decimals);
if (std::fabs(value) < static_cast<float>(UINT32_MAX) / mult) {
return value_accuracy_to_buf_fast(buf.data(), value, accuracy_decimals, mult);
}
}
// Fallback for NaN/Inf/high accuracy/out-of-range
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<size_t>(len) >= buf.size() ? buf.size() - 1 : static_cast<size_t>(len);
}
size_t value_accuracy_with_uom_to_buf(std::span<char, VALUE_ACCURACY_MAX_LEN> buf, float value,
int8_t accuracy_decimals, StringRef unit_of_measurement) {
if (unit_of_measurement.empty()) {
return value_accuracy_to_buf(buf, value, accuracy_decimals);
size_t len = value_accuracy_to_buf(buf, value, accuracy_decimals);
if (len == 0 || unit_of_measurement.empty()) {
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<size_t>(len) >= buf.size() ? buf.size() - 1 : static_cast<size_t>(len);
char *end = buf_append_sep_str(buf.data() + len, buf.size() - len, ' ', unit_of_measurement.c_str(),
unit_of_measurement.size());
return static_cast<size_t>(end - buf.data());
}
int8_t step_to_accuracy_decimals(float step) {
+35
View File
@@ -1311,6 +1311,29 @@ inline char *int8_to_str(char *buf, int8_t val) {
return buf;
}
/// Append a separator char and a string to a buffer, respecting remaining space.
/// Returns pointer past last char written. The buffer is always null-terminated
/// when remaining >= 1 (even on the no-room early-return), so callers always get
/// a valid C string.
inline char *buf_append_sep_str(char *buf, size_t remaining, char separator, const char *str, size_t str_len) {
if (remaining < 2) {
if (remaining >= 1) {
*buf = '\0';
}
return buf;
}
*buf++ = separator;
remaining--;
size_t copy_len = std::min(str_len, remaining - 1);
memcpy(buf, str, copy_len);
buf += copy_len;
*buf = '\0';
return buf;
}
/// Return 10^n for small non-negative n (0-3) as uint32_t, avoiding float.
inline uint32_t small_pow10(int8_t n) { return n == 3 ? 1000 : n == 2 ? 100 : n == 1 ? 10 : 1; }
/// Minimum buffer size for uint32_to_str: 10 digits + null terminator.
static constexpr size_t UINT32_MAX_STR_SIZE = 11;
@@ -1326,6 +1349,18 @@ inline size_t uint32_to_str(std::span<char, UINT32_MAX_STR_SIZE> buf, uint32_t v
return static_cast<size_t>(end - buf.data());
}
/// Write fractional digits with leading zeros to buffer (internal, no size check).
/// frac is the fractional value, divisor is the highest place value (e.g. 100 for 3 digits).
/// Returns pointer past last char written.
inline char *frac_to_str_unchecked(char *buf, uint32_t frac, uint32_t divisor) {
while (divisor > 0) {
*buf++ = '0' + static_cast<char>(frac / divisor);
frac %= divisor;
divisor /= 10;
}
return buf;
}
/// Format byte array as lowercase hex to buffer (base implementation).
char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length);
+96
View File
@@ -117,4 +117,100 @@ TEST(FormatHexChar, UppercaseDigits) {
EXPECT_EQ(format_hex_pretty_char(15), 'F');
}
// --- small_pow10() ---
TEST(SmallPow10, Zero) { EXPECT_EQ(small_pow10(0), 1u); }
TEST(SmallPow10, One) { EXPECT_EQ(small_pow10(1), 10u); }
TEST(SmallPow10, Two) { EXPECT_EQ(small_pow10(2), 100u); }
TEST(SmallPow10, Three) { EXPECT_EQ(small_pow10(3), 1000u); }
// --- frac_to_str_unchecked() ---
TEST(FracToStr, OneDigit) {
char buf[8];
char *end = frac_to_str_unchecked(buf, 5, 1);
*end = '\0';
EXPECT_STREQ(buf, "5");
EXPECT_EQ(end - buf, 1);
}
TEST(FracToStr, TwoDigits) {
char buf[8];
char *end = frac_to_str_unchecked(buf, 46, 10);
*end = '\0';
EXPECT_STREQ(buf, "46");
}
TEST(FracToStr, ThreeDigits) {
char buf[8];
char *end = frac_to_str_unchecked(buf, 456, 100);
*end = '\0';
EXPECT_STREQ(buf, "456");
EXPECT_EQ(end - buf, 3);
}
TEST(FracToStr, LeadingZeros) {
char buf[8];
char *end = frac_to_str_unchecked(buf, 1, 100);
*end = '\0';
EXPECT_STREQ(buf, "001");
end = frac_to_str_unchecked(buf, 5, 10);
*end = '\0';
EXPECT_STREQ(buf, "05");
}
TEST(FracToStr, AllZeros) {
char buf[8];
char *end = frac_to_str_unchecked(buf, 0, 100);
*end = '\0';
EXPECT_STREQ(buf, "000");
end = frac_to_str_unchecked(buf, 0, 1);
*end = '\0';
EXPECT_STREQ(buf, "0");
}
TEST(FracToStr, ZeroDivisor) {
char buf[8];
buf[0] = 'X';
char *end = frac_to_str_unchecked(buf, 0, 0);
EXPECT_EQ(end, buf); // writes nothing
}
// --- buf_append_sep_str() ---
TEST(BufAppendSepStr, Basic) {
char buf[32] = "23.46";
char *start = buf + 5;
char *end = buf_append_sep_str(start, sizeof(buf) - 5, ' ', "°C", 3);
EXPECT_STREQ(buf, "23.46 °C");
EXPECT_EQ(end - buf, 9); // "°C" is 3 bytes (UTF-8)
}
TEST(BufAppendSepStr, EmptyString) {
char buf[32] = "100";
char *start = buf + 3;
char *end = buf_append_sep_str(start, sizeof(buf) - 3, ' ', "", 0);
EXPECT_STREQ(buf, "100 ");
EXPECT_EQ(end - start, 1); // just the separator
}
TEST(BufAppendSepStr, NoRoom) {
char buf[8] = "1234567";
char *start = buf + 7;
char *end = buf_append_sep_str(start, 1, ' ', "unit", 4);
EXPECT_EQ(end, start); // nothing written
}
TEST(BufAppendSepStr, Truncation) {
char buf[8] = "val";
char *start = buf + 3;
// remaining = 5, separator takes 1, so 3 chars of string fit + null
char *end = buf_append_sep_str(start, 5, ' ', "longunit", 8);
*end = '\0';
EXPECT_STREQ(buf, "val lon");
EXPECT_EQ(end - buf, 7);
}
} // namespace esphome::core::testing
@@ -0,0 +1,237 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <span>
#include <string>
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
namespace esphome::core::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<char, VALUE_ACCURACY_MAX_LEN> 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<int>(acc);
}
}
}
// --- Return value (length) ---
TEST(ValueAccuracyToBuf, ReturnsCorrectLength) {
char buf[VALUE_ACCURACY_MAX_LEN];
std::span<char, VALUE_ACCURACY_MAX_LEN> 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);
}
TEST(ValueAccuracyToBuf, NegativeZero) {
// Hand-rolled formatter must preserve snprintf's sign-of-zero behavior.
EXPECT_EQ(va_to_string(-0.0f, 2), va_reference(-0.0f, 2));
EXPECT_EQ(va_to_string(-0.0f, 0), va_reference(-0.0f, 0));
// Tiny negative that rounds to zero at this precision must still render as "-0.00".
EXPECT_EQ(va_to_string(-0.001f, 2), va_reference(-0.001f, 2));
}
TEST(ValueAccuracyToBuf, OverflowFallsBackToSnprintf) {
// |value| * 10^acc must exceed UINT32_MAX to exercise the snprintf fallback path.
EXPECT_EQ(va_to_string(1.0e7f, 3), va_reference(1.0e7f, 3));
EXPECT_EQ(va_to_string(-1.0e7f, 3), va_reference(-1.0e7f, 3));
EXPECT_EQ(va_to_string(5.0e9f, 0), va_reference(5.0e9f, 0));
}
// --- value_accuracy_with_uom_to_buf ---
static std::string va_uom_to_string(float value, int8_t accuracy_decimals, const char *uom) {
char buf[VALUE_ACCURACY_MAX_LEN];
std::span<char, VALUE_ACCURACY_MAX_LEN> sp(buf);
StringRef ref(uom);
size_t len = value_accuracy_with_uom_to_buf(sp, value, accuracy_decimals, ref);
return std::string(buf, len);
}
static std::string va_uom_reference(float value, int8_t accuracy_decimals, const char *uom) {
char buf[VALUE_ACCURACY_MAX_LEN];
if (!uom || *uom == '\0') {
snprintf(buf, sizeof(buf), "%.*f", accuracy_decimals, value);
} else {
snprintf(buf, sizeof(buf), "%.*f %s", accuracy_decimals, value, uom);
}
return std::string(buf);
}
TEST(ValueAccuracyWithUomToBuf, BasicWithUnit) {
EXPECT_EQ(va_uom_to_string(23.456f, 2, "°C"), va_uom_reference(23.456f, 2, "°C"));
EXPECT_EQ(va_uom_to_string(1013.25f, 2, "hPa"), va_uom_reference(1013.25f, 2, "hPa"));
EXPECT_EQ(va_uom_to_string(-40.0f, 1, "°F"), va_uom_reference(-40.0f, 1, "°F"));
EXPECT_EQ(va_uom_to_string(100.0f, 0, "%"), va_uom_reference(100.0f, 0, "%"));
}
TEST(ValueAccuracyWithUomToBuf, EmptyUnit) {
EXPECT_EQ(va_uom_to_string(23.456f, 2, ""), "23.46");
EXPECT_EQ(va_uom_to_string(0.0f, 1, ""), "0.0");
}
TEST(ValueAccuracyWithUomToBuf, ReturnsCorrectLength) {
char buf[VALUE_ACCURACY_MAX_LEN];
std::span<char, VALUE_ACCURACY_MAX_LEN> sp(buf);
StringRef ref("°C");
size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref);
EXPECT_EQ(strlen(buf), len);
EXPECT_EQ(len, strlen("23.46 °C"));
}
TEST(ValueAccuracyWithUomToBuf, NearBufferLimitTruncates) {
// Build a unit long enough that value + " " + unit exceeds VALUE_ACCURACY_MAX_LEN.
// "23.46" (5) + " " (1) + unit -> must cap at buf.size()-1 and stay null-terminated.
std::string long_unit(VALUE_ACCURACY_MAX_LEN, 'U');
char buf[VALUE_ACCURACY_MAX_LEN];
std::span<char, VALUE_ACCURACY_MAX_LEN> sp(buf);
StringRef ref(long_unit.c_str());
size_t len = value_accuracy_with_uom_to_buf(sp, 23.46f, 2, ref);
EXPECT_LT(len, VALUE_ACCURACY_MAX_LEN);
EXPECT_EQ(strlen(buf), len);
// Should begin with the formatted value and a separator.
EXPECT_EQ(std::string(buf, 6), "23.46 ");
}
TEST(ValueAccuracyWithUomToBuf, MatchesSnprintf) {
const char *units[] = {"°C", "hPa", "%", "W", "kWh", "m/s"};
float values[] = {0.0f, 23.456f, -40.0f, 1013.25f, 100.0f};
int8_t accs[] = {0, 1, 2, 3};
for (const char *u : units) {
for (float v : values) {
for (int8_t a : accs) {
EXPECT_EQ(va_uom_to_string(v, a, u), va_uom_reference(v, a, u))
<< "value=" << v << " acc=" << static_cast<int>(a) << " uom=" << u;
}
}
}
}
} // namespace esphome::core::testing