[benchmark] Add light call and publish benchmarks (#15176)

This commit is contained in:
J. Nick Koston
2026-03-26 07:33:10 -10:00
committed by GitHub
parent 6898284361
commit 02e23eb386
3 changed files with 282 additions and 0 deletions
@@ -0,0 +1,28 @@
import esphome.codegen as cg
from esphome.components.light import generate_gamma_table
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# Light benchmarks need USE_LIGHT_GAMMA_LUT defined and a gamma table
# with external linkage that the benchmark .cpp can reference.
manifest.enable_codegen()
original_to_code = manifest.to_code
async def to_code(config):
await original_to_code(config)
cg.add_define("USE_LIGHT_GAMMA_LUT")
# Use the light component's own generate_gamma_table() so the
# benchmark stays in sync with any formula changes.
forward = generate_gamma_table(2.8)
values = ", ".join(f"0x{int(v):04X}" for v in forward)
# Use extern-visible (non-static) array so the benchmark .cpp
# can reference it via extern declaration.
cg.add_global(
cg.RawStatement(
f"extern const uint16_t bench_gamma_2_8_fwd[256] PROGMEM = {{{values}}};"
)
)
to_code.priority = original_to_code.priority
manifest.to_code = to_code
@@ -0,0 +1,253 @@
#include <benchmark/benchmark.h>
#include "esphome/components/light/light_output.h"
#include "esphome/components/light/light_state.h"
// Gamma 2.8 forward LUT generated by the light component's Python codegen
// (see tests/benchmarks/components/light/__init__.py which calls generate_gamma_table())
extern const uint16_t bench_gamma_2_8_fwd[256];
namespace esphome::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
static constexpr int kInnerIterations = 2000;
// Minimal LightOutput for benchmarking — no real hardware interaction.
class BenchLightOutput : public light::LightOutput {
public:
light::LightTraits get_traits() override { return this->traits_; }
void write_state(light::LightState * /*state*/) override {}
light::LightTraits traits_;
};
// Test subclass to access protected configure_entity_() for benchmark setup.
class TestLightState : public light::LightState {
public:
using LightState::LightState;
void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); }
};
// Helper to create a configured RGBWW light state for benchmarks.
// Note: setup() is not called (no preferences backend), so save_remote_values_()
// is effectively a no-op. This benchmarks the call/validation path, not persistence.
static void setup_rgbww_light(BenchLightOutput &output, TestLightState &light) {
output.traits_.set_supported_color_modes({light::ColorMode::RGB_COLD_WARM_WHITE});
output.traits_.set_min_mireds(153.0f);
output.traits_.set_max_mireds(500.0f);
light.configure("test_light");
light.set_default_transition_length(0);
light.set_gamma_correct(2.8f);
light.set_gamma_table(bench_gamma_2_8_fwd);
light.set_restore_mode(light::LIGHT_ALWAYS_OFF);
}
// --- LightCall::perform() with instant RGB color change (Home Assistant API path) ---
// Measures the full call path: validation, set_immediately_, publish, and save.
// HA sends color_mode explicitly since API 1.6.
static void LightCall_RGBInstant(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
// Turn on first so subsequent calls are color changes
light.make_call().set_state(true).set_brightness(1.0f).set_color_brightness(1.0f).set_transition_length(0).perform();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
float v = static_cast<float>(i % 256) / 255.0f;
light.make_call()
.set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE)
.set_red(v)
.set_green(1.0f - v)
.set_blue(v * 0.5f)
.set_transition_length(0)
.perform();
}
benchmark::DoNotOptimize(light.remote_values);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightCall_RGBInstant);
// --- LightCall::perform() turn on/off cycle (Home Assistant API path) ---
// HA sends color_mode explicitly since API 1.6, skipping compute_color_mode_().
static void LightCall_ToggleOnOff(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
light.make_call()
.set_state(i % 2 == 0)
.set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE)
.set_transition_length(0)
.perform();
}
benchmark::DoNotOptimize(light.remote_values);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightCall_ToggleOnOff);
// --- LightCall::perform() turn on/off via MQTT ---
// MQTT never sends color_mode, so compute_color_mode_() runs every call.
static void LightCall_ToggleOnOff_MQTT(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
light.make_call().set_state(i % 2 == 0).set_transition_length(0).perform();
}
benchmark::DoNotOptimize(light.remote_values);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightCall_ToggleOnOff_MQTT);
// --- LightCall::perform() with color temperature via MQTT ---
// Exercises the transform_parameters_() path that converts color_temperature
// to cold/warm white fractions. MQTT never sends color_mode, so this also
// hits compute_color_mode_() every call. Modern HA avoids this path entirely
// by converting color temp to CW/WW client-side.
static void LightCall_ColorTemperature_MQTT(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
// Sweep through color temperature range
float ct = 153.0f + static_cast<float>(i % 348);
light.make_call().set_color_temperature(ct).set_transition_length(0).perform();
}
benchmark::DoNotOptimize(light.remote_values);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightCall_ColorTemperature_MQTT);
// --- LightCall::perform() with 1s transition (Home Assistant API path) ---
// Exercises start_transition_() which allocates a LightTransformer.
// This is the default HA path when transition_length > 0.
static void LightCall_Transition(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
float v = static_cast<float>(i % 256) / 255.0f;
light.make_call()
.set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE)
.set_red(v)
.set_green(1.0f - v)
.set_blue(v * 0.5f)
.set_transition_length(1000)
.perform();
}
benchmark::DoNotOptimize(light.remote_values);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightCall_Transition);
// --- LightCall::perform() with cold/warm white (Home Assistant API path) ---
// Mirrors what modern HA sends: explicit color_mode with direct cold_white
// and warm_white values. HA converts color temp to CW/WW client-side for
// CWWW lights (API >= 1.6), so this is the primary HA path.
static void LightCall_ColdWarmWhite(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
light.make_call().set_state(true).set_brightness(1.0f).set_transition_length(0).perform();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
float frac = static_cast<float>(i % 256) / 255.0f;
light.make_call()
.set_color_mode(light::ColorMode::RGB_COLD_WARM_WHITE)
.set_cold_white(1.0f - frac)
.set_warm_white(frac)
.set_transition_length(0)
.perform();
}
benchmark::DoNotOptimize(light.remote_values);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightCall_ColdWarmWhite);
// --- LightState::publish_state() with a remote values listener ---
// Measures listener notification overhead.
static void LightPublish_WithListener(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
struct TestListener : public light::LightRemoteValuesListener {
void on_light_remote_values_update() override { count_++; }
uint64_t count_{0};
} listener;
light.add_remote_values_listener(&listener);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
light.publish_state();
}
benchmark::DoNotOptimize(listener.count_);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightPublish_WithListener);
// --- current_values_as_rgbww output conversion with gamma LUT ---
// Measures the output conversion path that real light drivers call
// from write_state() to get hardware PWM values, including gamma
// table lookups via the LUT generated by Python codegen.
static void LightOutput_RGBWW(benchmark::State &state) {
BenchLightOutput output;
TestLightState light(&output);
setup_rgbww_light(output, light);
light.make_call()
.set_state(true)
.set_brightness(0.8f)
.set_color_brightness(0.6f)
.set_red(1.0f)
.set_green(0.5f)
.set_blue(0.2f)
.set_cold_white(0.7f)
.set_warm_white(0.3f)
.set_transition_length(0)
.perform();
float r, g, b, cw, ww;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
light.current_values_as_rgbww(&r, &g, &b, &cw, &ww);
}
benchmark::DoNotOptimize(r);
benchmark::DoNotOptimize(cw);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(LightOutput_RGBWW);
} // namespace esphome::benchmarks
@@ -0,0 +1 @@
light: