feat(perf): time every presented frame, and a soak script to judge a rig

Each scroller already logs its own stats line, but in different formats,
per source, and Vegas logs a healthy window only at DEBUG. None of it
answers the question a release has to answer on each rig: over a long
run, how often did a moving frame reach the panel late?

Every frame reaches the panel through DisplayManager.update_display, so
it is timed there once, whoever drew it: the blit (SetImage), the vsync
wait, and the interval since the previous frame. The render thread only
appends a tuple. A worker thread aggregates cumulative counters and
histograms and rewrites /dev/shm/ledmatrix_frame_stats.json every 10s
(RAM, so no SD wear).

A frame due after `hold` refreshes that lands one or more refreshes
later is "late": the panel repeated the previous frame, a visible hitch.
Gaps of 250ms+ inside a scroll are "freezes" (recomposes, handovers,
blocking calls), counted separately so one handover does not read as 40
missed refreshes. Static frames, the first frame of a scroll and gaps
between scrolls are not timed. The refresh period is estimated from the
frames themselves.

scripts/frame_soak.py runs next to the service as any user, diffs two
snapshots over a run (default 10 minutes), optionally keeps the web
preview's viewer marker fresh, and exits non-zero above 0.1% late
frames. It also reports whether the loaded rgbmatrix binding releases
the GIL. Documented under "Soaking a rig" in docs/SCROLL_PERFORMANCE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 21:51:32 -04:00
co-authored by Claude Opus 5.5
parent f3894916a9
commit 3eb7a2e349
6 changed files with 848 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
"""System-wide frame timing: one set of numbers for every presented frame.
Each scroller already logs its own stats line (ScrollHelper.log_frame_rate,
the Vegas coordinator's "Vegas FPS"), but in different formats, per source,
and Vegas only logs a healthy window at DEBUG. None of that answers the
question a release has to answer on each rig: *over a long run, how often did
a moving frame reach the panel late?*
Every frame reaches the panel through ``DisplayManager.update_display``, so it
is recorded there, once, whoever drew it. The render thread only appends a
tuple; a worker thread aggregates, and every ``flush_interval`` seconds writes
cumulative counters and histograms to a small JSON file -- in ``/dev/shm`` where
it exists, so a stats file refreshed all day costs no SD-card writes.
``scripts/frame_soak.py`` reads it twice and reports the difference.
What is counted
---------------
Only intervals between two consecutive *scrolling* frames count: a static
screen that changes once a second has no timing to get wrong, and the first
frame of a scroll has no predecessor worth measuring against.
A frame held for ``hold`` refreshes should arrive ``hold`` refresh periods
after the one before it. One that arrives a whole refresh or more after that is
**late**: the panel showed the previous frame again, which on a moving strip is
a visible hitch. ``missed_refreshes`` sums how many refreshes late.
An interval of ``FREEZE_SECONDS`` or more is a **freeze** instead -- a
recompose, a plugin handover, a blocking call on the render thread. Those are
counted separately, both because they are a different fault and because
folding a single 400ms handover into the late count as "40 missed refreshes"
would drown the jitter the late count exists to measure. Intervals of
``GAP_SECONDS`` or more are not frames at all (one scroll ending, another
starting later) and are ignored.
The refresh period is estimated from the frames themselves: swaps that block
on vsync can only land on refresh boundaries, so the low end of
interval / hold is the period. It is the smallest per-window 10th percentile
seen so far, over windows with enough frames to trust.
"""
from __future__ import annotations
import json
import logging
import os
import queue
import sys
import tempfile
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
#: Bumped when a field changes meaning, so a reader can refuse stale files.
SCHEMA_VERSION = 1
#: Histogram resolution. 64ms of range covers any frame worth drawing a
#: distribution of; everything beyond lands in the last bucket.
BUCKET_MS = 0.25
BUCKET_COUNT = 256
#: See the module docstring.
FREEZE_SECONDS = 0.25
GAP_SECONDS = 1.0
#: A window needs this many scrolling frames before its refresh estimate is
#: trusted -- about a second of scrolling.
MIN_FRAMES_FOR_REFRESH = 90
FLUSH_INTERVAL = 10.0
#: Written by the display service, read by scripts/frame_soak.py and anything
#: else that wants the numbers. The web UI's viewer marker lives in /tmp; this
#: goes to RAM where there is some, since it is rewritten all day.
STATS_FILENAME = "ledmatrix_frame_stats.json"
def default_stats_path() -> str:
base = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir()
return os.path.join(base, STATS_FILENAME)
def _bucket(seconds: float) -> int:
index = int(seconds * 1000.0 / BUCKET_MS)
return min(max(index, 0), BUCKET_COUNT - 1)
def binding_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
The stock binding blocks in SwapOnVSync holding the GIL, which starves
every other thread for most of each frame (docs/SCROLL_PERFORMANCE.md).
scripts/build_rgbmatrix_nogil.sh rebuilds it, and the rebuilt module links
PyEval_SaveThread where the stock one never does -- a crude test, but the
only one that needs neither a probe on the panel nor the source tree the
module was built from. None when no hardware binding is loaded.
"""
module = sys.modules.get("rgbmatrix.core")
path = getattr(module, "__file__", None)
if not path:
return None
try:
with open(path, "rb") as handle:
return b"PyEval_SaveThread" in handle.read()
except OSError:
return None
def _pi_model() -> Optional[str]:
try:
with open("/proc/device-tree/model", "rb") as handle:
return handle.read().rstrip(b"\0").decode("ascii", "replace").strip()
except OSError:
return None
class FrameTimingRecorder:
"""Collects per-frame timings on the render thread; aggregates elsewhere.
``record`` is the only method the render thread calls, and it does no more
than compare two floats and append a tuple.
"""
def __init__(
self,
path: Optional[str] = None,
flush_interval: float = FLUSH_INTERVAL,
info: Optional[Dict[str, Any]] = None,
):
self.path = path or default_stats_path()
self.flush_interval = flush_interval
self.info = dict(info or {})
# Render-thread state.
self._pending: List[Tuple[float, float, float, int]] = []
self._static_frames = 0
self._previous: Optional[Tuple[float, bool, int]] = None
self._last_flush: Optional[float] = None
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
self._worker: Optional[threading.Thread] = None
# Worker-thread state. Nothing on the render thread reads these.
self.started = time.time()
self.refresh_period: Optional[float] = None
self.totals: Dict[str, Any] = {
"static_frames": 0,
"scroll_frames": 0,
"late_frames": 0,
"missed_refreshes": 0,
"late_by": {"1": 0, "2": 0, "3-5": 0, "6+": 0},
"freezes": 0,
"freeze_seconds": 0.0,
"worst_interval_ms": 0.0,
}
self.histograms: Dict[str, Dict[int, int]] = {
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
}
self._binding_gil: Optional[bool] = None
self._binding_checked = False
# -- render thread ------------------------------------------------------
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
presented_at: float) -> None:
"""One frame reached the panel.
:param blit: seconds spent copying the frame into the canvas.
:param wait: seconds SwapOnVSync blocked.
:param hold: the refreshes this frame was held for.
:param scrolling: whether a scroll was running when it was presented.
:param presented_at: ``time.perf_counter()`` when the swap returned.
"""
previous = self._previous
self._previous = (presented_at, scrolling, hold)
if not scrolling:
self._static_frames += 1
elif previous is not None and previous[1]:
interval = presented_at - previous[0]
if interval < GAP_SECONDS:
self._pending.append((interval, blit, wait, hold))
if self._last_flush is None:
self._last_flush = presented_at
elif presented_at - self._last_flush >= self.flush_interval:
self._hand_off()
self._last_flush = presented_at
def _hand_off(self) -> None:
batch, self._pending = self._pending, []
static, self._static_frames = self._static_frames, 0
self._queue.put((batch, static))
if self._worker is None or not self._worker.is_alive():
self._worker = threading.Thread(
target=self._run, daemon=True, name="frame-timing")
self._worker.start()
# -- worker thread ------------------------------------------------------
def _run(self) -> None:
while True:
batch, static = self._queue.get()
try:
self.aggregate(batch, static)
self.write()
except Exception: # never let telemetry take anything down
logger.debug("Frame timing flush failed", exc_info=True)
def aggregate(self, batch: List[Tuple[float, float, float, int]],
static: int) -> None:
"""Fold one window of frames into the running totals."""
totals = self.totals
totals["static_frames"] += static
per_hold = sorted(interval / max(1, hold)
for interval, _, _, hold in batch
if interval < FREEZE_SECONDS)
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
estimate = per_hold[len(per_hold) // 10]
if estimate > 0 and (self.refresh_period is None
or estimate < self.refresh_period):
self.refresh_period = estimate
period = self.refresh_period
histograms = self.histograms
for interval, blit, wait, hold in batch:
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
interval * 1000.0)
if interval >= FREEZE_SECONDS:
totals["freezes"] += 1
totals["freeze_seconds"] += interval
continue
totals["scroll_frames"] += 1
for name, value in (("blit", blit), ("wait", wait),
("work", max(0.0, interval - blit - wait)),
("interval_per_hold", interval / max(1, hold))):
bucket = _bucket(value)
histogram = histograms[name]
histogram[bucket] = histogram.get(bucket, 0) + 1
if period:
missed = round(interval / period) - hold
if missed >= 1:
totals["late_frames"] += 1
totals["missed_refreshes"] += missed
key = ("1" if missed == 1 else "2" if missed == 2
else "3-5" if missed <= 5 else "6+")
totals["late_by"][key] += 1
def snapshot(self) -> Dict[str, Any]:
"""The JSON document: cumulative since this process started."""
if not self._binding_checked:
self._binding_gil = binding_releases_gil()
self._binding_checked = True
info = dict(self.info)
info.setdefault("pi_model", _pi_model())
period = self.refresh_period
return {
"version": SCHEMA_VERSION,
"pid": os.getpid(),
"started": self.started,
"updated": time.time(),
"bucket_ms": BUCKET_MS,
"freeze_seconds": FREEZE_SECONDS,
"measured_refresh_hz": round(1.0 / period, 2) if period else None,
"binding_releases_gil": self._binding_gil,
"info": info,
"totals": self.totals,
# JSON keys are strings; readers convert back.
"histograms": {name: {str(k): v for k, v in sorted(h.items())}
for name, h in self.histograms.items()},
}
def write(self) -> None:
"""Replace the stats file atomically with the current snapshot."""
directory = os.path.dirname(self.path) or "."
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".frame_stats.",
suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(self.snapshot(), handle)
os.chmod(tmp, 0o644)
os.replace(tmp, self.path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
+23
View File
@@ -52,6 +52,7 @@ import zlib
import freetype
from src.common import snapshot_policy
from src.common.frame_timing import FrameTimingRecorder
from src.deprecation import deprecated
from src.common.permission_utils import (
ensure_directory_permissions,
@@ -232,6 +233,10 @@ class DisplayManager:
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1
# Timing of every presented frame, whoever drew it, for
# scripts/frame_soak.py. See src/common/frame_timing.py.
self.frame_timing = FrameTimingRecorder(info=self._frame_timing_info())
self._scrolling_state = {
'is_scrolling': False,
'last_scroll_activity': 0,
@@ -808,15 +813,21 @@ class DisplayManager:
# Copy the current image to the offscreen canvas. In double-sided
# mode the logical screen is first tiled across the full chain.
blit_started = time.perf_counter()
if self._double_sided is not None:
self.offscreen_canvas.SetImage(self._composite_double_sided())
else:
self.offscreen_canvas.SetImage(self.image)
blit_done = time.perf_counter()
# Swap buffers immediately. framerate_fraction holds the frame
# for N refreshes; SwapOnVSync blocks for all of them, which is
# what paces the render loop to the chosen frame rate.
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
presented_at = time.perf_counter()
self.frame_timing.record(
blit_done - blit_started, presented_at - blit_done,
self._frame_hold, self.is_currently_scrolling(), presented_at)
# Swap our canvas references
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
@@ -1448,6 +1459,18 @@ class DisplayManager:
value = 0.0
return value if value > 0 else 100.0
def _frame_timing_info(self) -> Dict[str, Any]:
"""What the frame-timing stats were measured on, for the soak report."""
display = self.config.get('display') or {}
hardware = display.get('hardware') or {}
runtime = display.get('runtime') or {}
info = {key: hardware.get(key) for key in (
'rows', 'cols', 'chain_length', 'parallel', 'pwm_bits',
'hardware_mapping', 'limit_refresh_rate_hz', 'pixel_mapper_config')}
info['gpio_slowdown'] = runtime.get('gpio_slowdown')
info['emulator'] = os.environ.get('EMULATOR', 'false') == 'true'
return info
def set_frame_hold(self, refreshes: int) -> None:
"""Hold each pushed frame for this many panel refreshes (>=1).