mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
perf(display): throttle the per-frame update tick; check strips without building them (#731)
The frame loops and the dwell sleep run PluginManager.run_scheduled_updates() at most every 0.25 s instead of after every frame (the top of each loop pass still ticks unthrottled), and SportsScrollDisplay and the sync follower ask ScrollHelper.has_strip() instead of building cached_image just to see whether a strip exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""The render loop's scheduled-update pass, throttled per frame.
|
||||
|
||||
The frame loops called _tick_plugin_updates() after every frame, about 125
|
||||
times a second on a scroller, and each call ran
|
||||
PluginManager.run_scheduled_updates(): a copy of the plugin dict and several
|
||||
locks per plugin, to find that nothing was due (no interval is shorter than
|
||||
5 s). The frame loops and the dwell sleep now call
|
||||
_tick_plugin_updates_if_due(), which runs it at most once per
|
||||
PLUGIN_UPDATE_TICK_INTERVAL. The top of each loop pass still calls
|
||||
_tick_plugin_updates() unthrottled, because a plugin loaded, reloaded or
|
||||
enabled for on-demand there is due at once.
|
||||
"""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src import display_controller as dc_mod # noqa: E402
|
||||
from src.display_controller import DisplayController # noqa: E402
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch):
|
||||
now = {"t": 1000.0}
|
||||
fake = SimpleNamespace(monotonic=lambda: now["t"], time=lambda: now["t"])
|
||||
monkeypatch.setattr(dc_mod, "time", fake)
|
||||
return now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dc():
|
||||
controller = object.__new__(DisplayController)
|
||||
controller.plugin_manager = MagicMock()
|
||||
return controller
|
||||
|
||||
|
||||
def _calls(dc):
|
||||
return dc.plugin_manager.run_scheduled_updates.call_count
|
||||
|
||||
|
||||
class TestThrottle:
|
||||
def test_the_first_call_runs(self, dc, clock):
|
||||
dc._tick_plugin_updates_if_due()
|
||||
assert _calls(dc) == 1
|
||||
|
||||
def test_calls_inside_the_window_are_skipped(self, dc, clock):
|
||||
dc._tick_plugin_updates_if_due()
|
||||
for _ in range(30): # a scroller's frames, 8 ms apart
|
||||
clock["t"] += 0.008
|
||||
dc._tick_plugin_updates_if_due()
|
||||
assert clock["t"] - 1000.0 < dc.PLUGIN_UPDATE_TICK_INTERVAL
|
||||
assert _calls(dc) == 1
|
||||
|
||||
def test_it_runs_again_once_the_window_has_passed(self, dc, clock):
|
||||
dc._tick_plugin_updates_if_due()
|
||||
clock["t"] += dc.PLUGIN_UPDATE_TICK_INTERVAL
|
||||
dc._tick_plugin_updates_if_due()
|
||||
assert _calls(dc) == 2
|
||||
|
||||
def test_the_unthrottled_tick_always_runs_and_restarts_the_window(self, dc, clock):
|
||||
# The top of the loop pass: a plugin just (re)loaded is due now.
|
||||
dc._tick_plugin_updates_if_due()
|
||||
clock["t"] += 0.01
|
||||
dc._tick_plugin_updates()
|
||||
assert _calls(dc) == 2
|
||||
# ...and the frame right after it does not repeat the pass.
|
||||
clock["t"] += 0.01
|
||||
dc._tick_plugin_updates_if_due()
|
||||
assert _calls(dc) == 2
|
||||
|
||||
def test_no_plugin_manager(self, clock):
|
||||
controller = object.__new__(DisplayController)
|
||||
controller.plugin_manager = None
|
||||
controller._tick_plugin_updates_if_due() # must not raise
|
||||
controller._tick_plugin_updates()
|
||||
|
||||
def test_a_failing_pass_is_contained_and_still_throttled(self, dc, clock):
|
||||
dc.plugin_manager.run_scheduled_updates.side_effect = RuntimeError("boom")
|
||||
dc._tick_plugin_updates_if_due()
|
||||
dc._tick_plugin_updates_if_due()
|
||||
assert _calls(dc) == 1
|
||||
|
||||
def test_the_vegas_tick_is_not_throttled(self, dc, clock):
|
||||
# Vegas's own update thread calls the plugin manager directly.
|
||||
dc.vegas_coordinator = None
|
||||
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = []
|
||||
dc._tick_plugin_updates()
|
||||
dc._tick_plugin_updates_for_vegas()
|
||||
dc._tick_plugin_updates_for_vegas()
|
||||
assert dc.plugin_manager.run_scheduled_updates_with_changes.call_count == 2
|
||||
|
||||
|
||||
class TestInTheRunLoop:
|
||||
"""The real run() on the golden-trace harness's fake clock."""
|
||||
|
||||
@pytest.fixture
|
||||
def run(self, tmp_path):
|
||||
h = RunLoopHarness(tmp_path, horizon=40)
|
||||
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=10, enable_scrolling=True))
|
||||
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
|
||||
ticks = []
|
||||
h.pm.run_scheduled_updates = lambda: ticks.append(round(h.clock.rel(), 3))
|
||||
h.run()
|
||||
passes = sorted({t for t, kind, _s, _d in h.events if kind == "pass"})
|
||||
frames = [t for t, kind, s, _d in h.events if kind in ("first", "frame") and s == "ticker"]
|
||||
return SimpleNamespace(ticks=ticks, passes=passes, frames=frames,
|
||||
interval=h.controller.PLUGIN_UPDATE_TICK_INTERVAL)
|
||||
|
||||
def test_a_scroller_ticks_a_few_times_a_second_not_every_frame(self, run):
|
||||
ticker_frames = len(run.frames)
|
||||
assert ticker_frames > 500 # 10 s screens at 125 Hz, twice
|
||||
# 40 s at four a second, plus one per pass.
|
||||
assert len(run.ticks) <= 40 / run.interval + len(run.passes) + 1
|
||||
|
||||
def test_ticks_are_never_closer_than_the_floor_except_at_a_pass(self, run):
|
||||
passes = set(run.passes)
|
||||
for prev, cur in zip(run.ticks, run.ticks[1:]):
|
||||
if cur - prev < run.interval - 1e-6:
|
||||
assert cur in passes, (prev, cur)
|
||||
|
||||
def test_every_pass_ticks_at_once(self, run):
|
||||
# Unthrottled: the frame loop ticked just before the screen ended, and
|
||||
# the pass ticks again anyway, so a plugin it just loaded is not kept
|
||||
# waiting.
|
||||
ticks = set(run.ticks)
|
||||
for t in run.passes:
|
||||
assert t in ticks, t
|
||||
assert any(cur - prev < run.interval
|
||||
for prev, cur in zip(run.ticks, run.ticks[1:]))
|
||||
Reference in New Issue
Block a user