perf(scroll): pace frames to the panel, not to a fixed sleep

Scrolling ran at 44-46 fps on a 2x128x64 chain and 14-17% of frames took
41-53ms, which reads as judder. Four independent causes, each measured on
the hardware; details and the diagnostic recipe are in
docs/SCROLL_PERFORMANCE.md.

The high-FPS loop slept a flat 8ms after every render. display() has
already blocked on the panel's vsync by then, so that sleep was added to a
wait that had happened: ~4ms of render plus 8ms put each iteration at ~12ms
against a 10ms refresh grid, so every swap missed a refresh and the loop
settled at 50fps while asking for 125 -- with no headroom, so a further
14% of frames slipped again. It now sleeps only the remainder, with a 1ms
floor so plugin threads still get the GIL.

ScrollHelper stepped position on a wall clock at 1/scroll_delay steps per
second. Plugins set scroll_delay to the frame period, so that comparison
sat exactly on its own threshold: a frame arriving a hair early moved zero
pixels and rendered an identical frame, dirty-tracking skipped the swap, it
returned in ~2ms, and the beat repeated. No scroll_delay value tunes that
out -- a shorter delay trades stalled frames for periodic double-steps.
Both modes now accumulate elapsed time at the same configured speed, so
position stays proportional to real time.

Sub-pixel blending goes back to off by default. It renders a half-step by
mixing two adjacent columns, which on a coarse panel showing pixel-font
text alternates crisp and smeared frames and reads as shimmer -- visibly
worse than integer stepping on the hardware. Vegas mode still opts in.

disk_cache uses orjson when importable, falling back to the stdlib. Encoding
a ~1MB record drops from 14.8ms to 5.4ms end-to-end, and that work holds the
GIL while a marquee is on screen. display_manager also checksummed the whole
framebuffer twice per frame (dirty tracking, then the preview snapshot); the
snapshot now takes the checksum the caller already computed.

