Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .core_files.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ core: &core
- requirements.txt
- setup.cfg

# Performance benchmark suite (CodSpeed). Only gates the benchmark job; kept out
# of the `any` aggregate below so it does not pull in the full test suite.
benchmarks: &benchmarks
- benchmarks/**
- requirements_test.txt

# Our base platforms, that are used by other integrations
base_platforms: &base_platforms
- homeassistant/components/ai_task/**
Expand Down
41 changes: 41 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,47 @@ jobs:
python --version
mypy --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})

benchmarks:
name: Run benchmarks
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write # OIDC token CodSpeed mints (no CODSPEED_TOKEN secret)
needs:
- info
- base
# Run only when core code or the benchmark suite itself changed. Skipped on
# forks, where the OIDC token CodSpeed needs is unavailable. Pushes to dev
# that touch core refresh the CodSpeed baseline.
if: >-
needs.info.outputs.lint_only != 'true'
&& (github.event_name != 'pull_request'
|| !github.event.pull_request.head.repo.fork)
&& (contains(fromJSON(needs.info.outputs.core), 'core')
|| contains(fromJSON(needs.info.outputs.core), 'benchmarks'))
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python and restore venv
id: python
uses: $/.github/actions/restore-or-build-venv
with:
uv-version: ${{ needs.info.outputs.uv_version }}
python-version: ${{ needs.info.outputs.default_python }}
python-cache-key: ${{ needs.info.outputs.python_cache_key }}
uv-cache-dir: ${{ env.UV_CACHE_DIR }}
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
- name: Run benchmarks
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
with:
mode: simulation,memory
# No token: auth uses the OIDC id-token minted by the job permissions.
run: |
. venv/bin/activate
pytest benchmarks --codspeed --no-cov -o addopts=""

prepare-pytest-full:
name: Split tests for full run
runs-on: ubuntu-24.04
Expand Down
1 change: 1 addition & 0 deletions benchmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""CodSpeed performance benchmarks for Home Assistant core hot paths."""
45 changes: 45 additions & 0 deletions benchmarks/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Shared fixtures for the CodSpeed benchmark suite.

These benchmarks live outside ``tests`` on purpose: ``testpaths`` only points at
``tests``, so the regular suite never collects them. CodSpeed runs them with
``pytest benchmarks --codspeed`` and tracks the results per pull request.
"""

from collections.abc import AsyncGenerator, Callable

import pytest

from homeassistant.core import HomeAssistant
from tests.common import async_test_home_assistant


@pytest.fixture
async def hass() -> AsyncGenerator[HomeAssistant]:
"""Return a running Home Assistant instance for benchmarking.

Most hot paths under test (``async_fire``, ``async_set``, ``async_render``)
are ``@callback`` methods, so the benchmark fixture can drive them
synchronously from within the running loop.
"""
async with async_test_home_assistant() as hass:
Comment on lines +8 to +24
yield hass
Comment thread
frenck marked this conversation as resolved.


@pytest.fixture
def populate_states(hass: HomeAssistant) -> Callable[[int], None]:
"""Return a helper that fills the state machine with ``count`` sensors.

Used by the scaling benchmarks to measure a path at several sizes, so an
algorithmic regression shows up as the curve bending instead of hiding
behind a single constant-factor number.
"""

def _populate(count: int) -> None:
for index in range(count):
hass.states.async_set(
f"sensor.bench_{index}",
str(index),
{"friendly_name": f"Bench {index}", "unit_of_measurement": "W"},
)

return _populate
186 changes: 186 additions & 0 deletions benchmarks/test_core_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"""CodSpeed benchmarks for the event bus and event helpers.

The event bus carries every state change, and ``async_track_state_change_event``
is the routing layer almost every automation, template and trigger sits on. A
regression in either is felt across the whole system.

Run locally with: ``pytest benchmarks --codspeed``.
"""

from collections.abc import Callable

import pytest
from pytest_codspeed import BenchmarkFixture

from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.event import async_call_later, async_track_state_change_event


@callback
def _noop(event: Event) -> None:
"""Do nothing, cheaply."""


def test_event_fire_no_listeners(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Fire an event nobody listens to (the bare dispatch cost)."""
benchmark(lambda: hass.bus.async_fire("benchmark_event", {"value": 1}))


@pytest.mark.parametrize("listeners", [1, 10])
def test_event_fire_callbacks(
benchmark: BenchmarkFixture, hass: HomeAssistant, listeners: int
) -> None:
"""Fire an event with N callback listeners that run inline."""
fired = 0

@callback
def listener(event: Event) -> None:
nonlocal fired
fired += 1

for _ in range(listeners):
hass.bus.async_listen("benchmark_event", listener)

benchmark(lambda: hass.bus.async_fire("benchmark_event", {"value": 1}))

# Each fire increments `fired` once per listener, but the benchmark may
# invoke the target multiple times (e.g. in walltime mode), so only the
# per-fire multiple is a stable invariant.
assert fired > 0
assert fired % listeners == 0


def test_event_fire_filtered_reject(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Fire an event whose listener is gated out by an event_filter.

The filter runs but the listener does not, so this isolates the filter
short-circuit cost from the listener body.
"""
fired = 0

@callback
def listener(event: Event) -> None:
nonlocal fired
fired += 1

@callback
def event_filter(event_data: dict) -> bool:
return False

hass.bus.async_listen("benchmark_event", listener, event_filter=event_filter)

benchmark(lambda: hass.bus.async_fire("benchmark_event", {"value": 1}))
Comment thread
frenck marked this conversation as resolved.

assert fired == 0


def test_state_change_tracked(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Fire a state change routed to a tracked entity's listener.

This is the real automation hot path: ``async_set`` fires
EVENT_STATE_CHANGED, the dispatcher does a dict lookup on the entity_id and
runs the inline callback.
"""
fired = 0

@callback
def listener(event: Event) -> None:
nonlocal fired
fired += 1

async_track_state_change_event(hass, "sensor.tracked", listener)
counter = 0

def _set() -> None:
nonlocal counter
counter += 1
hass.states.async_set("sensor.tracked", str(counter))

benchmark(_set)

assert fired


def test_state_change_untracked(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Fire a state change for an entity nobody tracks (the dict-miss path).

Tracking is installed for a different entity, so the dispatcher's lookup
misses and returns fast. This is the common case on a busy bus.
"""
async_track_state_change_event(hass, "sensor.tracked", _noop)
counter = 0

def _set() -> None:
nonlocal counter
counter += 1
hass.states.async_set("sensor.untracked", str(counter))

benchmark(_set)


def test_dispatcher_send_no_receivers(
benchmark: BenchmarkFixture, hass: HomeAssistant
) -> None:
"""Send a dispatcher signal with nobody connected (the bare dispatch cost)."""
benchmark(lambda: async_dispatcher_send(hass, "benchmark_signal", 1))


@pytest.mark.parametrize("receivers", [1, 10])
def test_dispatcher_send(
benchmark: BenchmarkFixture, hass: HomeAssistant, receivers: int
) -> None:
"""Send a dispatcher signal to N connected receivers."""
fired = 0

def _make_receiver() -> Callable[..., None]:
@callback
def receiver(*args: object) -> None:
nonlocal fired
fired += 1

return receiver

# async_dispatcher_connect keys receivers by the callable itself, so each
# connection needs its own object to actually register as a receiver.
for _ in range(receivers):
async_dispatcher_connect(hass, "benchmark_signal", _make_receiver())

benchmark(lambda: async_dispatcher_send(hass, "benchmark_signal", 1))
Comment thread
frenck marked this conversation as resolved.

# Each send increments `fired` once per receiver, but the benchmark may
# invoke the target multiple times (e.g. in walltime mode), so only the
# per-send multiple is a stable invariant.
assert fired > 0
assert fired % receivers == 0


def test_call_later_schedule(benchmark: BenchmarkFixture, hass: HomeAssistant) -> None:
"""Schedule a delayed callback and cancel it (the timer-tracking cost).

Cancelling inside the measured call keeps timers from piling up on the loop
across iterations.
"""
called = 0

@callback
def listener() -> None:
Comment thread
frenck marked this conversation as resolved.
nonlocal called
called += 1

def _schedule() -> None:
cancel: Callable[[], None] = async_call_later(hass, 60, listener)
cancel()

benchmark(_schedule)
Comment thread
frenck marked this conversation as resolved.

assert called == 0
Loading
Loading