Follow-up to the CallResult threading: move the 0-sentinel fallback
(if now_64 == 0, read the clock fresh) out of next_schedule_in and into
the single main-loop caller.
Effect on next_schedule_in: drops the uint32_t now parameter entirely
and removes the cold millis_64_from_(now) inlined clock read from the
function body. Verified in disassembly:
Scheduler::next_schedule_in 159 B -> 86 B (-46%)
The hot path is now just defer_empty + cleanup_ + top-of-heap compare +
return. No clock read anywhere in the function.
Application::loop handles the 0-sentinel with a single inline branch
right before the call; when call() fired items it invokes the public
Scheduler::millis_64_from() wrapper (new, exposes the existing protected
millis_64_from_ so callers can do 64-bit extension without platform
knowledge). Fast path (no items fired) stays branch-free.
Net code size: loop_task +64 B, next_schedule_in -73 B,
Scheduler::call unchanged -> -9 B total.
Scheduler::call() and Scheduler::next_schedule_in() both computed now_64 via
millis_64_from_(now) at the top of every main-loop iteration. On ESP32 with
USE_NATIVE_64BIT_TIME this is an esp_timer_get_time() MMIO read (~1us);
doing it twice per iteration is wasted work since the two calls happen
microseconds apart.
Thread the value through:
- Scheduler::call() now returns a CallResult { now, now_64 } struct instead
of just the advanced uint32_t now. When items fired, now_64 is set to 0
(sentinel) because execute_item_() only advances the uint32 and the local
now_64 is stale by the time we return.
- Scheduler::next_schedule_in() takes an optional now_64 parameter
(default 0). Non-zero values are trusted and used directly; the 0
sentinel triggers a fresh millis_64_from_(now) read.
- Application::scheduler_tick_() forwards the CallResult through.
- Application::loop() passes sched_result.now_64 to next_schedule_in().
Verified in the disassembly: on the fast path (no items fired),
next_schedule_in branches over its esp_timer_get_time call entirely via
`or a8, a4, a5; bnez a8, ...` on the two halves of now_64, jumping
straight to the next_exec comparison. When call() returned the 0 sentinel,
next_schedule_in falls through to the existing inlined
micros_to_millis(esp_timer_get_time()) sequence as before.
Bench callers (tests/benchmarks/core/bench_scheduler.cpp) and the external
test component (tests/integration/fixtures/.../scheduler_bulk_cleanup_component)
ignore the return value — no changes needed there.
Two small wins on the per-loop Scheduler::call path:
1. Mark the scheduler's `millis_64()` wrapper `ESPHOME_ALWAYS_INLINE`.
The inner `esphome::millis_64()` is already always_inline, but the
wrapper was not, so GCC emitted an out-of-line `$isra$0` clone with
its own `entry`/`retw` register-window prologue/epilogue at every
call site. Inlining removes the wrapper's call overhead and the
register-window thunk on ESP32.
2. Collapse the two atomic reads of `to_remove_` in `Scheduler::call`
into one. The previous sequence
cleanup_(); // loads to_remove_
if (to_remove_count_() >= MAX_...) ... // reloads to_remove_
produced `memw; l32i; beqz; memw; l32i; bltui` on the fast path
because the compiler cannot CSE across the `memw` barriers that
std::atomic<uint32_t>::load emits on Xtensa. Reading the counter
once and branching on the result leaves a single
`memw; l32i; beqz` on the common zero-case; the slow path
(cleanup_slow_path_ + re-read + optional full_cleanup) pays an
extra read but already holds the scheduler mutex.