New src/common/scroll_config.py resolves scroll settings in one place. Five
ticker plugins each hand-rolled this and disagreed: odds-ticker ranked the
deprecated scroll_pixels_per_second above the documented scroll_speed/delay
pair, and because that key carries a schema default the documented settings
were dead for every user (ChuckBuilds/ledmatrix-plugins#408), while
ledmatrix-leaderboard read the same key only as a fallback. The resolver also
warns when a speed will not advance a whole number of pixels per refresh,
which is the property that actually determines whether a scroll looks smooth.

scripts/build_rgbmatrix_nogil.sh rebuilds the rgbmatrix binding so it
releases the GIL. Upstream declares SwapOnVSync without nogil, unlike
SetPixel/Clear/Fill beside it, so the render thread held the GIL for the
whole vsync wait and starved background threads into long uninterruptible
bursts. The script patches, builds and self-verifies into a scratch tree;
--install backs up the original and rolls back if the service does not come
back healthy.

Measured after: 100 fps locked, no stalls observed, render thread down from
51% to 19% of one core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-03 17:45:45 -04:00
co-authored by Claude Opus 5
parent eae063700f
commit 7b4b07e313
10 changed files with 1011 additions and 29 deletions
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the shared scroll configuration resolver."""
import logging
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_config import ( # noqa: E402
DEFAULT_PIXELS_PER_SECOND,
MAX_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
ScrollSettings,
configure,
refresh_hz_from_config,
resolve,
)
class FakeHelper:
"""Records what configure() applied."""
def __init__(self, with_optional=True):
self.speed = None
self.frame_based = None
self.target_fps = None
if not with_optional:
del FakeHelper.set_frame_based_scrolling
del FakeHelper.set_target_fps
def set_scroll_speed(self, speed):
self.speed = speed
def set_frame_based_scrolling(self, enabled):
self.frame_based = enabled
def set_target_fps(self, fps):
self.target_fps = fps
class MinimalHelper:
"""An older helper exposing only set_scroll_speed."""
def __init__(self):
self.speed = None
def set_scroll_speed(self, speed):
self.speed = speed
class TestPrecedence:
def test_display_options_pair_wins(self):
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
assert s.pixels_per_second == 100.0
assert s.source == "display_options.scroll_speed/delay"
def test_display_block_used_when_options_absent(self):
s = resolve({"display": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
assert s.pixels_per_second == 200.0
assert s.source == "display.scroll_speed/delay"
def test_root_pair_used_when_both_blocks_absent(self):
s = resolve({"scroll_speed": 1.0, "scroll_delay": 0.02})
assert s.pixels_per_second == 50.0
def test_pixels_per_second_used_when_no_pair_given(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 120.0}})
assert s.pixels_per_second == 120.0
assert s.source == "display_options.scroll_pixels_per_second"
def test_global_display_is_the_last_resort_before_default(self):
s = resolve({}, {"display": {"scroll_speed": 1.0, "scroll_delay": 0.005}})
assert s.pixels_per_second == 200.0
def test_default_when_nothing_configured(self):
s = resolve({}, {})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
assert s.source == "default"
class TestDeprecatedKeyCannotOverrideExplicitPair:
"""Regression for ChuckBuilds/ledmatrix-plugins#408.
odds-ticker ranked scroll_pixels_per_second above the documented
scroll_speed/scroll_delay pair. Because that key carries a schema default,
the documented settings became unreachable for every user and the ticker
silently ran at the default speed. The pair must win.
"""
def test_pair_beats_pixels_per_second_in_the_same_block(self):
s = resolve(
{
"display_options": {
"scroll_speed": 1.0,
"scroll_delay": 0.01,
"scroll_pixels_per_second": 50.0, # schema default
}
}
)
assert s.pixels_per_second == 100.0
assert "scroll_speed/delay" in s.source
def test_pair_beats_pixels_per_second_in_an_outer_block(self):
s = resolve(
{
"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01},
"scroll_pixels_per_second": 50.0,
}
)
assert s.pixels_per_second == 100.0
class TestMalformedValues:
@pytest.mark.parametrize("bad", [None, "fast", "", {}, [], float("nan")])
def test_unusable_speed_falls_through(self, bad):
s = resolve({"display_options": {"scroll_speed": bad, "scroll_delay": 0.01}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
@pytest.mark.parametrize("bad", [0, -5, 0.0])
def test_non_positive_values_fall_through(self, bad):
s = resolve({"display_options": {"scroll_pixels_per_second": bad}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
def test_booleans_are_not_treated_as_numbers(self):
s = resolve({"display_options": {"scroll_pixels_per_second": True}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
def test_zero_delay_does_not_divide_by_zero(self):
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0}})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
def test_non_dict_blocks_are_ignored(self):
s = resolve({"display_options": "nonsense", "display": 5})
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
class TestClamping:
def test_absurdly_fast_is_clamped(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 100000.0}})
assert s.pixels_per_second == MAX_PIXELS_PER_SECOND
assert s.warning and "clamped" in s.warning
def test_absurdly_slow_is_clamped(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 0.01}})
assert s.pixels_per_second == MIN_PIXELS_PER_SECOND
class TestWholePixelWarning:
"""The display rule: whole pixels per refresh, or it judders."""
def test_no_warning_when_speed_divides_evenly(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 100.0}}, refresh_hz=100)
assert s.warning is None
assert s.pixels_per_frame == pytest.approx(1.0)
def test_no_warning_at_an_integer_multiple(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 200.0}}, refresh_hz=100)
assert s.warning is None
def test_warns_on_a_half_pixel_per_frame(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 50.0}}, refresh_hz=100)
assert s.warning is not None
assert "judder" in s.warning
assert "100 px/s" in s.warning
def test_warns_on_a_fractional_multiple(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 130.0}}, refresh_hz=100)
assert s.warning is not None
def test_respects_a_non_default_refresh_rate(self):
s = resolve({"display_options": {"scroll_pixels_per_second": 150.0}}, refresh_hz=150)
assert s.warning is None
assert s.pixels_per_frame == pytest.approx(1.0)
class TestConfigure:
def test_applies_time_based_mode_and_speed(self):
helper = FakeHelper()
s = configure(helper, {"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
assert helper.speed == 100.0
assert helper.frame_based is False, "must not use the wall-clock step gate"
assert helper.target_fps == 100.0
assert s.pixels_per_second == 100.0
def test_works_against_a_helper_without_optional_methods(self):
helper = MinimalHelper()
configure(helper, {"display_options": {"scroll_pixels_per_second": 100.0}})
assert helper.speed == 100.0
def test_logs_the_warning_when_speed_will_judder(self, caplog):
helper = FakeHelper()
with caplog.at_level(logging.WARNING):
configure(helper, {"display_options": {"scroll_pixels_per_second": 50.0}})
assert any("judder" in r.getMessage() for r in caplog.records)
def test_returns_settings_describing_the_source(self):
s = configure(FakeHelper(), {"display_options": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
assert isinstance(s, ScrollSettings)
assert "200.0 px/s" in s.describe()
class TestRefreshFromConfig:
def test_reads_the_hardware_limit(self):
assert refresh_hz_from_config(
{"display": {"hardware": {"limit_refresh_rate_hz": 150}}}
) == 150.0
@pytest.mark.parametrize("cfg", [None, {}, {"display": {}}, {"display": {"hardware": {}}},
"nonsense", {"display": {"hardware": {"limit_refresh_rate_hz": None}}}])
def test_falls_back_to_the_default(self, cfg):
assert refresh_hz_from_config(cfg) == 100.0