[core] Template micros_to_millis for both 32/64-bit results

Make micros_to_millis a template on return type (default uint32_t).
millis_64() now calls micros_to_millis<uint64_t>() to eliminate
__udivdi3 there too — same Euclidean decomposition, just with a
32x32->64 multiply for hi*Q instead of truncating.

Add 64-bit variant tests (140 total, all passing).
This commit is contained in:
J. Nick Koston
2026-03-02 00:11:02 -10:00
parent 99aa5043a3
commit df57c21368
4 changed files with 144 additions and 71 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ namespace esphome {
void HOT yield() { vPortYield(); }
uint32_t IRAM_ATTR HOT millis() { return micros_to_millis(static_cast<uint64_t>(esp_timer_get_time())); }
uint64_t HOT millis_64() { return static_cast<uint64_t>(esp_timer_get_time()) / 1000ULL; }
uint64_t HOT millis_64() { return micros_to_millis<uint64_t>(static_cast<uint64_t>(esp_timer_get_time())); }
void HOT delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); }
uint32_t IRAM_ATTR HOT micros() { return (uint32_t) esp_timer_get_time(); }
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
+1 -1
View File
@@ -11,7 +11,7 @@
namespace esphome {
void HOT yield() { ::yield(); }
uint64_t millis_64() { return time_us_64() / 1000ULL; }
uint64_t millis_64() { return micros_to_millis<uint64_t>(time_us_64()); }
uint32_t HOT millis() { return micros_to_millis(time_us_64()); }
void HOT delay(uint32_t ms) { ::delay(ms); }
uint32_t HOT micros() { return ::micros(); }
+9 -4
View File
@@ -599,8 +599,12 @@ template<std::integral T> constexpr uint32_t fnv1a_hash_extend(uint32_t hash, T
constexpr uint32_t fnv1a_hash(const char *str) { return fnv1a_hash_extend(FNV1_OFFSET_BASIS, str); }
inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str()); }
/// Convert a 64-bit microsecond count to a 32-bit millisecond count without
/// calling __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz).
/// Convert a 64-bit microsecond count to milliseconds without calling
/// __udivdi3 (software 64-bit divide, ~1200 ns on Xtensa @ 240 MHz).
///
/// Returns uint32_t by default (for millis()), or uint64_t when requested
/// (for millis_64()). The only difference is whether hi * Q is truncated
/// to 32 bits or widened to 64.
///
/// On 32-bit targets, GCC does not optimize 64-bit constant division into a
/// multiply-by-reciprocal. Since 1000 = 8 * 125, we first right-shift by 3
@@ -618,7 +622,7 @@ inline uint32_t fnv1a_hash(const std::string &str) { return fnv1a_hash(str.c_str
///
/// See: https://en.wikipedia.org/wiki/Euclidean_division
/// See: https://ridiculousfish.com/blog/posts/labor-of-division-episode-iii.html
inline constexpr ESPHOME_ALWAYS_INLINE uint32_t micros_to_millis(uint64_t us) {
template<typename ReturnT = uint32_t> inline constexpr ESPHOME_ALWAYS_INLINE ReturnT micros_to_millis(uint64_t us) {
constexpr uint32_t D = 125U;
constexpr uint32_t Q = static_cast<uint32_t>((1ULL << 32) / D); // 34359738
constexpr uint32_t R = static_cast<uint32_t>((1ULL << 32) % D); // 46
@@ -629,7 +633,8 @@ inline constexpr ESPHOME_ALWAYS_INLINE uint32_t micros_to_millis(uint64_t us) {
// Combine remainder term: hi * (2^32 % 125) + lo
uint32_t adj = hi * R + lo;
// If adj overflowed, the true value is 2^32 + adj; apply the identity again
return hi * Q + (adj < lo ? (adj + R) / D + Q : adj / D);
// static_cast<ReturnT>(hi) widens to 64-bit when ReturnT=uint64_t, preserving upper bits of hi*Q
return static_cast<ReturnT>(hi) * Q + (adj < lo ? (adj + R) / D + Q : adj / D);
}
/// Return a random 32-bit unsigned integer.
+133 -65
View File
@@ -2,7 +2,7 @@
Verifies that the Python equivalent of the C++ micros_to_millis() helper
in esphome/core/helpers.h matches the reference (us // 1000) across
edge cases and overflow boundaries.
edge cases and overflow boundaries. Tests both 32-bit and 64-bit variants.
"""
import pytest
@@ -12,10 +12,11 @@ D = 125
Q = (1 << 32) // D # 34359738
R = (1 << 32) % D # 46
UINT32_MAX = 0xFFFFFFFF
UINT64_MAX = 0xFFFFFFFFFFFFFFFF
def micros_to_millis(us: int) -> int:
"""Convert microseconds to milliseconds using Euclidean decomposition."""
"""Convert microseconds to 32-bit milliseconds using Euclidean decomposition."""
x = us >> 3
lo = x & UINT32_MAX
hi = (x >> 32) & UINT32_MAX
@@ -25,92 +26,150 @@ def micros_to_millis(us: int) -> int:
return (hi * Q + adj // D) & UINT32_MAX
def reference(us: int) -> int:
"""Reference implementation: truncated 32-bit result of us / 1000."""
def micros_to_millis_64(us: int) -> int:
"""Convert microseconds to 64-bit milliseconds using Euclidean decomposition."""
x = us >> 3
lo = x & UINT32_MAX
hi = (x >> 32) & UINT32_MAX
adj = (hi * R + lo) & UINT32_MAX
if adj < lo:
return (hi * Q + (adj + R) // D + Q) & UINT64_MAX
return (hi * Q + adj // D) & UINT64_MAX
def reference_32(us: int) -> int:
"""Reference: truncated 32-bit result of us / 1000."""
return (us // 1000) & UINT32_MAX
@pytest.mark.parametrize(
"us",
[
0,
1,
999,
1000,
1001,
7999,
8000,
8001,
999_999,
1_000_000,
UINT32_MAX - 1,
UINT32_MAX,
UINT32_MAX + 1,
],
ids=lambda v: f"us={v}",
)
def test_small_and_boundary_values(us):
assert micros_to_millis(us) == reference(us)
def reference_64(us: int) -> int:
"""Reference: 64-bit result of us / 1000."""
return us // 1000
@pytest.mark.parametrize(
"hi",
[1, 2, 100, 603, 1000, 5000, 10000, 14685, 0xFFFF],
ids=lambda v: f"hi={v}",
)
@pytest.mark.parametrize(
"lo_offset",
[0, 1, 999, UINT32_MAX - 999, UINT32_MAX],
ids=lambda v: f"lo={v}",
)
def test_hi_lo_combinations(hi, lo_offset):
BOUNDARY_VALUES = [
0,
1,
999,
1000,
1001,
7999,
8000,
8001,
999_999,
1_000_000,
UINT32_MAX - 1,
UINT32_MAX,
UINT32_MAX + 1,
]
HI_VALUES = [1, 2, 100, 603, 1000, 5000, 10000, 14685, 0xFFFF]
LO_VALUES = [0, 1, 999, UINT32_MAX - 999, UINT32_MAX]
UPTIME_VALUES = [
2_592_000_000_000, # 30-day
31_536_000_000_000, # 1-year
3_200_000_000_000_000_000, # ~101,700 years (near safe limit)
]
# --- 32-bit tests ---
@pytest.mark.parametrize("us", BOUNDARY_VALUES, ids=lambda v: f"us={v}")
def test_32bit_boundary_values(us):
assert micros_to_millis(us) == reference_32(us)
@pytest.mark.parametrize("hi", HI_VALUES, ids=lambda v: f"hi={v}")
@pytest.mark.parametrize("lo_offset", LO_VALUES, ids=lambda v: f"lo={v}")
def test_32bit_hi_lo_combinations(hi, lo_offset):
us = (hi << 32) | lo_offset
assert micros_to_millis(us) == reference(us)
assert micros_to_millis(us) == reference_32(us)
@pytest.mark.parametrize(
"hi",
[1, 50, 100, 500, 1000, 5000],
ids=lambda v: f"hi={v}",
)
def test_carry_boundary(hi):
@pytest.mark.parametrize("hi", [1, 50, 100, 500, 1000, 5000], ids=lambda v: f"hi={v}")
def test_32bit_carry_boundary(hi):
"""Test around the adj overflow boundary (hi * R + lo > UINT32_MAX)."""
# After >>3, the decomposition uses R=46
# Carry boundary for original us: compute where adj wraps
# hi_shifted = (us >> 3) >> 32 = us >> 35
# We construct us such that hi_shifted = hi
base = hi << 35
hi_r = hi * R
if hi_r < UINT32_MAX:
threshold_lo = UINT32_MAX - hi_r
# Test around the boundary in the shifted domain
for lo in [threshold_lo - 1, threshold_lo, threshold_lo + 1]:
us = base | (lo << 3) # Scale lo back to us domain
assert micros_to_millis(us) == reference(us)
us = base | (lo << 3)
assert micros_to_millis(us) == reference_32(us)
@pytest.mark.parametrize(
"us",
[
# 30-day uptime in microseconds
2_592_000_000_000,
# 1-year uptime
31_536_000_000_000,
# Near safe limit (~101,700 years)
3_200_000_000_000_000_000,
],
ids=["30_days", "1_year", "near_safe_limit"],
"us", UPTIME_VALUES, ids=["30_days", "1_year", "near_safe_limit"]
)
def test_realistic_uptimes(us):
assert micros_to_millis(us) == reference(us)
def test_32bit_realistic_uptimes(us):
assert micros_to_millis(us) == reference_32(us)
def test_shift_boundary_mod8():
def test_32bit_shift_boundary_mod8():
"""Values where us % 8 varies — exercises the >>3 shift edge."""
for base in [0, 1000, 8000, UINT32_MAX, 603 << 32]:
for offset in range(8):
us = base + offset
assert micros_to_millis(us) == reference(us)
assert micros_to_millis(us) == reference_32(us)
# --- 64-bit tests ---
@pytest.mark.parametrize("us", BOUNDARY_VALUES, ids=lambda v: f"us={v}")
def test_64bit_boundary_values(us):
assert micros_to_millis_64(us) == reference_64(us)
@pytest.mark.parametrize("hi", HI_VALUES, ids=lambda v: f"hi={v}")
@pytest.mark.parametrize("lo_offset", LO_VALUES, ids=lambda v: f"lo={v}")
def test_64bit_hi_lo_combinations(hi, lo_offset):
us = (hi << 32) | lo_offset
assert micros_to_millis_64(us) == reference_64(us)
@pytest.mark.parametrize("hi", [1, 50, 100, 500, 1000, 5000], ids=lambda v: f"hi={v}")
def test_64bit_carry_boundary(hi):
"""Test around the adj overflow boundary for 64-bit result."""
base = hi << 35
hi_r = hi * R
if hi_r < UINT32_MAX:
threshold_lo = UINT32_MAX - hi_r
for lo in [threshold_lo - 1, threshold_lo, threshold_lo + 1]:
us = base | (lo << 3)
assert micros_to_millis_64(us) == reference_64(us)
@pytest.mark.parametrize(
"us", UPTIME_VALUES, ids=["30_days", "1_year", "near_safe_limit"]
)
def test_64bit_realistic_uptimes(us):
assert micros_to_millis_64(us) == reference_64(us)
def test_64bit_shift_boundary_mod8():
"""Values where us % 8 varies — exercises the >>3 shift edge."""
for base in [0, 1000, 8000, UINT32_MAX, 603 << 32]:
for offset in range(8):
us = base + offset
assert micros_to_millis_64(us) == reference_64(us)
def test_64bit_preserves_upper_bits():
"""Verify 64-bit variant does not truncate large results."""
# 30-day uptime: result > UINT32_MAX
us = 2_592_000_000_000
result = micros_to_millis_64(us)
assert result == 2_592_000_000
# 1-year uptime
us = 31_536_000_000_000
result = micros_to_millis_64(us)
assert result == 31_536_000_000
assert result > UINT32_MAX
# --- Shared tests ---
def test_constants_match():
@@ -125,4 +184,13 @@ def test_constexpr_values():
assert micros_to_millis(0) == 0
assert micros_to_millis(999) == 0
assert micros_to_millis(1000) == 1
assert micros_to_millis(2_592_000_000_000) == 2_592_000_000
assert micros_to_millis_64(0) == 0
assert micros_to_millis_64(999) == 0
assert micros_to_millis_64(1000) == 1
assert micros_to_millis_64(2_592_000_000_000) == 2_592_000_000
def test_32bit_and_64bit_agree_when_result_fits():
"""Both variants agree when result fits in 32 bits."""
for us in [0, 1000, 999_999, UINT32_MAX]:
assert micros_to_millis(us) == micros_to_millis_64(us)