feat(perf): frame timing for every presented frame, a soak tool, a render bench and a stall watchdog (#629)

src/common/frame_timing.py times every frame the display presents, whoever drew it, and writes cumulative counters to /dev/shm. scripts/frame_soak.py grades a running service (late frames, freezes, where the time goes) and scripts/render_bench.py the hardware and render path alone. A stall watchdog logs the stacks behind any scroll held up for 250 ms or more (LEDMATRIX_STALL_WATCHDOG_MS lowers that). See docs/SCROLL_PERFORMANCE.md, "Soaking a rig".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 19:38:49 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7f9c73e9aa
commit 8ad9d191a7
9 changed files with 2217 additions and 13 deletions
+9
View File
@@ -257,6 +257,15 @@ floor on the release that ships them):
`api_extractors`). No known plugin imports it. A plugin that does must use `api_extractors`). No known plugin imports it. A plugin that does must use
`src.common` or its own copy of the code. `src.common` or its own copy of the code.
- `src.common.frame_timing` -- times every frame the display presents, whoever
drew it, and writes cumulative counters to `/dev/shm`. Two tools read it:
`scripts/frame_soak.py` judges a running service (late frames, freezes,
where the time goes), and `scripts/render_bench.py` judges the hardware and
render path alone on a synthetic strip. Both fail a run above 0.1% late
frames, and both call a loop that never waited for the panel NOT LOCKED. A
stall watchdog logs the stack of whatever holds a scroll up for 250 ms or
more. See `docs/SCROLL_PERFORMANCE.md`, "Soaking a rig".
## 3.5.0 ## 3.5.0
New modules a plugin may import via `src.*` (floor on 3.5.0): New modules a plugin may import via `src.*` (floor on 3.5.0):
+1
View File
@@ -37,6 +37,7 @@
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001 - Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`) - Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>` - Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
- Soak a rig for frame timing (on the Pi, service running): `python3 scripts/frame_soak.py --preview` — late-frame rate across every scroller; see `docs/SCROLL_PERFORMANCE.md`
## Plugin Store Architecture ## Plugin Store Architecture
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos) - Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
+167
View File
@@ -234,6 +234,9 @@ advances by elapsed time at `scroll_speed / scroll_delay` px/s.
## Diagnosing a juddery scroller ## Diagnosing a juddery scroller
To check a whole rig rather than one scroller, soak it -- see *Soaking a rig*
below.
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait **An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
healthy 100 fps. The stats line reports the tail for that reason — read the healthy 100 fps. The stats line reports the tail for that reason — read the
@@ -306,6 +309,170 @@ journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
If a plugin logs its scroll config **twice** with different modes, the second If a plugin logs its scroll config **twice** with different modes, the second
line is what is running. line is what is running.
## Soaking a rig
The per-scroller lines above tell you *which* scroller misbehaves. The soak
answers the question a release has to answer for 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 -- Vegas, a ticker plugin, anything. The
render thread only appends a tuple; a worker thread aggregates and rewrites
`/dev/shm/ledmatrix_frame_stats.json` every 10 seconds (RAM, so no SD-card
wear). `src/common/frame_timing.py` has the details.
```bash
python3 scripts/frame_soak.py # 10 minutes, as the display is now
python3 scripts/frame_soak.py --preview # with the web preview open
python3 scripts/frame_soak.py --show # totals since the service started
python3 scripts/frame_soak.py --json a.json # keep the report to compare later
```
It runs as any user next to the display service and stops nothing. It needs
something to *scroll* during the run: a live game holding a static scoreboard
on screen gives no verdict. `--preview` keeps the web preview's viewer marker
fresh, which puts the preview's PNG encoding at full rate -- run it as the web
service's user.
| line | what it tells you |
|---|---|
| **Late frames** | Frames presented one or more refreshes after they were due: the panel showed the previous frame again, a visible hitch. **The pass/fail number**, 0.1% by default (`--max-late-pct`). Only intervals between two scrolling frames count, and a frame held for `frame_hold` refreshes is due `frame_hold` refreshes after the last. |
| **Freezes** | Gaps of 250 ms or more inside a scroll: recomposes, plugin handovers, blocking calls on the render thread. Reported but not failed on, because some are handovers between plugins rather than faults. A gap still counts when the display's scroll state went missing for one frame across it, as long as scrolling resumes within 1 s: both of that frame's intervals count. Two static frames in a row end the scroll. (The state expires after 2 s without scroll activity, and plugins can clear it from their own `display()`.) The late and early rates are over frames judged against a known refresh period, which the recorder adopts once two windows in a row agree on it. |
| **blit** | Copying the frame into the matrix canvas (`SetImage`). It grows with width × height × `pwm_bits`: ~5.5 ms at 512×64 with 8 bits on a Pi 4. It is the biggest fixed cost, and it sets the refresh rates a rig can hold one pixel per refresh at. |
| **wait** | Time blocked in `SwapOnVSync`, i.e. the slack left in each refresh. A p50 near zero means the rig has no headroom and anything extra lands a frame late. |
| **work** | Everything else between two frames: drawing, scrolling, and waiting for the GIL. A wide gap between its p50 and p99 is another thread getting in the way. |
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
The refresh rate is estimated from the frames themselves (swaps that block on
vsync can only land on refresh boundaries). Cross-check it with
`scroll_speeds.py --measure` if it looks wrong. It can read high on a rig where
nothing ever presented at the full refresh rate.
A soak is only meaningful against a fixed workload. Compare runs with the same
content and `--preview` setting, and alternate which build goes first when you
A/B two of them. A live-API workload drifts over time.
The soak says how often; the service's log says why. A scroll that presents no
frame for 250 ms logs `Render stall:` with the stack of the render thread and
the top of every other thread's, and whether the whole interpreter was blocked
(C code holding the GIL) rather than one thread. To see what is behind the
shorter hitches, run the service with `LEDMATRIX_STALL_WATCHDOG_MS=30`, which
dumps at three refreshes late instead: its extra polling costs a little GIL
time of its own, so do that on a diagnostic run, not a soak you are grading.
`LEDMATRIX_STALL_WATCHDOG=0` turns it off.
### Results: hdpi, 2026-09-24
Pi 4, 4×128×64 on one chain (512×64), `gpio_slowdown` 3, cap 120 Hz, the
GIL-releasing binding. Vegas mode with live content, 8-minute soaks with
`--preview`, run in the order shown so each build went both first and last.
| run | build | pacing | pwm_bits | refresh | late | 1 | 2 | 3–5 | 6+ | freezes |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.33% | 2,542 | 74 | 19 | 4 | 0 |
| 2 | #628 | 1 px / refresh | 8 | 100.2 Hz | 0.66% | 238 | 32 | 30 | 5 | 2 |
| 3 | #628 | 1 px / refresh | 8 | 100.3 Hz | 0.70% | 252 | 38 | 26 | 6 | 2 |
| 4 | main | time-based, blended, 90 px/s | 8 | 94.5 Hz | 6.46% | 2,659 | 90 | 10 | 4 | 0 |
| 5 | #628 | 1 px / 2 refreshes (53 px/s) | **7** | 107.2 Hz | 0.32% | 68 | 7 | 4 | 2 | 1 |
- Blending cost the panel refresh rate as well as frames: 94.5 Hz against
~100 Hz for the same hardware under whole-pixel pacing.
- The freezes and the 3+ rows in the #628 runs line up with canvas-bound
plugins fetched on the render thread (`drain_deferred`): `news` took ~320 ms
and `hockey-scoreboard` ~660 ms there. Moving those
fetches off the render thread is proposed separately (offscreen rendering).
- Run 5 changed two things at once: the speed, and `pwm_bits` (changed on the
rig between runs). Its lower late rate cannot be credited to either alone.
- These soaks were taken before the recorder counted 1–2 s stalls as freezes,
so a stall of that length would be missing from these rows.
### Without the service: `render_bench.py`
The soak measures the service as it really runs: live content, plugin
updates, the web preview. `scripts/render_bench.py` answers the narrower
question underneath: *with nothing else in the way, can this hardware present
every frame on time?* It scrolls a synthetic strip through the production path
-- a real `DisplayManager`, a real `ScrollHelper`, the same `scroll_config`
resolver every ticker uses -- on content that is identical every run, which
makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT against
another) and for A/B testing a change to the render path.
```bash
sudo systemctl stop ledmatrix # the service owns the GPIO
sudo python3 scripts/render_bench.py # 60s at one pixel per refresh
sudo python3 scripts/render_bench.py --seconds 600 # the shipping gate
sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) speed
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
sudo systemctl start ledmatrix
```
It never starts or stops the service itself, so a crash in it cannot leave
the panel dark. It grades with the same recorder as the soak and prints the
same report, with the same exit status, except that **2** also means the run
could not be set up at all (no root, no panel, a fallback display), so a rig
that was never measured cannot pass by accident.
Two differences from the soak matter:
- **It measures the panel first.** Before scrolling it times bare swaps for a
few seconds to get the idle refresh rate, and seeds the recorder with it.
That is what catches a loop that never locked to the panel at all. The first
version of the bench announced its scrolling state once instead of every
frame; the state expired, the dirty-tracking skip fired mid-scroll, and the
loop free-ran at 827 fps. Graded against its own frames that looks perfectly
steady; graded against the panel's measured rate every frame is early, and
the run fails as NOT LOCKED. (The soak has no idle measurement, so it checks
the rate against `limit_refresh_rate_hz` instead: a "refresh" faster than
the cap cannot have been waiting for the panel.)
- **The stall watchdog prints to the terminal.** A frame held up for more than
250 ms prints the stack of what held it up, in the middle of the run.
Measured with the first version of the bench on hdpi (Pi 4, 512x64,
`pwm_bits` 8), two-minute runs at one pixel per refresh: 8 of 11,449 frames
late (0.070%), and with `--busy 2` 3 of 11,445 (0.026%). The render path and
the hardware pass on their own. Compare the soak results above, from the same
rig with the service running, for how much of the late rate comes from
everything else.
### The panel is slower while you are rendering into it
The bench prints two refresh rates, and they differ:
| | Pi 4, 512x64, `pwm_bits` 8 |
|---|---|
| idle, timing bare swaps | 100.4 Hz |
| while scrolling | 96.3 Hz |
Both are real. Driving an LED matrix is bit-banging on the same machine, so
`SetImage` over a 512x64 chain contends with the refresh itself and slows it.
The recorder therefore reads the rendering rate back from the frames: swaps
that block on vsync can only return on a refresh boundary, so the low end of
`interval / frame_hold` is the period. The idle figure is still printed,
because the gap between the two is itself a measure of how expensive a frame
is: **a rise in that gap is a render-cost regression even when nothing is
late.**
The practical consequence for config: set `limit_refresh_rate_hz` near the rate
the panel holds *while rendering*, not the idle rate and certainly not a cap it
can never reach. A cap well above the real rate makes `scroll_config` solve
speeds against a refresh that does not exist, which is where "3px every 4
refreshes" comes from.
### Bench-only counters
| line | meaning |
|---|---|
| `duplicate` | frames that advanced no pixels. A crisp fixed-step scroll should show none; any at all means the loop is presenting faster than the strip is moving. |
| `blank` | frames with no visible slice to draw: the helper had no content. Should be zero. |
| `restarts` | how many times the strip was scrolled through end to end. Informational: the bench restarts the strip where a plugin would hand over to the next one. |
`--json` writes the full report plus the panel geometry, the solved speed and
these counters, so two rigs (or one rig before and after a change) can be
compared without re-reading a terminal.
--- ---
## A tear across the middle on fast scrolls ## A tear across the middle on fast scrolls
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""Soak a running display and report how often moving frames reached the panel late.
Runs NEXT TO the display service, as any user: it only reads the stats file the
service writes (src/common/frame_timing.py) at the start and end of the run and
reports the difference. Nothing is stopped, restarted or drawn.
# 10 minutes, as the display is now
python3 scripts/frame_soak.py
# the same with the web preview open (the preview's PNG encodes are one of
# the things that used to make the render loop miss refreshes)
python3 scripts/frame_soak.py --preview
# quick look at the totals since the service started
python3 scripts/frame_soak.py --show
# keep the report for a before/after comparison
python3 scripts/frame_soak.py --duration 600 --json soak-before.json
Exit status: 0 when the late-frame rate is within ``--max-late-pct``, 1 when it
is not, 2 when there was nothing to measure (no stats file, the service
restarted mid-run, or nothing scrolled).
What the numbers mean
---------------------
late frames frames that reached the panel one or more refreshes after they
were due -- the panel showed the previous frame again, which on
a moving strip is a visible hitch. This is the pass/fail number.
freezes gaps of 250ms+ inside a scroll: recomposes, plugin handovers,
blocking calls on the render thread. Reported, not failed on,
since some are handovers between plugins rather than faults.
blit copying the frame into the matrix canvas (rgbmatrix SetImage).
Grows with width x height x pwm_bits.
wait blocked in SwapOnVSync, i.e. slack before the refresh.
work everything else between two frames: drawing, scrolling, and
waiting for the GIL.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.frame_timing import ( # noqa: E402
BUCKET_COUNT,
SCHEMA_VERSION,
default_stats_path,
)
#: Touched by the web UI while someone has the preview open; a fresh marker
#: puts the display service's snapshot writer at full rate. Same path as
#: DisplayManager._viewer_marker_path.
VIEWER_MARKER = "/tmp/led_matrix_preview_viewer" # nosec B108 - fixed path shared with the service
#: A stats file not rewritten for this long means nothing is being presented.
STALE_SECONDS = 30.0
def load(path: str) -> Optional[Dict[str, Any]]:
try:
with open(path, encoding="utf-8") as handle:
stats = json.load(handle)
except (OSError, ValueError):
return None
if not isinstance(stats, dict) or stats.get("version") != SCHEMA_VERSION:
return None
return stats
def _histogram(stats: Dict[str, Any], name: str) -> Dict[int, int]:
raw = (stats.get("histograms") or {}).get(name) or {}
return {int(k): int(v) for k, v in raw.items()}
def diff(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
"""What happened between two snapshots of the same process."""
tb, ta = before["totals"], after["totals"]
totals = {}
for key, value in ta.items():
if isinstance(value, dict):
totals[key] = {k: v - tb.get(key, {}).get(k, 0)
for k, v in value.items()}
elif key == "worst_interval_ms":
# A running maximum can't be differenced; it is reported as the
# worst since the service started.
totals[key] = value
else:
totals[key] = value - tb.get(key, 0)
histograms = {}
for name in (after.get("histograms") or {}):
hb, ha = _histogram(before, name), _histogram(after, name)
histograms[name] = {k: v - hb.get(k, 0) for k, v in ha.items()
if v - hb.get(k, 0) > 0}
return {"totals": totals, "histograms": histograms,
"seconds": after["updated"] - before["updated"]}
def percentiles(histogram: Dict[int, int], bucket_ms: float) -> Dict[str, Any]:
"""p50/p95/p99/max from a sparse histogram, as each bucket's upper edge."""
count = sum(histogram.values())
if not count:
return {}
out = {}
targets = {"p50": 0.50, "p95": 0.95, "p99": 0.99}
running = 0
for index in sorted(histogram):
running += histogram[index]
for name, fraction in list(targets.items()):
if running >= fraction * count:
out[name] = _edge(index, bucket_ms)
del targets[name]
out["max"] = _edge(max(histogram), bucket_ms)
return out
def _edge(index: int, bucket_ms: float):
if index >= BUCKET_COUNT - 1:
return f">={index * bucket_ms:g}"
return round((index + 1) * bucket_ms, 2)
def build_report(before, after, preview: bool) -> Dict[str, Any]:
delta = diff(before, after)
totals = delta["totals"]
frames = totals["scroll_frames"]
# The rates are over frames judged against a known refresh period. Stats
# from a recorder that predates the count fall back to every frame.
timed = totals.get("timed_frames", frames) if "timed_frames" in totals else frames
hours = delta["seconds"] / 3600.0 if delta["seconds"] > 0 else 0.0
bucket_ms = after.get("bucket_ms", 0.25)
report = {
"seconds": round(delta["seconds"], 1),
"preview": preview,
"info": after.get("info"),
"binding_releases_gil": after.get("binding_releases_gil"),
"measured_refresh_hz": after.get("measured_refresh_hz"),
"scroll_frames": frames,
"static_frames": totals["static_frames"],
"late_frames": totals["late_frames"],
"timed_frames": timed,
"late_pct": round(100.0 * totals["late_frames"] / timed, 3) if timed else None,
"missed_refreshes": totals["missed_refreshes"],
"late_by": totals["late_by"],
"early_frames": totals.get("early_frames", 0),
"early_pct": (round(100.0 * totals.get("early_frames", 0) / timed, 3)
if timed else None),
"freeze_by": totals.get("freeze_by", {}),
"freezes": totals["freezes"],
"freezes_per_hour": round(totals["freezes"] / hours, 1) if hours else None,
"freeze_seconds": round(totals["freeze_seconds"], 2),
"worst_interval_ms": (round(totals["worst_interval_ms"], 1)
if totals["worst_interval_ms"] else None),
"timing_ms": {name: percentiles(h, bucket_ms)
for name, h in delta["histograms"].items()},
}
# The rate the panel held while rendering: the typical frame's interval
# per refresh held. A few percent under the idle rate is normal (the Pi is
# bit-banging the panel and pushing frames at once); a widening gap between
# the two is a render-cost regression even when nothing is late.
typical = (report["timing_ms"].get("interval_per_hold") or {}).get("p50")
# percentiles() reports a bucket's upper edge; the midpoint is the better
# estimate, and half a 0.25ms bucket is already ~1% at 100Hz -- the size
# of the idle-vs-held gap this number exists to show.
if isinstance(typical, (int, float)) and typical > bucket_ms / 2:
report["held_refresh_hz"] = round(1000.0 / (typical - bucket_ms / 2), 1)
else:
report["held_refresh_hz"] = None
return report
def print_report(report: Dict[str, Any], limit: float) -> None:
info = report.get("info") or {}
size = "{}x{}".format(
(info.get("cols") or 0) * (info.get("chain_length") or 1),
(info.get("rows") or 0) * (info.get("parallel") or 1))
gil = {True: "releases the GIL", False: "STOCK (holds the GIL in SwapOnVSync)",
None: "unknown"}[report.get("binding_releases_gil")]
print(f"Rig {info.get('pi_model') or 'unknown'}")
print(f"Panel {size} chain {info.get('chain_length')} x parallel "
f"{info.get('parallel')} pwm_bits {info.get('pwm_bits')} "
f"slowdown {info.get('gpio_slowdown')} mapping {info.get('hardware_mapping')}")
print(f"Refresh {report.get('measured_refresh_hz') or '?'} Hz measured, "
f"cap {info.get('limit_refresh_rate_hz')}")
print(f"Binding {gil}")
print(f"Run {report['seconds']:.0f}s, preview "
f"{'open (simulated)' if report['preview'] else 'as-is'}")
print()
frames = report["scroll_frames"]
print(f"Scrolling frames {frames}")
if frames:
late_by = report["late_by"]
print(f"Late frames {report['late_frames']} ({report['late_pct']}%)"
f" missed refreshes {report['missed_refreshes']}"
f" [by 1: {late_by['1']}, 2: {late_by['2']}, "
f"3-5: {late_by['3-5']}, 6+: {late_by['6+']}]")
if report["early_frames"]:
print(f"Early frames {report['early_frames']} "
f"({report['early_pct']}%) swaps returned a refresh early")
print(f"Freezes >=250ms {report['freezes']}"
f" ({report['freezes_per_hour']}/h, {report['freeze_seconds']}s total)"
f" worst gap since start {report['worst_interval_ms'] or '-'} ms")
if report["freezes"]:
print(" by length: " + ", ".join(
f"{k}: {v}" for k, v in report["freeze_by"].items()))
print()
print(f"{'ms':<18}{'p50':>8}{'p95':>8}{'p99':>8}{'max':>8}")
for name in ("blit", "wait", "work", "interval_per_hold"):
row = report["timing_ms"].get(name) or {}
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
for k in ("p50", "p95", "p99", "max")))
print()
if report["late_pct"] is None:
print("RESULT nothing scrolled - no verdict")
elif not locked(report, limit):
ceiling = refresh_ceiling(report)
if (report.get("early_pct") or 0.0) > limit:
why = (f"{report['early_pct']}% of frames came a refresh early, so the "
"swaps were not waiting for the panel")
else:
why = (f"frames arrived at {report['measured_refresh_hz']}Hz, faster than "
f"the panel can refresh ({ceiling:g}Hz)")
print(f"RESULT FAIL NOT LOCKED: {why}, and the late count means nothing")
elif report["late_pct"] <= limit:
print(f"RESULT PASS {report['late_pct']}% late <= {limit}%")
else:
print(f"RESULT FAIL {report['late_pct']}% late > {limit}%")
#: How far over the panel's rate frames may arrive before the loop cannot have
#: been waiting for it. The margin covers the refresh wandering a little.
CEILING_MARGIN = 1.05
def refresh_ceiling(report: Dict[str, Any]) -> Optional[float]:
"""The fastest the panel can refresh, as far as this run knows.
The benchmark measures it (``idle_refresh_hz``); the service only knows its
cap. With neither, there is no ceiling to check against.
"""
idle = report.get("idle_refresh_hz")
if idle:
return float(idle)
cap = (report.get("info") or {}).get("limit_refresh_rate_hz")
try:
cap = float(cap)
except (TypeError, ValueError):
return None
return cap if cap > 0 else None
def locked(report: Dict[str, Any], limit: float) -> bool:
"""Whether the loop was paced by the panel at all.
Two ways it is not. Frames a whole refresh early mean some swaps did not
wait. And a loop that never waited at all -- the dirty-tracking skip firing
mid-scroll let one free-run at 827fps -- looks self-consistent to a refresh
estimate taken from its own frames, so nothing registers as early; what
gives it away is a "refresh" faster than the panel can physically do.
"""
if (report.get("early_pct") or 0.0) > limit:
return False
ceiling = refresh_ceiling(report)
measured = report.get("measured_refresh_hz")
return not (ceiling and measured and measured > ceiling * CEILING_MARGIN)
def passed(report: Dict[str, Any], limit: float) -> bool:
return (report["late_pct"] is not None and locked(report, limit)
and report["late_pct"] <= limit)
def touch_marker() -> bool:
try:
with open(VIEWER_MARKER, "a"):
pass
os.utime(VIEWER_MARKER, None)
return True
except OSError:
return False
def wait_for_fresh(path: str, timeout: float) -> Optional[Dict[str, Any]]:
"""The first snapshot written after now, so both ends of the run are exact."""
first = load(path)
deadline = time.time() + timeout
while time.time() < deadline:
current = load(path)
if current and (first is None or current["updated"] != first["updated"]):
return current
time.sleep(0.5)
return None
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--duration", type=float, default=600.0,
help="seconds to soak (default 600)")
parser.add_argument("--preview", action="store_true",
help="keep the web-preview viewer marker fresh, as an "
"open preview tab does")
parser.add_argument("--max-late-pct", type=float, default=0.1,
help="fail above this percentage of late frames (default 0.1)")
parser.add_argument("--stats", default=default_stats_path(),
help="stats file written by the display service")
parser.add_argument("--json", metavar="PATH",
help="also write the report as JSON")
parser.add_argument("--show", action="store_true",
help="print totals since the service started and exit")
args = parser.parse_args(argv)
current = load(args.stats)
if current is None:
print(f"No frame stats at {args.stats}. Is the display service running a "
"build with frame timing, and has anything scrolled for ~10s?",
file=sys.stderr)
return 2
if time.time() - current["updated"] > STALE_SECONDS:
print(f"Frame stats are {time.time() - current['updated']:.0f}s old: nothing "
"has been presented recently (static screen, or the service stopped).",
file=sys.stderr)
if not args.show:
return 2
if args.show:
empty = json.loads(json.dumps(current))
for key, value in empty["totals"].items():
empty["totals"][key] = ({k: 0 for k in value} if isinstance(value, dict)
else 0)
empty["histograms"] = {}
empty["updated"] = current["started"]
report = build_report(empty, current, preview=False)
print_report(report, args.max_late_pct)
return 0
if args.preview and not touch_marker():
print(f"Cannot touch {VIEWER_MARKER}; run as the web service's user to "
"simulate an open preview.", file=sys.stderr)
return 2
print(f"Waiting for a fresh baseline from {args.stats} ...", flush=True)
before = wait_for_fresh(args.stats, timeout=60.0)
if before is None:
print("The stats file stopped updating.", file=sys.stderr)
return 2
end = time.time() + args.duration
next_progress = time.time() + 60.0
while time.time() < end:
if args.preview:
touch_marker()
time.sleep(1.0)
if time.time() >= next_progress:
now = load(args.stats)
if now and now.get("pid") == before["pid"]:
done = now["totals"]["scroll_frames"] - before["totals"]["scroll_frames"]
late = now["totals"]["late_frames"] - before["totals"]["late_frames"]
print(f" {int(end - time.time())}s left: {done} scrolling frames, "
f"{late} late", flush=True)
next_progress += 60.0
after = wait_for_fresh(args.stats, timeout=60.0)
if after is None:
print("The stats file stopped updating during the run.", file=sys.stderr)
return 2
if after.get("pid") != before.get("pid"):
print("The display service restarted during the run; results discarded.",
file=sys.stderr)
return 2
report = build_report(before, after, preview=args.preview)
print()
print_report(report, args.max_late_pct)
if args.json:
with open(args.json, "w", encoding="utf-8") as handle:
json.dump(report, handle, indent=2)
if report["late_pct"] is None:
return 2
return 0 if passed(report, args.max_late_pct) else 1
if __name__ == "__main__":
sys.exit(main())
+404
View File
@@ -0,0 +1,404 @@
#!/usr/bin/env python3
"""Benchmark the render loop against the panel's real refresh rate.
The question this answers is the one that decides whether a rig ships: *does
every frame present on the refresh it was meant to?* It drives the production
path -- a real ``DisplayManager`` and ``ScrollHelper``, the same crisp speed
resolver every ticker uses -- scrolls a synthetic strip for a while, and grades
it with the same frame-timing recorder the display service uses
(``src.common.frame_timing``), printing the same report as
``scripts/frame_soak.py``. A run passes when the loop was genuinely locked to
the panel and no more than ``--max-late-pct`` percent of frames were late.
Where frame_soak.py measures the service as it runs -- live content, plugin
updates, the web preview -- this measures the hardware and the render path
with nothing else in the way, on content that is identical every run. That is
what makes it the tool for comparing rigs (a Pi 3 against a Pi 4, one HAT
against another) and for A/B testing a change to the render path.
# stop the service first; it owns the GPIO
sudo systemctl stop ledmatrix
sudo python3 scripts/render_bench.py # 60s, default speed
sudo python3 scripts/render_bench.py --seconds 600 # the 10-minute gate
sudo python3 scripts/render_bench.py --speed 50 # a slower, held speed
sudo python3 scripts/render_bench.py --busy 2 # with background load
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
sudo systemctl start ledmatrix
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
so a crash here can never leave the panel dark.
Exit status is 0 when the run clears the gate, 1 when it does not, and 2 when
the run could not be set up (no hardware, no root, unusable config) -- so a rig
that cannot be measured is never mistaken for a rig that passed.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import threading
import time
import zlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import frame_timing, scroll_config # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_soak # noqa: E402 (same report, same verdict as the soak)
REPO = Path(__file__).resolve().parent.parent
CONFIG = REPO / "config" / "config.json"
#: Long enough to average out a scheduler hiccup, short enough that nobody
#: skips running it. The shipping gate is --seconds 600.
DEFAULT_SECONDS = 60.0
#: Seconds spent timing bare swaps before the scroll starts. The measurement
#: has to settle, but every second here is a second not scrolling.
MEASURE_SECONDS = 4.0
#: Scrolling discarded before the graded run starts: the first frames carry
#: first-touch costs and the scrolling state settling.
WARMUP_SECONDS = 2.0
def load_config() -> dict:
"""The config the display service would run with."""
try:
from src.config_manager import ConfigManager
config = ConfigManager().config
if isinstance(config, dict) and config:
return config
except Exception as exc: # noqa: BLE001 - any failure means use the plain read
print(f"ConfigManager unavailable ({exc}); reading {CONFIG} directly",
file=sys.stderr)
# ConfigManager pulls in a lot; a plain read is enough to drive the panel
# and keeps the benchmark usable on a half-installed machine.
try:
with open(CONFIG, encoding="utf-8") as handle:
config = json.load(handle)
except (OSError, ValueError) as exc:
sys.exit(f"could not read {CONFIG}: {exc}")
if not isinstance(config, dict):
sys.exit(f"{CONFIG} is not a config object")
return config
def build_strip(width: int, height: int, label: str):
"""A marquee strip a few screens wide, with text and colour.
Deliberately not plain white text on black: how long ``SetImage`` takes
depends on how many subpixels are lit, so a strip that is mostly dark
flatters the panel and hides exactly the regression this benchmark exists
to catch.
"""
from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype
font = None
for path, size in (
(str(REPO / "assets/fonts/PressStart2P-Regular.ttf"), max(8, height // 4)),
("/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", max(10, height // 2)),
):
try:
font = load_truetype(path, size)
break
except OSError:
continue
if font is None:
font = ImageFont.load_default()
text = f" {label} *** THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG *** "
probe = ImageDraw.Draw(Image.new("RGB", (8, 8)))
box = probe.textbbox((0, 0), text, font=font)
text_width = max(1, box[2] - box[0])
text_height = box[3] - box[1]
reps = max(2, (width * 4) // text_width + 1)
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
draw = ImageDraw.Draw(strip)
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
palette = [(255, 210, 60), (80, 200, 255), (255, 90, 90), (140, 255, 140)]
for i in range(reps):
left = i * text_width
# A filled block per repeat, so a meaningful share of the strip is lit.
draw.rectangle(
[left + 4, height - 4, left + text_width - 4, height - 2],
fill=palette[i % len(palette)],
)
draw.text((left, (height - text_height) // 2 - box[1]), text,
font=font, fill=palette[(i + 1) % len(palette)])
return strip
class BackgroundLoad:
"""Threads that imitate plugins updating while the panel scrolls.
Not a simulation of any particular plugin -- it is the shape of the work
that competes with the render loop for the GIL: decoding JSON, resizing an
image, compressing bytes. A render loop that only holds its pacing on an
idle machine is not shippable, and this is how that shows up.
"""
def __init__(self, workers: int) -> None:
self.workers = max(0, workers)
self._stop = threading.Event()
self._threads: list = []
def __enter__(self) -> "BackgroundLoad":
for index in range(self.workers):
thread = threading.Thread(
target=self._run, args=(index,), name=f"bench-load-{index}", daemon=True)
thread.start()
self._threads.append(thread)
return self
def __exit__(self, *exc_info) -> None:
self._stop.set()
for thread in self._threads:
thread.join(timeout=2.0)
def _run(self, index: int) -> None:
from PIL import Image
payload = json.dumps({"games": [{"id": n, "score": [n, n + 1],
"name": f"team {n}"} for n in range(200)]})
image = Image.new("RGB", (256, 64), (12, 34, 56))
while not self._stop.wait(0.25 + 0.05 * index):
json.loads(payload)
image.resize((128, 32), Image.LANCZOS)
zlib.compress(image.tobytes(), 1)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--seconds", type=float, default=DEFAULT_SECONDS,
help=f"how long to scroll for (default {DEFAULT_SECONDS:.0f}; "
"the shipping gate is 600)")
parser.add_argument("--speed", type=float, default=None,
help="requested px/s; snapped to the nearest speed the "
"panel can show in whole pixels (default: one pixel "
"per refresh)")
parser.add_argument("--hz", type=float, default=None,
help="skip the idle measurement and take this as the "
"panel's rate (for reproducing a rig's numbers)")
parser.add_argument("--busy", type=int, default=0, metavar="N",
help="run N background workers imitating plugin updates")
parser.add_argument("--max-late-pct", "--max-missed", dest="max_late_pct",
type=float, default=0.1, metavar="PCT",
help="fail above this percentage of late frames (default 0.1)")
parser.add_argument("--json", dest="json_path", default=None, metavar="PATH",
help="also write the report as JSON, for comparing rigs")
parser.add_argument("--label", default=None,
help="name for this run in the JSON report (default: hostname)")
args = parser.parse_args(argv)
# Everything the display service logs would otherwise land in the middle of
# the report; the benchmark's own output is the point. The stall watchdog
# is the exception: a stack dump naming what held a frame up belongs here.
logging.basicConfig(level=logging.ERROR, stream=sys.stderr)
logging.getLogger("src.common.frame_timing").setLevel(logging.WARNING)
if hasattr(os, "geteuid") and os.geteuid() != 0:
print("this needs root for GPIO access - rerun with sudo", file=sys.stderr)
return 2
config = load_config()
from src.common.scroll_helper import ScrollHelper
from src.display_manager import DisplayManager
try:
display = DisplayManager(config, suppress_test_pattern=True)
except Exception as exc:
print(f"could not open the display ({exc}).\n"
"If the display service is running it owns the GPIO - stop it "
"first:\n sudo systemctl stop ledmatrix", file=sys.stderr)
return 2
if getattr(display, "matrix", None) is None:
print("the display came up in fallback mode - there is no panel here to "
"measure, and a software loop's frame times say nothing about "
"vsync. Run this on a rig.", file=sys.stderr)
return 2
width, height = display.width, display.height
if args.hz is not None:
idle_hz = float(args.hz)
print(f"taking the panel's rate as {idle_hz:.1f}Hz (given, not measured)")
else:
print(f"measuring the panel for {MEASURE_SECONDS:.0f}s...", flush=True)
idle_hz = frame_timing.measure_refresh_hz(display.matrix, MEASURE_SECONDS)
if idle_hz <= 0:
print("the panel did not answer a swap; cannot measure it",
file=sys.stderr)
return 2
cap = scroll_config.refresh_hz_from_config(config)
note = (f" (cap is {cap:.0f}Hz)" if idle_hz < cap * 0.98
else " (at its configured cap)")
print(f"panel refreshes at {idle_hz:.1f}Hz{note}")
requested = args.speed if args.speed else idle_hz
# Configured through the shared resolver rather than by setting the helper
# up by hand, so the benchmark measures the engine every ticker runs on. A
# speed the bench reached some other way would be measuring something no
# plugin does.
helper = ScrollHelper(width, height)
settings = scroll_config.configure(
helper,
plugin_config={"scroll_pixels_per_second": requested},
global_config=config,
refresh_hz=idle_hz,
display_manager=display,
)
choice = settings.crisp
if choice is None:
print("the resolver did not snap to a whole-pixel speed; nothing to "
"grade against", file=sys.stderr)
return 2
print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
helper.set_sub_pixel_scrolling(False)
helper.set_scrolling_image(
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s"))
# The display service's own recorder, owned outright here: never flushed to
# the service's stats file, drained exactly at the start and end of the
# graded run, and seeded with the idle rate so a loop that never locked
# (free-running, or stuck at a fraction of the refresh) shows as early or
# late frames instead of looking self-consistent.
recorder = frame_timing.FrameTimingRecorder(
flush_interval=float("inf"),
info=display._frame_timing_info(), # pylint: disable=protected-access
refresh_hz=idle_hz,
)
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
display.frame_timing = recorder
print(f"scrolling {width}x{height} for {args.seconds:.0f}s"
+ (f" with {args.busy} background worker(s)" if args.busy else "")
+ " ...", flush=True)
frames = 0
duplicates = 0
blanks = 0
restarts = 0
last_column = None
before = None
started = time.perf_counter()
run_started = None
try:
with BackgroundLoad(args.busy):
while True:
now = time.perf_counter()
if run_started is None and now - started >= WARMUP_SECONDS:
recorder.drain()
before = recorder.snapshot()
run_started = now
frames = duplicates = blanks = restarts = 0
if run_started is not None and now - run_started >= args.seconds:
break
helper.update_scroll_position()
if helper.is_scroll_complete():
# The helper parks at the end of the strip and stops
# advancing, exactly as it does under a plugin -- which
# then hands over to the next one. Here there is nothing
# to hand over to, so start the strip again. Without this
# the benchmark measures a still image for the rest of the
# run and reports a smoothness it never demonstrated.
helper.reset_scroll()
restarts += 1
visible = helper.get_visible_portion()
column = int(helper.scroll_position)
if column == last_column:
duplicates += 1
last_column = column
if visible is None:
blanks += 1
else:
display.image.paste(visible, (0, 0))
# Every frame, not once before the loop. The scrolling state
# expires on its own inactivity threshold and takes the frame
# hold with it, so a scroll that announces itself once is
# presented at the wrong rate for all but its first moments --
# and its unchanged frames start taking the dirty-tracking
# skip, which returns without waiting for the panel at all.
# Every ticker re-announces per frame; so does this.
display.set_scrolling_state(True, frame_hold=choice.frame_hold)
display.update_display()
frames += 1
except KeyboardInterrupt:
print("\ninterrupted - reporting what was measured so far")
finally:
display.set_scrolling_state(False)
try:
display.clear()
except Exception as exc: # noqa: BLE001 - a lit panel is harmless; say so and go on
print(f"could not blank the panel: {exc}", file=sys.stderr)
if before is None:
print("interrupted during warm-up; nothing was graded", file=sys.stderr)
return 2
recorder.drain()
report = frame_soak.build_report(before, recorder.snapshot(), preview=False)
report["idle_refresh_hz"] = round(idle_hz, 2)
print()
frame_soak.print_report(report, args.max_late_pct)
held = report.get("held_refresh_hz")
if held:
drop = 100.0 * (idle_hz - held) / idle_hz
print(f"\npanel held ~{held:.1f}Hz while rendering, {drop:.1f}% below its "
f"{idle_hz:.1f}Hz idle rate (a widening gap is a render-cost "
"regression even with nothing late)")
if duplicates:
# A frame that shows the same columns as the one before it is work the
# panel did not need. It is not a miss -- the frame arrived on time --
# but it means the loop is presenting faster than the strip is moving.
print(f"duplicate {duplicates} frames advanced no pixels "
f"({100.0 * duplicates / max(1, frames):.2f}%)")
if blanks:
print(f"blank {blanks} frames had no visible slice to draw")
if restarts:
print(f"restarts {restarts} (the strip was scrolled through "
f"{restarts} time{'s' if restarts != 1 else ''})")
if args.json_path:
report.update({
"label": args.label or os.uname().nodename,
"bench": True,
"requested_pixels_per_second": requested,
"pixels_per_second": choice.pixels_per_second,
"pixels_per_frame": choice.pixels_per_frame,
"frame_hold": choice.frame_hold,
"busy_workers": args.busy,
"duplicate_frames": duplicates,
"blank_frames": blanks,
"strip_restarts": restarts,
"max_late_pct": args.max_late_pct,
"passed": frame_soak.passed(report, args.max_late_pct),
})
Path(args.json_path).write_text(json.dumps(report, indent=2) + "\n",
encoding="utf-8")
print(f"\nwrote {args.json_path}")
if report["late_pct"] is None:
return 2
return 0 if frame_soak.passed(report, args.max_late_pct) else 1
if __name__ == "__main__":
sys.exit(main())
+6 -13
View File
@@ -42,7 +42,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import scroll_config # noqa: E402 from src.common import frame_timing, scroll_config # noqa: E402
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json" CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
@@ -99,20 +99,13 @@ def open_matrix(config, refresh_override=None):
def measure_refresh(config, seconds=6.0): def measure_refresh(config, seconds=6.0):
"""Actual refresh rate, by running uncapped and timing the swaps. """Actual refresh rate, by running uncapped and timing the swaps.
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop What an older Pi or a longer chain will really give you, as opposed to
runs at exactly the panel's rate. This is what an older Pi or a longer whatever limit_refresh_rate_hz optimistically asks for. The timing loop
chain will really give you, as opposed to whatever limit_refresh_rate_hz itself lives in src.common.frame_timing so the benchmark grades against
optimistically asks for. the same measurement this ladder is built from.
""" """
matrix = open_matrix(config, refresh_override=0) matrix = open_matrix(config, refresh_override=0)
canvas = matrix.CreateFrameCanvas() measured = frame_timing.measure_refresh_hz(matrix, seconds)
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
frames = 0
started = time.perf_counter()
while time.perf_counter() - started < seconds:
canvas = matrix.SwapOnVSync(canvas)
frames += 1
measured = frames / (time.perf_counter() - started)
matrix.Clear() matrix.Clear()
return measured return measured
+609
View File
@@ -0,0 +1,609 @@
"""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.
"Scrolling" is DisplayManager's scroll state when the frame is presented, and
that state can go missing in the middle of a scroll. It expires after 2s
without scroll activity, which a long enough stall outlasts, and any thread can
clear it: plugins call ``set_scrolling_state(False)`` from their own
``display()``, and Vegas captures some of those on the render thread between
two of its frames. The frame after that is recorded as static, and the interval
it ends -- the stall, or the capture -- would vanish from the report. So a
single static frame between two scrolling ones, with the scroll picking up
again within ``RESUME_SECONDS``, is treated as a frame of the scroll: both of
its intervals count. A second static frame in a row means the scroll really
ended. (On hdpi on 2026-09-24 the watchdog logged a 1.9s stall that the soak
report did not have; this is how.)
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. ``freeze_by`` splits
them by length. Intervals of ``GAP_SECONDS`` or more are ignored as not being
frames of one scroll at all.
A frame that arrives a whole refresh or more *early* means the swap did not
wait for the panel: the emulator, the fallback display, or a hold that was not
the one in effect. Those are counted as **early**, and a run with more than a
trace of them was not locked to the panel, so its late count means nothing.
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 -- except that a window
cutting it by more than ``MAX_REFRESH_DROP`` is ignored. A panel's refresh does
not jump like that; swaps that stopped blocking do, and adopting their period
would make every early frame look on time.
A caller that has measured the panel independently -- ``scripts/render_bench.py``
times bare swaps first with :func:`measure_refresh_hz` -- passes that rate in
as ``refresh_hz``. The estimate then starts from it instead of from the frames,
which is what catches a loop that never locked at all: one that free-runs
faster than the panel (every frame early) or sits at half its rate (every
frame late), both of which look self-consistent to an estimate taken from
their own intervals.
Stall watchdog
--------------
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
same frames from its own thread and, when a scroll's last frame is more than
``STALL_SECONDS`` old, logs the stack of the thread that presented it and the
top of every other thread's, so the log names what the render thread was
waiting on. It also measures how late its own wake-up was: if the watchdog was
held up as long as the render thread, the whole interpreter was blocked (C
code holding the GIL, or the process not scheduled), not one thread on a lock.
Set ``LEDMATRIX_STALL_WATCHDOG=0`` to turn it off, or
``LEDMATRIX_STALL_WATCHDOG_MS`` to dump at a lower threshold -- 30 catches
frames three refreshes late, which is where GIL contention shows. It polls
three times per threshold, so keep it to diagnostic runs, not soaks.
"""
from __future__ import annotations
import copy
import json
import logging
import os
import queue
import sys
import tempfile
import threading
import time
import traceback
from typing import Any, Callable, 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
#: Intervals this long are not frames of one scroll. This used to be 1s,
#: which silently dropped every 1-2s stall inside a scroll. It is now only a
#: sanity bound.
GAP_SECONDS = 5.0
#: A frame recorded as static between two scrolling frames is a frame of the
#: scroll whose state went missing, if the scroll resumes within this long.
#: See "What is counted".
RESUME_SECONDS = 1.0
#: Buckets for freeze length, as cumulative counters a soak can difference.
FREEZE_BUCKETS = ((0.5, "<0.5s"), (1.0, "0.5-1s"), (2.0, "1-2s"),
(float("inf"), "2s+"))
#: A window may lower the refresh-period estimate by at most this fraction.
MAX_REFRESH_DROP = 0.2
#: 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
#: A scroll's last frame older than this is a stall worth a stack dump.
STALL_SECONDS = 0.25
#: How often the watchdog looks. Also the resolution of its starvation check.
WATCHDOG_POLL_SECONDS = 0.05
#: At most one stack dump per this many seconds: a stall that repeats every
#: extension would otherwise write the same stacks to the SD card all day.
STALL_LOG_INTERVAL = 30.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:
# A fixed name in a shared directory is safe here: write() creates its
# temp file with mkstemp and os.replace()s it over this path, which swaps
# out whatever is there -- a planted symlink included -- without following it.
base = "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir() # nosec B108
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
def measure_refresh_hz(matrix: Any, seconds: float = 4.0) -> float:
"""The panel's refresh rate with nothing else running, by timing bare swaps.
``SwapOnVSync`` blocks until the panel's next refresh, so a loop that does
nothing else runs at exactly the panel's rate. ``limit_refresh_rate_hz`` is
a *cap*, and a long chain, a high ``pwm_bits`` or an older Pi will sit well
under it. Solving scroll speeds against a cap the panel cannot reach is
what produces "3px every 4 refreshes" and the judder that comes with it.
This is the idle rate. The panel refreshes a few percent slower while the
Pi is also pushing frames into it (100.4Hz idle against 96.3Hz scrolling on
a Pi 4 driving 512x64), which is why the recorder reads the rendering rate
back from the frames rather than trusting this.
Pass the matrix the display is already running on rather than opening a
second one: the GPIO has a single owner, and the options in force change
the answer.
:returns: measured Hz, or 0.0 if the matrix cannot be swapped (no
hardware, a stub, a mock).
"""
try:
canvas = matrix.CreateFrameCanvas()
# Discard the first swap: it carries construction and first-touch costs
# that have nothing to do with the steady-state refresh.
canvas = matrix.SwapOnVSync(canvas)
except Exception: # pylint: disable=broad-except
return 0.0
frames = 0
started = time.perf_counter()
while time.perf_counter() - started < seconds:
canvas = matrix.SwapOnVSync(canvas)
frames += 1
elapsed = time.perf_counter() - started
if elapsed <= 0 or frames <= 0:
return 0.0
return frames / elapsed
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,
refresh_hz: Optional[float] = None,
):
"""
:param refresh_hz: the panel's rate, measured independently (see the
module docstring). Omit it to estimate from the frames alone, as
the display service does.
"""
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
# The interval ended by a static frame that followed a scrolling one,
# until the next frame shows whether the scroll went on.
self._unsure: Optional[Tuple[float, float, float, 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] = (
1.0 / refresh_hz if refresh_hz and refresh_hz > 0 else None)
# The first estimate, until a second window agrees with it.
self._refresh_candidate: 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},
"early_frames": 0,
# Frames judged against a known refresh period: the denominator
# for the late and early rates. Frames before the period is known
# are neither, and must not dilute them.
"timed_frames": 0,
"freezes": 0,
"freeze_seconds": 0.0,
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
"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
# Read by the stall watchdog from its own thread: one tuple assignment,
# so it always sees a consistent (time, scrolling, thread) triple.
self.last_frame: Optional[Tuple[float, bool, int]] = None
#: Whether a scroll is running *now*, supplied by the display manager.
#: The last frame's flag alone would call the end of every scroll a
#: stall.
self.scrolling_now: Optional[Callable[[], bool]] = None
self.watchdog: Optional["StallWatchdog"] = None
def close(self) -> None:
"""Stop the stall watchdog, if one was started."""
watchdog, self.watchdog = self.watchdog, None
if watchdog is not None:
watchdog.stop()
# -- 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)
self.last_frame = (presented_at, scrolling, threading.get_ident())
if not scrolling:
self._static_frames += 1
# The scroll ended, or its state went missing for this frame: the
# next frame says which. Its hold may have been dropped with the
# state, so the interval is due at the scroll's own.
self._unsure = None
if previous is not None and previous[1]:
self._unsure = (presented_at - previous[0], blit, wait, previous[2])
elif self.watchdog is None and self.scrolling_now is not None \
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
self.watchdog = StallWatchdog(self, **watchdog_settings())
self.watchdog.start()
elif previous is not None:
interval = presented_at - previous[0]
unsure, self._unsure = self._unsure, None
if previous[1]:
if interval < GAP_SECONDS:
self._pending.append((interval, blit, wait, hold))
elif unsure is not None and interval < RESUME_SECONDS:
# One static frame between two scrolling ones: the scroll never
# stopped, only its state did. Both intervals were motion.
self._static_frames -= 1
if unsure[0] < GAP_SECONDS:
self._pending.append(unsure)
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()
def drain(self) -> None:
"""Aggregate everything recorded so far, on the calling thread.
For a caller that owns the recorder outright and wants exact numbers at
a moment of its choosing -- the benchmark, between warm-up and run and
at the end. Construct it with ``flush_interval=float('inf')`` so the
worker never runs; the two must not aggregate at once.
"""
batch, self._pending = self._pending, []
static, self._static_frames = self._static_frames, 0
self.aggregate(batch, static)
# -- 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]
current = self.refresh_period
if estimate <= 0:
pass
elif current is None:
# Adopt the first period only once two windows in a row agree:
# one loaded window at startup, most of its frames a refresh
# late, would otherwise fix a period twice the real one for
# the life of the process, since later windows may only lower
# it by MAX_REFRESH_DROP.
candidate = self._refresh_candidate
if candidate and abs(estimate - candidate) <= candidate * MAX_REFRESH_DROP:
self.refresh_period = min(candidate, estimate)
else:
self._refresh_candidate = estimate
elif current * (1.0 - MAX_REFRESH_DROP) <= estimate < current:
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
label = next(name for limit, name in FREEZE_BUCKETS
if interval < limit)
totals["freeze_by"][label] += 1
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:
totals["timed_frames"] += 1
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
elif missed <= -1:
totals["early_frames"] += 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": copy.deepcopy(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
def watchdog_settings() -> Dict[str, float]:
"""StallWatchdog arguments from ``LEDMATRIX_STALL_WATCHDOG_MS``, if set.
The poll comes down with the threshold, or a stall shorter than one poll
would go unseen.
"""
try:
ms = float(os.environ.get("LEDMATRIX_STALL_WATCHDOG_MS") or 0)
except ValueError:
ms = 0.0
if ms <= 0:
return {}
threshold = ms / 1000.0
return {"threshold": threshold,
"poll": min(WATCHDOG_POLL_SECONDS, threshold / 3)}
class StallWatchdog:
"""Log what the render thread is doing when a scroll stops presenting.
See the module docstring. Polls; never touches the render thread.
"""
def __init__(
self,
recorder: FrameTimingRecorder,
threshold: float = STALL_SECONDS,
poll: float = WATCHDOG_POLL_SECONDS,
log_interval: float = STALL_LOG_INTERVAL,
clock: Callable[[], float] = time.perf_counter,
):
self.recorder = recorder
self.threshold = threshold
self.poll = poll
self.log_interval = log_interval
self.clock = clock
self.stalls = 0
self._last_dump: Optional[float] = None
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, daemon=True, name="stall-watchdog")
self._thread.start()
def stop(self, timeout: float = 1.0) -> None:
"""End the polling thread (DisplayManager.cleanup calls this)."""
self._stop.set()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout)
def _run(self) -> None:
last_wake = self.clock()
stall_from: Optional[float] = None # presented_at of the stalled frame
dumped = False
while not self._stop.wait(self.poll):
now = self.clock()
late = max(0.0, now - last_wake - self.poll)
last_wake = now
try:
stall_from, dumped = self.check(now, late, stall_from, dumped)
except Exception: # never let a diagnostic take anything down
logger.debug("Stall watchdog check failed", exc_info=True)
def check(self, now: float, late: float, stall_from: Optional[float],
dumped: bool) -> Tuple[Optional[float], bool]:
"""One look. Returns the updated (stall_from, dumped) state."""
frame = self.recorder.last_frame
if frame is None:
return None, False
presented_at, scrolling, ident = frame
if stall_from is not None and presented_at != stall_from:
# A frame arrived: the stall is over.
if dumped:
logger.warning(
"Render stall over: no frame for %.0fms",
(presented_at - stall_from) * 1000.0)
return None, False
scrolling_now = self.recorder.scrolling_now
if (stall_from is not None and now - stall_from >= GAP_SECONDS
and (scrolling_now is None or not scrolling_now())):
# The scroll ended without another frame: nothing more to time.
# Only past GAP_SECONDS: the scroll state expires after 2s without
# activity, which a stall outlasts, and its end still wants saying.
return None, False
age = now - presented_at
if (stall_from is None and scrolling and age >= self.threshold
and scrolling_now is not None and scrolling_now()):
self.stalls += 1
if self._last_dump is None or now - self._last_dump >= self.log_interval:
self._last_dump = now
logger.warning(self.describe(ident, age, late))
return presented_at, True
return presented_at, False
return stall_from, dumped
def describe(self, ident: int, age: float, late: float) -> str:
"""The stack dump: the stalled thread in full, the rest in brief."""
names = {t.ident: t.name for t in threading.enumerate()}
frames = sys._current_frames()
lines = [
f"Render stall: no frame for {age * 1000.0:.0f}ms mid-scroll "
f"(watchdog woke {late * 1000.0:.0f}ms late"
+ ("; the interpreter itself was blocked" if late >= age / 2 else "")
+ ")",
f"-- {names.get(ident, ident)} (presents frames):",
]
stalled = frames.get(ident)
if stalled is not None:
lines.extend(line.rstrip() for line in
traceback.format_stack(stalled, limit=12))
for other, frame in frames.items():
if other in (ident, threading.get_ident()):
continue
top = traceback.extract_stack(frame, limit=3)
where = " <- ".join(
f"{os.path.basename(f.filename)}:{f.lineno} {f.name}"
for f in reversed(top))
lines.append(f"-- {names.get(other, other)}: {where}")
return "\n".join(lines)
+36
View File
@@ -51,6 +51,7 @@ import zlib
import freetype import freetype
from src.common import snapshot_policy from src.common import snapshot_policy
from src.common.frame_timing import FrameTimingRecorder
from src.deprecation import deprecated from src.deprecation import deprecated
from src.logging_config import get_logger from src.logging_config import get_logger
from src.common.permission_utils import ( from src.common.permission_utils import (
@@ -236,6 +237,11 @@ class DisplayManager:
# See src/common/scroll_config.py and scripts/scroll_speeds.py. # See src/common/scroll_config.py and scripts/scroll_speeds.py.
self._frame_hold = 1 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.frame_timing.scrolling_now = self._scrolling_now
self._scrolling_state = { self._scrolling_state = {
'is_scrolling': False, 'is_scrolling': False,
'last_scroll_activity': 0, 'last_scroll_activity': 0,
@@ -798,15 +804,21 @@ class DisplayManager:
# Copy the current image to the offscreen canvas. In double-sided # Copy the current image to the offscreen canvas. In double-sided
# mode the logical screen is first tiled across the full chain. # mode the logical screen is first tiled across the full chain.
blit_started = time.perf_counter()
if self._double_sided is not None: if self._double_sided is not None:
self.offscreen_canvas.SetImage(self._composite_double_sided()) self.offscreen_canvas.SetImage(self._composite_double_sided())
else: else:
self.offscreen_canvas.SetImage(self.image) self.offscreen_canvas.SetImage(self.image)
blit_done = time.perf_counter()
# Swap buffers immediately. framerate_fraction holds the frame # Swap buffers immediately. framerate_fraction holds the frame
# for N refreshes; SwapOnVSync blocks for all of them, which is # for N refreshes; SwapOnVSync blocks for all of them, which is
# what paces the render loop to the chosen frame rate. # what paces the render loop to the chosen frame rate.
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold) 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 # Swap our canvas references
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
@@ -1295,6 +1307,9 @@ class DisplayManager:
self._new_canvas(self.width, self.height) self._new_canvas(self.width, self.height)
except (OSError, RuntimeError, ValueError, MemoryError): except (OSError, RuntimeError, ValueError, MemoryError):
logger.debug("Canvas reset during cleanup failed", exc_info=True) logger.debug("Canvas reset during cleanup failed", exc_info=True)
# The stall watchdog would otherwise outlive this manager.
if getattr(self, 'frame_timing', None) is not None:
self.frame_timing.close()
# Reset the singleton state when cleaning up # Reset the singleton state when cleaning up
DisplayManager._instance = None DisplayManager._instance = None
@@ -1390,6 +1405,27 @@ class DisplayManager:
value = 0.0 value = 0.0
return value if value > 0 else 100.0 return value if value > 0 else 100.0
def _scrolling_now(self) -> bool:
"""Whether a scroll is running, without is_currently_scrolling()'s
side effect of expiring the state -- safe from the stall watchdog's
thread."""
state = self._scrolling_state
return bool(state['is_scrolling']) and (
time.time() - state['last_scroll_activity']
<= state['scroll_inactivity_threshold'])
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: def set_frame_hold(self, refreshes: int) -> None:
"""Hold each pushed frame for this many panel refreshes (>=1). """Hold each pushed frame for this many panel refreshes (>=1).
+596
View File
@@ -0,0 +1,596 @@
"""System-wide frame timing (src/common/frame_timing.py) and its soak report.
The recorder's job is to separate what a viewer sees as a hitch -- a moving
frame one or more refreshes late -- from things that are not jitter: static
screens, the first frame of a scroll, gaps between scrolls, and freezes.
"""
import json
import sys
import time
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import frame_timing # noqa: E402
from src.common.frame_timing import FrameTimingRecorder # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import frame_soak # noqa: E402
PERIOD = 0.010 # a 100Hz panel
def _feed(recorder, intervals, hold=1, scrolling=True, start=100.0,
blit=0.002, wait=0.004):
"""Present one frame, then one more per interval."""
t = start
recorder.record(blit, wait, hold, scrolling, t)
for interval in intervals:
t += interval
recorder.record(blit, wait, hold, scrolling, t)
return t
def _aggregate(recorder):
batch, static = recorder._pending, recorder._static_frames
recorder._pending, recorder._static_frames = [], 0
recorder.aggregate(batch, static)
return recorder.totals
def _recorder(tmp_path, **kwargs):
# A flush interval nothing in these tests reaches, so aggregation is
# driven explicitly and no worker thread starts.
return FrameTimingRecorder(path=str(tmp_path / "stats.json"),
flush_interval=1e9, **kwargs)
def _settle(recorder, interval=PERIOD, hold=1, start=0.0):
"""Two agreeing windows: the refresh period is adopted from the second."""
for offset in (0.0, 50.0):
_feed(recorder, [interval] * 200, hold=hold, start=start + offset)
_aggregate(recorder)
def test_steady_frames_are_on_time_and_give_the_refresh(tmp_path):
r = _recorder(tmp_path)
_feed(r, [PERIOD] * 200)
_aggregate(r)
assert r.refresh_period is None # one window proves nothing yet
_feed(r, [PERIOD] * 200, start=500.0)
totals = _aggregate(r)
assert totals["scroll_frames"] == 400
assert totals["timed_frames"] == 200 # the second window, judged
assert totals["late_frames"] == 0
assert abs(1.0 / r.refresh_period - 100.0) < 0.5
def test_a_bad_first_window_does_not_fix_the_period(tmp_path):
# Startup: nine frames in ten a refresh late, so that window's low end is
# two periods. Adopted outright, every later window (a 50% "drop") would
# be refused and one-refresh-late frames would read as on time for good.
r = _recorder(tmp_path)
_feed(r, [2 * PERIOD if i % 10 else PERIOD for i in range(200)])
_aggregate(r)
for start in (500.0, 1000.0):
_feed(r, [PERIOD] * 200, start=start)
_aggregate(r)
assert abs(1.0 / r.refresh_period - 100.0) < 0.5
def test_a_frame_a_refresh_late_is_counted(tmp_path):
r = _recorder(tmp_path, refresh_hz=100.0)
intervals = [PERIOD] * 200
intervals[50] = 2 * PERIOD # one refresh late
intervals[120] = 4 * PERIOD # three refreshes late
_feed(r, intervals)
totals = _aggregate(r)
assert totals["late_frames"] == 2
assert totals["missed_refreshes"] == 1 + 3
assert totals["late_by"] == {"1": 1, "2": 0, "3-5": 1, "6+": 0}
def test_a_held_frame_is_not_late(tmp_path):
# 50px/s on a 100Hz panel is 1px every 2 refreshes: 20ms is on time.
r = _recorder(tmp_path)
_settle(r, 2 * PERIOD, hold=2)
assert r.totals["late_frames"] == 0
assert abs(1.0 / r.refresh_period - 100.0) < 0.5
def test_small_jitter_is_not_late(tmp_path):
r = _recorder(tmp_path)
_feed(r, [PERIOD * (1 + 0.03 * ((i % 5) - 2)) for i in range(300)])
assert _aggregate(r)["late_frames"] == 0
def test_static_frames_and_the_start_of_a_scroll_are_not_timed(tmp_path):
r = _recorder(tmp_path)
t = _feed(r, [1.0, 1.0, 1.0], scrolling=False)
# The first scrolling frame follows a static one 300ms later: that is a
# scroll starting, not a 30-refresh stall.
_feed(r, [PERIOD] * 100, start=t + 0.3)
totals = _aggregate(r)
assert totals["static_frames"] == 4
assert totals["scroll_frames"] == 100
assert totals["late_frames"] == totals["freezes"] == 0
def test_freezes_are_separate_from_late_frames_and_gaps_are_ignored(tmp_path):
r = _recorder(tmp_path)
intervals = [PERIOD] * 200
intervals[80] = 0.400 # a render-thread plugin fetch: freeze
intervals[120] = 1.5 # a longer stall, still inside the scroll: freeze
intervals[150] = 6.0 # past any scroll's inactivity window: ignored
_feed(r, intervals)
totals = _aggregate(r)
assert totals["freezes"] == 2
assert abs(totals["freeze_seconds"] - 1.9) < 1e-9
assert totals["freeze_by"] == {"<0.5s": 1, "0.5-1s": 0, "1-2s": 1, "2s+": 0}
assert totals["late_frames"] == 0
assert totals["scroll_frames"] == 197
def test_a_stall_between_one_and_two_seconds_is_not_lost(tmp_path):
# Two "scrolling" frames can be up to DisplayManager's 2s inactivity
# threshold apart. The first version ignored everything past 1s, so a
# 1.4s render-thread stall vanished from the report.
r = _recorder(tmp_path)
intervals = [PERIOD] * 100
intervals[40] = 1.4
_feed(r, intervals)
assert _aggregate(r)["freezes"] == 1
def test_a_stall_whose_scroll_state_went_missing_still_counts(tmp_path):
# hdpi, 14:48: the watchdog logged a 1.9s stall the soak never reported.
# A plugin captured between two Vegas frames cleared the scroll state, so
# the frame that ended the stall was recorded as static and its interval
# dropped. Vegas set the state again right after, as it does every frame.
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
t += 1.911
r.record(0.002, 0.004, 1, False, t) # state missing: "static"
_feed(r, [PERIOD] * 100, start=t + PERIOD) # the scroll carries on
totals = _aggregate(r)
assert totals["freezes"] == 1
assert totals["freeze_by"]["1-2s"] == 1
assert totals["static_frames"] == 0
# 100 before, the step back into the scroll, 100 after: all but the freeze.
assert totals["scroll_frames"] == 100 + 1 + 100
def test_a_frame_with_its_scroll_state_missing_is_still_timed(tmp_path):
# No stall at all -- the state was cleared and the frame went out on
# time. Nothing is late and no interval is lost.
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
r.record(0.002, 0.004, 1, False, t + PERIOD)
_feed(r, [PERIOD] * 100, start=t + 2 * PERIOD)
totals = _aggregate(r)
assert totals["scroll_frames"] == 202
assert totals["late_frames"] == totals["freezes"] == totals["static_frames"] == 0
def test_a_scroll_that_really_ended_is_not_a_freeze(tmp_path):
# Two static frames in a row, then a new scroll: the gaps between them
# were a static screen, not a stall.
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
t = _feed(r, [0.5], scrolling=False, start=t + 0.3)
_feed(r, [PERIOD] * 100, start=t + 0.4)
totals = _aggregate(r)
assert totals["freezes"] == 0
assert totals["static_frames"] == 2
assert totals["scroll_frames"] == 200
def test_one_static_frame_then_a_scroll_much_later_is_a_new_scroll(tmp_path):
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
r.record(0.002, 0.004, 1, False, t + 0.3)
_feed(r, [PERIOD] * 100, start=t + 0.3 + frame_timing.RESUME_SECONDS + 0.1)
totals = _aggregate(r)
assert totals["freezes"] == 0
assert totals["static_frames"] == 1
assert totals["scroll_frames"] == 200
def test_a_missing_state_interval_is_due_at_the_scrolls_own_hold(tmp_path):
# Clearing the state drops the hold to 1 as well, so the "static" frame
# reports hold 1. Its interval is still due two refreshes after the last.
r = _recorder(tmp_path)
t = _feed(r, [2 * PERIOD] * 100, hold=2)
r.record(0.002, 0.004, 1, False, t + 2 * PERIOD)
_feed(r, [2 * PERIOD] * 100, hold=2, start=t + 4 * PERIOD)
totals = _aggregate(r)
assert totals["late_frames"] == totals["early_frames"] == 0
assert totals["scroll_frames"] == 202
def test_early_frames_are_counted(tmp_path):
# Hold 2 on a 100Hz panel: frames are due every 20ms. A swap that returns
# after 10ms did not wait out the hold.
r = _recorder(tmp_path)
_feed(r, [2 * PERIOD] * 200, hold=2)
_aggregate(r)
intervals = [2 * PERIOD] * 200
for i in range(0, 200, 20):
intervals[i] = PERIOD
_feed(r, intervals, hold=2, start=1000.0)
totals = _aggregate(r)
assert totals["early_frames"] == 10
assert totals["late_frames"] == 0
def test_refresh_estimate_survives_a_window_full_of_misses(tmp_path):
r = _recorder(tmp_path)
_settle(r)
# A bad window where every frame is late must not redefine the refresh.
_feed(r, [2 * PERIOD] * 200, start=1000.0)
totals = _aggregate(r)
assert abs(1.0 / r.refresh_period - 100.0) < 0.5
assert totals["late_frames"] == 200
def test_record_hands_off_and_the_worker_writes_the_file(tmp_path):
path = tmp_path / "stats.json"
r = FrameTimingRecorder(path=str(path), flush_interval=0.5,
info={"cols": 128, "rows": 32})
_feed(r, [PERIOD] * 120) # 1.2s of frames: at least one flush
deadline = time.time() + 5
while not path.exists() and time.time() < deadline:
time.sleep(0.02)
stats = json.loads(path.read_text(encoding="utf-8"))
assert stats["version"] == frame_timing.SCHEMA_VERSION
assert stats["info"]["cols"] == 128
assert stats["totals"]["scroll_frames"] > 0
def test_soak_report_is_the_difference_between_snapshots(tmp_path):
r = _recorder(tmp_path)
_feed(r, [PERIOD] * 200)
_aggregate(r)
before = json.loads(json.dumps(r.snapshot()))
intervals = [PERIOD] * 1000
intervals[500] = 0.0201 # a refresh late; off a bucket boundary
_feed(r, intervals, start=500.0)
_aggregate(r)
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
report = frame_soak.build_report(before, after, preview=True)
assert report["scroll_frames"] == 1000
assert report["late_frames"] == 1
assert report["late_pct"] == 0.1
assert report["timing_ms"]["blit"]["p50"] == 2.25 # 2ms lands in [2, 2.25)
assert report["timing_ms"]["interval_per_hold"]["max"] == 20.25
def test_soak_fails_a_run_that_was_not_locked(tmp_path):
r = _recorder(tmp_path)
_settle(r, 2 * PERIOD, hold=2)
before = json.loads(json.dumps(r.snapshot()))
_feed(r, [PERIOD] * 1000, hold=2, start=500.0) # never waited out the hold
_aggregate(r)
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
report = frame_soak.build_report(before, after, preview=False)
assert report["late_pct"] == 0.0
assert report["early_pct"] == 100.0
assert not frame_soak.passed(report, 0.1)
def test_frames_before_the_period_is_known_do_not_make_a_verdict(tmp_path):
# With no period, nothing was judged: 0 late of 200 is not a pass.
r = _recorder(tmp_path)
before = json.loads(json.dumps(r.snapshot()))
_feed(r, [PERIOD] * 200)
_aggregate(r)
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
report = frame_soak.build_report(before, after, preview=False)
assert report["scroll_frames"] == 200
assert report["timed_frames"] == 0
assert report["late_pct"] is None
assert not frame_soak.passed(report, 0.1)
def test_a_snapshot_is_not_changed_by_what_comes_after_it(tmp_path):
# render_bench keeps the snapshot object itself, no JSON round trip; it
# used to share the live totals, so every graded run differenced to zero.
r = _recorder(tmp_path, refresh_hz=100.0)
_feed(r, [PERIOD] * 100)
_aggregate(r)
before = r.snapshot()
_feed(r, [PERIOD] * 300, start=500.0)
_aggregate(r)
after = r.snapshot()
after["updated"] = before["updated"] + 10.0
report = frame_soak.build_report(before, after, preview=False)
assert report["scroll_frames"] == 300
assert report["late_pct"] == 0.0
def test_the_held_rate_is_read_from_the_bucket_midpoint(tmp_path):
# percentiles() gives a bucket's upper edge. At the midpoint of the
# [10.0, 10.25)ms bucket the upper edge would say 97.6Hz.
r = _recorder(tmp_path, refresh_hz=100.0)
before = json.loads(json.dumps(r.snapshot()))
_feed(r, [0.010125] * 300)
_aggregate(r)
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
report = frame_soak.build_report(before, after, preview=False)
assert report["held_refresh_hz"] == round(1000.0 / 10.125, 1)
def test_soak_percentiles_mark_the_overflow_bucket():
top = frame_timing.BUCKET_COUNT - 1
result = frame_soak.percentiles({0: 98, top: 2}, 0.25)
assert result["p50"] == 0.25
assert str(result["max"]).startswith(">=")
def test_display_manager_records_every_presented_frame(monkeypatch):
"""The hook sits in update_display, so every source is covered."""
monkeypatch.setenv("EMULATOR", "true")
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
dm = DisplayManager({"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 1, "parallel": 1},
"runtime": {"gpio_slowdown": 0}}}, suppress_test_pattern=True)
try:
assert dm.frame_timing.info["cols"] == 64
dm.set_scrolling_state(True)
for shade in (10, 20, 30):
dm.draw.rectangle([0, 0, 4, 4], fill=(shade, 0, 0))
dm.update_display()
assert len(dm.frame_timing._pending) == 2 # 3 frames, 2 intervals
finally:
dm.set_scrolling_state(False)
DisplayManager._instance = None
DisplayManager._initialized = False
# --- stall watchdog ----------------------------------------------------------
class _FakeRecorder:
def __init__(self):
self.last_frame = None
self.scrolling = True
self.scrolling_now = lambda: self.scrolling
def test_watchdog_reports_a_stall_once_and_its_end(caplog):
rec = _FakeRecorder()
dog = frame_timing.StallWatchdog(rec, threshold=0.25, log_interval=0.0)
ident = __import__("threading").get_ident()
rec.last_frame = (10.0, True, ident)
with caplog.at_level("WARNING", logger="src.common.frame_timing"):
state = dog.check(10.1, 0.0, None, False) # 100ms: fine
assert state == (None, False)
state = dog.check(10.4, 0.0, *state) # 400ms: stall
assert state == (10.0, True)
state = dog.check(10.9, 0.0, *state) # still stalled: no repeat
rec.last_frame = (11.2, True, ident)
state = dog.check(11.25, 0.0, *state) # a frame arrived
assert state == (None, False)
messages = [r.getMessage() for r in caplog.records]
assert len(messages) == 2
assert messages[0].startswith("Render stall: no frame for 400ms")
assert "test_watchdog_reports_a_stall_once_and_its_end" in messages[0]
assert messages[1] == "Render stall over: no frame for 1200ms"
assert dog.stalls == 1
def test_watchdog_ignores_a_scroll_that_ended(caplog):
rec = _FakeRecorder()
dog = frame_timing.StallWatchdog(rec, threshold=0.25, log_interval=0.0)
rec.last_frame = (10.0, True, 1)
rec.scrolling = False # the scroll handed over
with caplog.at_level("WARNING", logger="src.common.frame_timing"):
assert dog.check(12.0, 0.0, None, False) == (None, False)
assert not caplog.records
def test_watchdog_names_what_the_stalled_thread_is_waiting_on():
import threading
rec = _FakeRecorder()
dog = frame_timing.StallWatchdog(rec)
blocked, release = threading.Event(), threading.Event()
def render_loop_waiting_on_a_lock():
blocked.set()
release.wait(5)
t = threading.Thread(target=render_loop_waiting_on_a_lock, name="render")
t.start()
try:
assert blocked.wait(5)
text = dog.describe(t.ident, 1.5, 1.4)
finally:
release.set()
t.join(5)
assert "no frame for 1500ms" in text
assert "the interpreter itself was blocked" in text
assert "-- render (presents frames):" in text
assert "render_loop_waiting_on_a_lock" in text
def test_watchdog_rate_limits_its_dumps(caplog):
rec = _FakeRecorder()
dog = frame_timing.StallWatchdog(rec, threshold=0.25, log_interval=30.0)
with caplog.at_level("WARNING", logger="src.common.frame_timing"):
for start in (10.0, 20.0): # two stalls 10s apart
rec.last_frame = (start, True, 1)
state = dog.check(start + 0.5, 0.0, None, False)
rec.last_frame = (start + 0.6, True, 1)
dog.check(start + 0.65, 0.0, *state)
dumps = [r for r in caplog.records if r.getMessage().startswith("Render stall:")]
assert len(dumps) == 1
assert dog.stalls == 2
def test_watchdog_reports_the_end_of_a_stall_that_outlasts_the_scroll_state(caplog):
# The scroll state expires after 2s without activity; a stall longer than
# that must still have its end reported.
rec = _FakeRecorder()
dog = frame_timing.StallWatchdog(rec, threshold=0.25, log_interval=0.0)
rec.last_frame = (10.0, True, 1)
with caplog.at_level("WARNING", logger="src.common.frame_timing"):
state = dog.check(10.3, 0.0, None, False) # dumped
rec.scrolling = False # state expired at 12.0
state = dog.check(12.5, 0.0, *state)
rec.last_frame = (13.0, True, 1) # the frame arrives
dog.check(13.05, 0.0, *state)
over = [r.getMessage() for r in caplog.records
if r.getMessage().startswith("Render stall over")]
assert over == ["Render stall over: no frame for 3000ms"]
def test_closing_the_recorder_stops_its_watchdog(tmp_path, monkeypatch):
monkeypatch.delenv("LEDMATRIX_STALL_WATCHDOG", raising=False)
r = _recorder(tmp_path)
r.scrolling_now = lambda: True
r.record(0.001, 0.009, 1, True, 1.0)
thread = r.watchdog._thread
assert thread.is_alive()
r.close()
thread.join(2)
assert not thread.is_alive()
assert r.watchdog is None
def test_watchdog_threshold_can_be_lowered_for_a_diagnostic_run(monkeypatch):
monkeypatch.delenv("LEDMATRIX_STALL_WATCHDOG_MS", raising=False)
assert frame_timing.watchdog_settings() == {}
monkeypatch.setenv("LEDMATRIX_STALL_WATCHDOG_MS", "nonsense")
assert frame_timing.watchdog_settings() == {}
monkeypatch.setenv("LEDMATRIX_STALL_WATCHDOG_MS", "30")
settings = frame_timing.watchdog_settings()
assert settings["threshold"] == pytest.approx(0.030)
assert settings["poll"] == pytest.approx(0.010) # sees a stall one poll long
# ...and the recorder starts its watchdog with them.
rec = frame_timing.FrameTimingRecorder(path=None)
rec.scrolling_now = lambda: True
monkeypatch.setattr(frame_timing.StallWatchdog, "start", lambda self: None)
rec.record(0.001, 0.009, 1, True, 1.0)
assert rec.watchdog.threshold == pytest.approx(0.030)
# --- measuring the panel, and runs that never locked -------------------------
# The refresh measurement and the "not locked" cases came from the first
# version of scripts/render_bench.py, which graded runs with its own module.
class FakePanel:
"""A matrix whose swaps block for a fixed period, like real vsync."""
def __init__(self, period, fail=False):
self.period = period
self.fail = fail
self.swaps = 0
def CreateFrameCanvas(self): # noqa: N802 - mirrors rgbmatrix
if self.fail:
raise RuntimeError("no hardware here")
return object()
def SwapOnVSync(self, canvas, framerate_fraction=1): # noqa: N802
self.swaps += 1
time.sleep(self.period)
return canvas
def test_measure_refresh_times_the_swaps_not_the_loop():
# A loop that spun without waiting for each swap would report far more
# than the 200Hz a 5ms swap allows; the lower bound is loose because
# sleep() on a loaded runner overshoots.
measured = frame_timing.measure_refresh_hz(FakePanel(0.005), seconds=0.2)
assert 0 < measured <= 210.0
def test_measure_refresh_discards_the_first_swap():
panel = FakePanel(0.005)
frame_timing.measure_refresh_hz(panel, seconds=0.05)
assert panel.swaps >= 2
def test_measure_refresh_without_hardware_reports_nothing():
assert frame_timing.measure_refresh_hz(FakePanel(0.0, fail=True), seconds=0.1) == 0.0
assert frame_timing.measure_refresh_hz(object(), seconds=0.1) == 0.0
def _seeded(tmp_path, hz=100.0):
return FrameTimingRecorder(path=str(tmp_path / "s.json"),
flush_interval=float("inf"), refresh_hz=hz)
def test_a_seeded_recorder_catches_a_loop_that_never_waited(tmp_path):
# The first bench build free-ran at 827fps once the dirty-tracking skip
# fired mid-scroll. Estimated from its own frames that looks fine; against
# the measured panel rate every frame is early.
r = _seeded(tmp_path)
_feed(r, [0.0012] * 500)
r.drain()
assert r.totals["early_frames"] == 500
assert abs(1.0 / r.refresh_period - 100.0) < 0.5
def test_a_seeded_recorder_catches_a_loop_stuck_at_half_rate(tmp_path):
# Hold 1, but every frame takes two refreshes: self-consistent at 50Hz,
# late on every frame against the panel's 100Hz.
r = _seeded(tmp_path)
_feed(r, [2 * PERIOD] * 500)
r.drain()
assert r.totals["late_frames"] == 500
def test_a_seeded_recorder_still_passes_a_panel_a_little_slower_than_idle(tmp_path):
# 100.4Hz idle, 96.3Hz while rendering: not a single frame is late.
r = _seeded(tmp_path, hz=100.4)
_feed(r, [1 / 96.3] * 500)
r.drain()
assert r.totals["late_frames"] == r.totals["early_frames"] == 0
def test_soak_calls_a_rate_faster_than_the_panel_not_locked(tmp_path):
r = _recorder(tmp_path)
r.info = {"limit_refresh_rate_hz": 100}
before = json.loads(json.dumps(r.snapshot()))
_feed(r, [0.0012] * 500) # unseeded: nothing looks early...
r.drain()
_feed(r, [0.0012] * 500, start=500.0)
r.drain()
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
report = frame_soak.build_report(before, after, preview=False)
assert report["early_pct"] == 0.0
assert report["measured_refresh_hz"] > 800
assert not frame_soak.locked(report, 0.1) # ...but 833Hz beats a 100Hz cap
assert not frame_soak.passed(report, 0.1)
def test_soak_reports_the_rate_held_while_rendering(tmp_path):
r = _recorder(tmp_path)
before = json.loads(json.dumps(r.snapshot()))
_feed(r, [1 / 96.3] * 500)
r.drain()
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
report = frame_soak.build_report(before, after, preview=False)
assert 95.0 <= report["held_refresh_hz"] <= 97.0
def test_render_bench_strip_lights_a_real_share_of_pixels():
# How long SetImage takes depends on how many subpixels are lit; a mostly
# dark strip would flatter the panel.
import render_bench
strip = render_bench.build_strip(128, 32, "test")
assert strip.width >= 128 * 4
lit = sum(1 for px in strip.getdata() if px != (0, 0, 0))
assert lit / (strip.width * strip.height) > 0.05