perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 18:36:38 -04:00
co-authored by Claude Opus 5.5
parent 7804ea8f69
commit 1252df9bad
6 changed files with 695 additions and 19 deletions
+7
View File
@@ -342,6 +342,7 @@ service's user.
| **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. | | **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. | | **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*. | | **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
| **after work** | Frames presented straight after tagged render-thread work, with their own late rate: `extend` and `compose` (Vegas building its strip), `patch` (live elements, once they land). A kind whose late rate sits well above the overall one is the work making frames late. Shown only when something tagged its work. |
The refresh rate is estimated from the frames themselves (swaps that block on The refresh rate is estimated from the frames themselves (swaps that block on
vsync can only land on refresh boundaries). Cross-check it with vsync can only land on refresh boundaries). Cross-check it with
@@ -406,6 +407,10 @@ sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) spe
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates 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 python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
# render-thread strip work, each tagged so the report gives it a late rate:
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25 # a live map patch
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6 # Vegas extensions
sudo systemctl start ledmatrix sudo systemctl start ledmatrix
``` ```
@@ -468,6 +473,8 @@ refreshes" comes from.
| `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. | | `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. | | `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. | | `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. |
| `patches` | `--patch-bytes N --patch-every K`: N bytes of columns written into the strip in place every K frames, on screen or (`--patch-where ahead`) just past it -- what a live element update costs the render thread. Their frames are the `patch` row under *after work*. |
| `extensions` | `--extend-every-screens N`: a block appended and the scrolled-past columns trimmed every N screens, as continuous Vegas does. The cost is a copy of the whole strip, so size it like Vegas's with `--strip-screens` (8,000-20,000px). Their frames are the `extend` row. |
`--json` writes the full report plus the panel geometry, the solved speed and `--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 these counters, so two rigs (or one rig before and after a change) can be
+42
View File
@@ -35,6 +35,9 @@ blit copying the frame into the matrix canvas (rgbmatrix SetImage).
wait blocked in SwapOnVSync, i.e. slack before the refresh. wait blocked in SwapOnVSync, i.e. slack before the refresh.
work everything else between two frames: drawing, scrolling, and work everything else between two frames: drawing, scrolling, and
waiting for the GIL. waiting for the GIL.
after work frames presented straight after tagged render-thread work
(Vegas strip extensions, live-element patches), with their own
late rate. Shown only when something tagged its work.
""" """
from __future__ import annotations from __future__ import annotations
@@ -126,6 +129,33 @@ def _edge(index: int, bucket_ms: float):
return round((index + 1) * bucket_ms, 2) return round((index + 1) * bucket_ms, 2)
def op_rows(totals: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""Per kind of noted render-thread work: how often its frame was late.
A kind's frames are the ones presented straight after that work ran (see
"Operations" in src/common/frame_timing.py). Stats from a recorder that
predates the counters have none, and give an empty table.
"""
frames = totals.get("op_frames") or {}
late = totals.get("late_op_frames") or {}
freezes = totals.get("op_freezes") or {}
moved = totals.get("op_bytes") or {}
rows = {}
for kind in sorted(set(frames) | set(freezes)):
count = frames.get(kind, 0)
if not count and not freezes.get(kind, 0):
continue
rows[kind] = {
"frames": count,
"late": late.get(kind, 0),
"late_pct": (round(100.0 * late.get(kind, 0) / count, 3)
if count else None),
"freezes": freezes.get(kind, 0),
"bytes": moved.get(kind, 0),
}
return rows
def build_report(before, after, preview: bool) -> Dict[str, Any]: def build_report(before, after, preview: bool) -> Dict[str, Any]:
delta = diff(before, after) delta = diff(before, after)
totals = delta["totals"] totals = delta["totals"]
@@ -159,6 +189,7 @@ def build_report(before, after, preview: bool) -> Dict[str, Any]:
if totals["worst_interval_ms"] else None), if totals["worst_interval_ms"] else None),
"timing_ms": {name: percentiles(h, bucket_ms) "timing_ms": {name: percentiles(h, bucket_ms)
for name, h in delta["histograms"].items()}, for name, h in delta["histograms"].items()},
"ops": op_rows(totals),
} }
# The rate the panel held while rendering: the typical frame's interval # 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 # per refresh held. A few percent under the idle rate is normal (the Pi is
@@ -216,6 +247,17 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}" print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
for k in ("p50", "p95", "p99", "max"))) for k in ("p50", "p95", "p99", "max")))
print() print()
ops = report.get("ops") or {}
if ops:
# Frames presented straight after render-thread work of each kind. A
# late rate well above the overall one points at that work.
print(f"{'after work':<18}{'frames':>8}{'late':>8}{'late %':>8}"
f"{'freezes':>9}{'MB moved':>10}")
for kind, row in ops.items():
pct = "-" if row["late_pct"] is None else f"{row['late_pct']:g}"
print(f"{kind:<18}{row['frames']:>8}{row['late']:>8}{pct:>8}"
f"{row['freezes']:>9}{row['bytes'] / 1e6:>10.2f}")
print()
if report["late_pct"] is None: if report["late_pct"] is None:
print("RESULT nothing scrolled - no verdict") print("RESULT nothing scrolled - no verdict")
elif not locked(report, limit): elif not locked(report, limit):
+192 -7
View File
@@ -25,6 +25,14 @@ against another) and for A/B testing a change to the render path.
sudo python3 scripts/render_bench.py --busy 2 # with background load sudo python3 scripts/render_bench.py --busy 2 # with background load
sudo python3 scripts/render_bench.py --json /tmp/pi4.json sudo python3 scripts/render_bench.py --json /tmp/pi4.json
# the cost of changing pixels under a moving strip (Vegas live elements):
# a 101KB write into the visible columns every 25 frames
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25
# the cost of extending a Vegas-sized strip on the render thread: a 30-screen
# strip, extended by 6 screens (and trimmed) every 6 screens scrolled
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6
sudo systemctl start ledmatrix sudo systemctl start ledmatrix
Like scripts/scroll_speeds.py, this never starts or stops the service itself, Like scripts/scroll_speeds.py, this never starts or stops the service itself,
@@ -92,13 +100,17 @@ def load_config() -> dict:
return config return config
def build_strip(width: int, height: int, label: str): def build_strip(width: int, height: int, label: str, screens: float = 4.0):
"""A marquee strip a few screens wide, with text and colour. """A marquee strip about ``screens`` screens wide, with text and colour.
Deliberately not plain white text on black: how long ``SetImage`` takes 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 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 flatters the panel and hides exactly the regression this benchmark exists
to catch. to catch.
The width matters to the extension mode, whose cost is a copy of the whole
strip: Vegas carries 8,000-20,000 columns, so measure extension against a
strip that wide (``--strip-screens``), not the four-screen default.
""" """
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@@ -123,7 +135,7 @@ def build_strip(width: int, height: int, label: str):
text_width = max(1, box[2] - box[0]) text_width = max(1, box[2] - box[0])
text_height = box[3] - box[1] text_height = box[3] - box[1]
reps = max(2, (width * 4) // text_width + 1) reps = max(2, int(width * screens) // text_width + 1)
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0)) strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
draw = ImageDraw.Draw(strip) draw = ImageDraw.Draw(strip)
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
@@ -179,6 +191,119 @@ class BackgroundLoad:
zlib.compress(image.tobytes(), 1) zlib.compress(image.tobytes(), 1)
class StripWork:
"""Render-thread work a Vegas strip does between frames, on a schedule.
Patching writes a block of columns into the strip in place, as a live
element update does. Extending appends a block and trims what has scrolled
past, as continuous Vegas does (render_pipeline.extend_scroll_content).
Both run where Vegas runs them -- on the frame loop, before the next frame
is drawn -- and are tagged with ``FrameTimingRecorder.note_op``, so the
report shows how often the frame straight after each one was late.
The content comes from the benchmark's own strip, prepared before the run:
in Vegas it is drawn off the render thread, so drawing it here would time
work the render thread never does.
"""
def __init__(self, helper, recorder, source, *, patch_bytes: int = 0,
patch_every: int = 25, patch_where: str = "visible",
extend_every_screens: float = 0.0, extend_width: int = 0,
separator: int = 32) -> None:
import numpy as np
from PIL import Image
self.helper = helper
self.recorder = recorder
self.width = helper.display_width
self.height = helper.display_height
self.patch_every = max(1, int(patch_every))
self.patch_where = patch_where
self.separator = max(0, int(separator))
self.patches = 0
self.patched_bytes = 0
self.extensions = 0
self._frames = 0
self._position_at_extend = 0.0
pixels = np.asarray(source.convert("RGB"))
source_width = pixels.shape[1]
def columns(count: int, offset: int):
# Wraps around the source, so any width can be cut from it.
return np.ascontiguousarray(
pixels[:, (np.arange(count) + offset) % source_width])
# Two versions to alternate between, so every patch changes pixels.
self._patches = []
if patch_bytes > 0:
count = max(1, int(patch_bytes) // (self.height * 3))
self._patches = [columns(count, 0), columns(count, count)]
self.extend_every = (int(extend_every_screens * self.width)
if extend_every_screens > 0 else 0)
self._blocks = []
if self.extend_every:
# By default each append (block plus its separator) replaces
# exactly what scrolled past since the last one, so the strip
# holds its width, as Vegas's does in the steady state.
count = int(extend_width) or max(1, self.extend_every - self.separator)
self._blocks = [Image.fromarray(columns(count, 0)),
Image.fromarray(columns(count, count))]
def reset(self) -> None:
"""The strip was restarted from the beginning."""
self._position_at_extend = self.helper.scroll_position
def before_frame(self) -> None:
"""Do whatever work is due before the next frame is drawn."""
if self._blocks:
self._extend_if_due()
if self._patches:
self._frames += 1
if self._frames % self.patch_every == 0:
self._patch()
def _extend_if_due(self) -> None:
helper = self.helper
if helper.scroll_position < self._position_at_extend:
self._position_at_extend = helper.scroll_position
# Due on a fixed cadence rather than N screens after the last one ran,
# which would drift by the overshoot of a multi-pixel step each time.
due_at = self._position_at_extend + self.extend_every
if helper.scroll_position < due_at:
return
block = self._blocks[self.extensions % 2]
helper.append_content([block], item_gap=self.separator, element_gap=0)
moved = helper.cached_array.nbytes
# One screen kept behind the viewport, as Vegas does. The trim shifts
# every strip coordinate, the cadence's included.
cut = helper.drop_scrolled_prefix(keep_before=self.width)
if cut:
moved += helper.cached_array.nbytes
self._position_at_extend = due_at - cut
self.extensions += 1
self.recorder.note_op("extend", moved)
def _patch(self) -> None:
patch = self._patches[self.patches % 2]
strip = self.helper.cached_array
count = patch.shape[1]
if strip is None or count > strip.shape[1]:
return
start = int(self.helper.scroll_position)
if self.patch_where == "visible":
x = start + max(0, (self.width - count) // 2)
else:
# Just past the right edge: a change to content not yet on screen.
x = start + self.width + 16
x = max(0, min(x, strip.shape[1] - count))
strip[:, x:x + count] = patch
self.patches += 1
self.patched_bytes += patch.nbytes
self.recorder.note_op("patch", patch.nbytes)
def main(argv=None) -> int: def main(argv=None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -202,8 +327,36 @@ def main(argv=None) -> int:
help="also write the report as JSON, for comparing rigs") help="also write the report as JSON, for comparing rigs")
parser.add_argument("--label", default=None, parser.add_argument("--label", default=None,
help="name for this run in the JSON report (default: hostname)") help="name for this run in the JSON report (default: hostname)")
parser.add_argument("--strip-screens", type=float, default=4.0, metavar="S",
help="strip width in screens (default 4). Vegas strips are "
"8,000-20,000px; the extension cost scales with it")
parser.add_argument("--patch-bytes", type=int, default=0, metavar="N",
help="write N bytes of columns into the strip in place "
"every --patch-every frames, as a Vegas live element "
"update does (a 150x64 card is ~29KB, a 512x64 map "
"~100KB)")
parser.add_argument("--patch-every", type=int, default=25, metavar="K",
help="frames between patches (default 25; 1 = every frame)")
parser.add_argument("--patch-where", choices=("visible", "ahead"),
default="visible",
help="patch the columns on screen, or just past its right "
"edge (default visible)")
parser.add_argument("--extend-every-screens", type=float, default=0.0,
metavar="N",
help="append a block and trim the strip every N screens "
"scrolled, as continuous Vegas does")
parser.add_argument("--extend-width", type=int, default=0, metavar="W",
help="width of each appended block in px (default: N "
"screens less the separator, so the strip holds its "
"width)")
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.extend_every_screens > 0 and args.strip_screens < args.extend_every_screens + 3:
# The strip must stay ahead of the viewport between extensions.
args.strip_screens = args.extend_every_screens + 3
print(f"strip widened to {args.strip_screens:g} screens so extensions "
"keep ahead of the viewport")
# Everything the display service logs would otherwise land in the middle of # 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 # 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. # is the exception: a stack dump naming what held a frame up belongs here.
@@ -272,8 +425,9 @@ def main(argv=None) -> int:
print(f"asked for {requested:.1f} px/s -> {choice.describe()}") print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
helper.set_sub_pixel_scrolling(False) helper.set_sub_pixel_scrolling(False)
helper.set_scrolling_image( strip = build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s",
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s")) screens=args.strip_screens)
helper.set_scrolling_image(strip)
# The display service's own recorder, owned outright here: never flushed to # 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 # the service's stats file, drained exactly at the start and end of the
@@ -288,8 +442,23 @@ def main(argv=None) -> int:
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
display.frame_timing = recorder display.frame_timing = recorder
print(f"scrolling {width}x{height} for {args.seconds:.0f}s" work = StripWork(helper, recorder, strip,
+ (f" with {args.busy} background worker(s)" if args.busy else "") patch_bytes=args.patch_bytes, patch_every=args.patch_every,
patch_where=args.patch_where,
extend_every_screens=args.extend_every_screens,
extend_width=args.extend_width)
doing = []
if args.busy:
doing.append(f"{args.busy} background worker(s)")
if args.patch_bytes > 0:
doing.append(f"a {args.patch_bytes}B {args.patch_where} patch every "
f"{args.patch_every} frame(s)")
if work.extend_every:
doing.append(f"an extension every {args.extend_every_screens:g} screens")
print(f"scrolling {width}x{height} ({helper.cached_array.shape[1]}px strip) "
f"for {args.seconds:.0f}s"
+ (" with " + ", ".join(doing) if doing else "")
+ " ...", flush=True) + " ...", flush=True)
frames = 0 frames = 0
@@ -309,8 +478,11 @@ def main(argv=None) -> int:
before = recorder.snapshot() before = recorder.snapshot()
run_started = now run_started = now
frames = duplicates = blanks = restarts = 0 frames = duplicates = blanks = restarts = 0
work.patches = work.patched_bytes = work.extensions = 0
if run_started is not None and now - run_started >= args.seconds: if run_started is not None and now - run_started >= args.seconds:
break break
# Where Vegas does its strip work: before the frame is drawn.
work.before_frame()
helper.update_scroll_position() helper.update_scroll_position()
if helper.is_scroll_complete(): if helper.is_scroll_complete():
# The helper parks at the end of the strip and stops # The helper parks at the end of the strip and stops
@@ -320,6 +492,7 @@ def main(argv=None) -> int:
# the benchmark measures a still image for the rest of the # the benchmark measures a still image for the rest of the
# run and reports a smoothness it never demonstrated. # run and reports a smoothness it never demonstrated.
helper.reset_scroll() helper.reset_scroll()
work.reset()
restarts += 1 restarts += 1
visible = helper.get_visible_portion() visible = helper.get_visible_portion()
column = int(helper.scroll_position) column = int(helper.scroll_position)
@@ -375,6 +548,11 @@ def main(argv=None) -> int:
if restarts: if restarts:
print(f"restarts {restarts} (the strip was scrolled through " print(f"restarts {restarts} (the strip was scrolled through "
f"{restarts} time{'s' if restarts != 1 else ''})") f"{restarts} time{'s' if restarts != 1 else ''})")
if work.patches:
print(f"patches {work.patches} ({work.patched_bytes / 1e6:.1f} MB "
"written into the strip)")
if work.extensions:
print(f"extensions {work.extensions}")
if args.json_path: if args.json_path:
report.update({ report.update({
@@ -388,6 +566,13 @@ def main(argv=None) -> int:
"duplicate_frames": duplicates, "duplicate_frames": duplicates,
"blank_frames": blanks, "blank_frames": blanks,
"strip_restarts": restarts, "strip_restarts": restarts,
"strip_screens": args.strip_screens,
"patch_bytes": args.patch_bytes,
"patch_every": args.patch_every,
"patch_where": args.patch_where,
"patches": work.patches,
"extend_every_screens": args.extend_every_screens,
"extensions": work.extensions,
"max_late_pct": args.max_late_pct, "max_late_pct": args.max_late_pct,
"passed": frame_soak.passed(report, args.max_late_pct), "passed": frame_soak.passed(report, args.max_late_pct),
}) })
+69 -12
View File
@@ -66,6 +66,17 @@ 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 frame late), both of which look self-consistent to an estimate taken from
their own intervals. their own intervals.
Operations
----------
The late count says how often, not which work did it. Render-thread work that
happens between two frames -- extending the Vegas strip, patching a live
element into it -- calls :meth:`FrameTimingRecorder.note_op` first, and the
next presented frame carries the tag: the interval that frame ends is the one
the work landed in. ``op_frames`` counts timed frames per kind,
``late_op_frames`` the late ones among them, ``op_freezes`` those that were a
freeze instead, and ``op_bytes`` what the work moved. A kind whose late rate
sits well above the overall one is the work to look at.
Stall watchdog Stall watchdog
-------------- --------------
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
@@ -153,11 +164,20 @@ def default_stats_path() -> str:
return os.path.join(base, STATS_FILENAME) return os.path.join(base, STATS_FILENAME)
#: One presented frame's interval: (interval, blit, wait, hold, ops), where
#: ops is the work noted before it (kind -> bytes) or None.
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
def _bucket(seconds: float) -> int: def _bucket(seconds: float) -> int:
index = int(seconds * 1000.0 / BUCKET_MS) index = int(seconds * 1000.0 / BUCKET_MS)
return min(max(index, 0), BUCKET_COUNT - 1) return min(max(index, 0), BUCKET_COUNT - 1)
def _bump(counter: Dict[str, int], key: str, by: int = 1) -> None:
counter[key] = counter.get(key, 0) + by
def binding_releases_gil() -> Optional[bool]: def binding_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None. """Whether the loaded rgbmatrix binding releases the GIL, or None.
@@ -250,12 +270,14 @@ class FrameTimingRecorder:
self.info = dict(info or {}) self.info = dict(info or {})
# Render-thread state. # Render-thread state.
self._pending: List[Tuple[float, float, float, int]] = [] self._pending: List[_Frame] = []
self._static_frames = 0 self._static_frames = 0
self._previous: Optional[Tuple[float, bool, int]] = None self._previous: Optional[Tuple[float, bool, int]] = None
# The interval ended by a static frame that followed a scrolling one, # The interval ended by a static frame that followed a scrolling one,
# until the next frame shows whether the scroll went on. # until the next frame shows whether the scroll went on.
self._unsure: Optional[Tuple[float, float, float, int]] = None self._unsure: Optional[_Frame] = None
# Work noted since the last frame (kind -> bytes), for the next one.
self._ops: Optional[Dict[str, int]] = None
self._last_flush: Optional[float] = None self._last_flush: Optional[float] = None
self._queue: "queue.SimpleQueue" = queue.SimpleQueue() self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
self._worker: Optional[threading.Thread] = None self._worker: Optional[threading.Thread] = None
@@ -281,6 +303,11 @@ class FrameTimingRecorder:
"freeze_seconds": 0.0, "freeze_seconds": 0.0,
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS}, "freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
"worst_interval_ms": 0.0, "worst_interval_ms": 0.0,
# Per kind of noted render-thread work; see "Operations".
"op_frames": {},
"late_op_frames": {},
"op_freezes": {},
"op_bytes": {},
} }
self.histograms: Dict[str, Dict[int, int]] = { self.histograms: Dict[str, Dict[int, int]] = {
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {}, "blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
@@ -305,6 +332,21 @@ class FrameTimingRecorder:
# -- render thread ------------------------------------------------------ # -- render thread ------------------------------------------------------
def note_op(self, kind: str, nbytes: int = 0) -> None:
"""Tag the next presented frame with work done before it.
Render thread only, like :meth:`record`, which consumes the tag: the
interval the next frame ends is the one this work landed in. Several
notes before one frame accumulate, per kind. See "Operations".
:param kind: a short name for the work, e.g. ``"extend"``, ``"patch"``.
:param nbytes: how much the work moved, summed into ``op_bytes``.
"""
ops = self._ops
if ops is None:
ops = self._ops = {}
ops[kind] = ops.get(kind, 0) + int(nbytes)
def record(self, blit: float, wait: float, hold: int, scrolling: bool, def record(self, blit: float, wait: float, hold: int, scrolling: bool,
presented_at: float) -> None: presented_at: float) -> None:
"""One frame reached the panel. """One frame reached the panel.
@@ -318,6 +360,7 @@ class FrameTimingRecorder:
previous = self._previous previous = self._previous
self._previous = (presented_at, scrolling, hold) self._previous = (presented_at, scrolling, hold)
self.last_frame = (presented_at, scrolling, threading.get_ident()) self.last_frame = (presented_at, scrolling, threading.get_ident())
ops, self._ops = self._ops, None
if not scrolling: if not scrolling:
self._static_frames += 1 self._static_frames += 1
# The scroll ended, or its state went missing for this frame: the # The scroll ended, or its state went missing for this frame: the
@@ -325,7 +368,8 @@ class FrameTimingRecorder:
# state, so the interval is due at the scroll's own. # state, so the interval is due at the scroll's own.
self._unsure = None self._unsure = None
if previous is not None and previous[1]: if previous is not None and previous[1]:
self._unsure = (presented_at - previous[0], blit, wait, previous[2]) self._unsure = (presented_at - previous[0], blit, wait,
previous[2], ops)
elif self.watchdog is None and self.scrolling_now is not None \ elif self.watchdog is None and self.scrolling_now is not None \
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0": and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
self.watchdog = StallWatchdog(self, **watchdog_settings()) self.watchdog = StallWatchdog(self, **watchdog_settings())
@@ -335,14 +379,14 @@ class FrameTimingRecorder:
unsure, self._unsure = self._unsure, None unsure, self._unsure = self._unsure, None
if previous[1]: if previous[1]:
if interval < GAP_SECONDS: if interval < GAP_SECONDS:
self._pending.append((interval, blit, wait, hold)) self._pending.append((interval, blit, wait, hold, ops))
elif unsure is not None and interval < RESUME_SECONDS: elif unsure is not None and interval < RESUME_SECONDS:
# One static frame between two scrolling ones: the scroll never # One static frame between two scrolling ones: the scroll never
# stopped, only its state did. Both intervals were motion. # stopped, only its state did. Both intervals were motion.
self._static_frames -= 1 self._static_frames -= 1
if unsure[0] < GAP_SECONDS: if unsure[0] < GAP_SECONDS:
self._pending.append(unsure) self._pending.append(unsure)
self._pending.append((interval, blit, wait, hold)) self._pending.append((interval, blit, wait, hold, ops))
if self._last_flush is None: if self._last_flush is None:
self._last_flush = presented_at self._last_flush = presented_at
@@ -382,15 +426,17 @@ class FrameTimingRecorder:
except Exception: # never let telemetry take anything down except Exception: # never let telemetry take anything down
logger.debug("Frame timing flush failed", exc_info=True) logger.debug("Frame timing flush failed", exc_info=True)
def aggregate(self, batch: List[Tuple[float, float, float, int]], def aggregate(self, batch: List[_Frame], static: int) -> None:
static: int) -> None: """Fold one window of frames into the running totals.
"""Fold one window of frames into the running totals."""
Each frame is ``(interval, blit, wait, hold, ops)``; ``ops`` (the work
noted before it, or None) may be left off.
"""
totals = self.totals totals = self.totals
totals["static_frames"] += static totals["static_frames"] += static
per_hold = sorted(interval / max(1, hold) per_hold = sorted(frame[0] / max(1, frame[3]) for frame in batch
for interval, _, _, hold in batch if frame[0] < FREEZE_SECONDS)
if interval < FREEZE_SECONDS)
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH: if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
estimate = per_hold[len(per_hold) // 10] estimate = per_hold[len(per_hold) // 10]
current = self.refresh_period current = self.refresh_period
@@ -412,15 +458,22 @@ class FrameTimingRecorder:
period = self.refresh_period period = self.refresh_period
histograms = self.histograms histograms = self.histograms
for interval, blit, wait, hold in batch: for frame in batch:
interval, blit, wait, hold = frame[:4]
ops = frame[4] if len(frame) > 4 else None
totals["worst_interval_ms"] = max(totals["worst_interval_ms"], totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
interval * 1000.0) interval * 1000.0)
if ops:
for kind, nbytes in ops.items():
_bump(totals["op_bytes"], kind, nbytes)
if interval >= FREEZE_SECONDS: if interval >= FREEZE_SECONDS:
totals["freezes"] += 1 totals["freezes"] += 1
totals["freeze_seconds"] += interval totals["freeze_seconds"] += interval
label = next(name for limit, name in FREEZE_BUCKETS label = next(name for limit, name in FREEZE_BUCKETS
if interval < limit) if interval < limit)
totals["freeze_by"][label] += 1 totals["freeze_by"][label] += 1
for kind in ops or ():
_bump(totals["op_freezes"], kind)
continue continue
totals["scroll_frames"] += 1 totals["scroll_frames"] += 1
for name, value in (("blit", blit), ("wait", wait), for name, value in (("blit", blit), ("wait", wait),
@@ -432,6 +485,10 @@ class FrameTimingRecorder:
if period: if period:
totals["timed_frames"] += 1 totals["timed_frames"] += 1
missed = round(interval / period) - hold missed = round(interval / period) - hold
for kind in ops or ():
_bump(totals["op_frames"], kind)
if missed >= 1:
_bump(totals["late_op_frames"], kind)
if missed >= 1: if missed >= 1:
totals["late_frames"] += 1 totals["late_frames"] += 1
totals["missed_refreshes"] += missed totals["missed_refreshes"] += missed
+22
View File
@@ -328,6 +328,7 @@ class RenderPipeline:
return False return False
self._static_markers = self._markers_for_composition(blocks) self._static_markers = self._markers_for_composition(blocks)
self._note_op('compose', self._strip_nbytes())
# Track which plugins are in this scroll (get safely via buffer status) # Track which plugins are in this scroll (get safely via buffer status)
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids() self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
@@ -356,6 +357,22 @@ class RenderPipeline:
logger.exception("Error composing scroll content") logger.exception("Error composing scroll content")
return False return False
def _note_op(self, kind: str, nbytes: int = 0) -> None:
"""Tag the next presented frame with render-thread work done for it.
See "Operations" in src/common/frame_timing.py: a soak can then say
how often a frame straight after an extension or a patch was late,
rather than only how often any frame was.
"""
timing = getattr(self.display_manager, 'frame_timing', None)
note = getattr(timing, 'note_op', None)
if note is not None:
note(kind, nbytes)
def _strip_nbytes(self) -> int:
array = self.scroll_helper.cached_array
return int(array.nbytes) if array is not None else 0
def _markers_for_composition(self, blocks: List[Image.Image]) -> Tuple[Tuple[int, str], ...]: def _markers_for_composition(self, blocks: List[Image.Image]) -> Tuple[Tuple[int, str], ...]:
"""Static markers for a strip just built by create_scrolling_image. """Static markers for a strip just built by create_scrolling_image.
@@ -525,6 +542,7 @@ class RenderPipeline:
element_gap=0, element_gap=0,
) )
if appended: if appended:
self._note_op('extend', self._strip_nbytes())
logger.info( logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead", "[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width, plugin_id, self.scroll_helper.total_scroll_width,
@@ -626,6 +644,7 @@ class RenderPipeline:
) )
if not appended: if not appended:
return False return False
moved = self._strip_nbytes()
if statics: if statics:
# Where each block ends, laid out as append_content does: a # Where each block ends, laid out as append_content does: a
@@ -646,6 +665,9 @@ class RenderPipeline:
if cut and self._static_markers: if cut and self._static_markers:
self._static_markers = tuple( self._static_markers = tuple(
(max(0, x - cut), pid) for x, pid in self._static_markers) (max(0, x - cut), pid) for x, pid in self._static_markers)
# The append built the whole strip anew, and a trim copies what is
# left of it again: both land in the frame after this one.
self._note_op('extend', moved + (self._strip_nbytes() if cut else 0))
self._segments_in_scroll = [pid for pid, _ in grouped] self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1 self.stats['composition_count'] += 1
+363
View File
@@ -0,0 +1,363 @@
"""Attributing late frames to render-thread work (FrameTimingRecorder.note_op).
The recorder already says how often a moving frame was late. These tests pin
the part that says which work it followed: Vegas tags a strip extension or a
live-element patch before the frame it lands in, and the soak report gives
each kind its own late rate. The render bench drives the same work on a
schedule so it can be measured on a panel with nothing else running.
"""
import json
import sys
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from src.common.frame_timing import FrameTimingRecorder # noqa: E402
from src.common.scroll_helper import ScrollHelper # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
import frame_soak # noqa: E402
import render_bench # noqa: E402
PERIOD = 0.010 # a 100Hz panel
def _recorder(tmp_path, **kwargs):
return FrameTimingRecorder(path=str(tmp_path / "stats.json"),
flush_interval=1e9, refresh_hz=100.0, **kwargs)
def _frame(recorder, t, scrolling=True, hold=1):
recorder.record(0.002, 0.004, hold, scrolling, t)
def _totals(recorder):
recorder.drain()
return recorder.totals
# -- the recorder -----------------------------------------------------------
def test_a_noted_op_tags_the_next_frame_only(tmp_path):
r = _recorder(tmp_path)
t = 100.0
_frame(r, t)
for i in range(10):
if i == 4:
r.note_op("patch", 1000)
t += PERIOD
_frame(r, t)
totals = _totals(r)
assert totals["op_frames"] == {"patch": 1}
assert totals["late_op_frames"] == {}
assert totals["op_bytes"] == {"patch": 1000}
def test_a_late_frame_after_an_op_is_counted_against_it(tmp_path):
r = _recorder(tmp_path)
t = 100.0
_frame(r, t)
for i in range(20):
late = i in (5, 12)
if late or i == 15:
r.note_op("extend", 4_000_000)
t += 2 * PERIOD if late else PERIOD
_frame(r, t)
totals = _totals(r)
assert totals["late_frames"] == 2
assert totals["op_frames"] == {"extend": 3}
assert totals["late_op_frames"] == {"extend": 2}
assert totals["op_bytes"] == {"extend": 12_000_000}
def test_notes_before_one_frame_accumulate_per_kind(tmp_path):
r = _recorder(tmp_path)
_frame(r, 100.0)
r.note_op("patch", 100)
r.note_op("patch", 50)
r.note_op("extend", 7)
_frame(r, 100.0 + PERIOD)
totals = _totals(r)
assert totals["op_frames"] == {"patch": 1, "extend": 1}
assert totals["op_bytes"] == {"patch": 150, "extend": 7}
def test_an_op_before_a_freeze_is_a_freeze_not_a_late_frame(tmp_path):
r = _recorder(tmp_path)
_frame(r, 100.0)
r.note_op("extend", 10)
_frame(r, 100.4)
totals = _totals(r)
assert totals["freezes"] == 1
assert totals["op_freezes"] == {"extend": 1}
assert totals["op_frames"] == {}
assert totals["op_bytes"] == {"extend": 10}
def test_an_op_survives_the_scroll_state_going_missing_for_a_frame(tmp_path):
# The frame the op landed in was presented with the scroll state missing;
# the scroll resumed, so the interval counts, and so does its tag.
r = _recorder(tmp_path)
_frame(r, 100.0)
r.note_op("patch", 5)
_frame(r, 100.0 + 2 * PERIOD, scrolling=False)
_frame(r, 100.0 + 3 * PERIOD)
totals = _totals(r)
assert totals["late_frames"] == 1
assert totals["late_op_frames"] == {"patch": 1}
def test_an_op_noted_before_a_static_frame_is_dropped(tmp_path):
r = _recorder(tmp_path)
_frame(r, 100.0, scrolling=False)
r.note_op("patch", 5)
_frame(r, 101.0, scrolling=False)
_frame(r, 102.0, scrolling=False)
totals = _totals(r)
assert totals["op_frames"] == {} and totals["op_bytes"] == {}
def test_frames_before_the_period_is_known_are_not_op_frames(tmp_path):
# op_frames is a denominator for late_op_frames, which needs a period.
r = FrameTimingRecorder(path=str(tmp_path / "s.json"), flush_interval=1e9)
_frame(r, 100.0)
r.note_op("patch", 5)
_frame(r, 100.0 + PERIOD)
totals = _totals(r)
assert totals["op_frames"] == {}
assert totals["op_bytes"] == {"patch": 5}
def test_aggregate_still_takes_frames_without_ops(tmp_path):
r = _recorder(tmp_path)
r.aggregate([(PERIOD, 0.002, 0.004, 1)] * 5, 0)
assert r.totals["scroll_frames"] == 5
assert r.totals["op_frames"] == {}
# -- the soak report ----------------------------------------------------------
def _report(r, before):
r.drain()
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
return frame_soak.build_report(before, after, preview=False)
def test_the_soak_report_gives_each_kind_its_late_rate(tmp_path):
r = _recorder(tmp_path)
_frame(r, 50.0)
r.note_op("patch", 10)
_frame(r, 50.0 + PERIOD)
r.drain()
before = json.loads(json.dumps(r.snapshot()))
t = 100.0
_frame(r, t)
for i in range(400):
late = i % 100 == 0
if i % 4 == 0:
r.note_op("patch", 1000)
t += 2 * PERIOD if late else PERIOD
_frame(r, t)
report = _report(r, before)
row = report["ops"]["patch"]
assert row["frames"] == 100 # the frame before the baseline is not in it
assert row["late"] == 4
assert row["late_pct"] == 4.0
assert row["bytes"] == 100_000
def test_the_soak_report_has_no_op_rows_when_nothing_was_tagged(tmp_path, capsys):
r = _recorder(tmp_path)
before = json.loads(json.dumps(r.snapshot()))
t = 100.0
_frame(r, t)
for _ in range(200):
t += PERIOD
_frame(r, t)
report = _report(r, before)
assert report["ops"] == {}
frame_soak.print_report(report, 0.1)
assert "after work" not in capsys.readouterr().out
def test_the_soak_report_prints_the_op_table(tmp_path, capsys):
r = _recorder(tmp_path)
before = json.loads(json.dumps(r.snapshot()))
t = 100.0
_frame(r, t)
for i in range(200):
if i == 10:
r.note_op("extend", 3_000_000)
t += PERIOD
_frame(r, t)
frame_soak.print_report(_report(r, before), 0.1)
out = capsys.readouterr().out
assert "after work" in out
assert "extend" in out
def test_a_report_from_an_older_recorder_has_an_empty_op_table():
totals = {"late_frames": 0}
assert frame_soak.op_rows(totals) == {}
# -- Vegas tags its own work ---------------------------------------------------
W, H = 128, 32
class _DM:
width = W
height = H
def __init__(self, recorder):
self.image = Image.new("RGB", (W, H))
self.frame_timing = recorder
def set_scrolling_state(self, *a):
pass
def update_display(self):
pass
class _Stream:
def __init__(self, groups):
self.groups = groups
self.plugin_manager = type("PM", (), {"plugins": {}})()
self.plugin_adapter = None
self._i = 0
def get_grouped_content_for_composition(self):
return self.groups[0]
def get_active_plugin_ids(self):
return [pid for pid, _ in self.groups[0]]
def take_next_group(self, count=None, offscreen_only=False):
if self._i >= len(self.groups):
return []
group = self.groups[self._i]
self._i += 1
return group
class _NoteSpy:
def __init__(self):
self.notes = []
def note_op(self, kind, nbytes=0):
self.notes.append((kind, nbytes))
def _block(w):
return Image.new("RGB", (w, H), (255, 255, 255))
def test_vegas_tags_a_compose_and_every_extension():
spy = _NoteSpy()
groups = [[("a", [_block(600)])], [("b", [_block(600)])], [("c", [_block(600)])]]
p = RenderPipeline(VegasModeConfig(lead_in_width=0, continuous_scroll=True),
_DM(spy), _Stream(groups))
assert p.compose_scroll_content()
assert [kind for kind, _ in spy.notes] == ["compose"]
p.scroll_helper.scroll_position = 400.0 # far enough to trim on extension
assert p.extend_scroll_content()
kind, moved = spy.notes[-1]
assert kind == "extend"
# The append built the whole strip anew and the trim copied it again.
assert moved > p.scroll_helper.cached_array.nbytes
def test_vegas_without_a_recorder_does_not_fail():
class DM(_DM):
def __init__(self):
super().__init__(None)
groups = [[("a", [_block(600)])], [("b", [_block(600)])]]
p = RenderPipeline(VegasModeConfig(lead_in_width=0, continuous_scroll=True),
DM(), _Stream(groups))
assert p.compose_scroll_content()
assert p.extend_scroll_content()
# -- the render bench's strip work --------------------------------------------
def _helper(screens=6):
helper = ScrollHelper(W, H)
helper.set_scrolling_image(render_bench.build_strip(W, H, "t", screens=screens))
return helper
def test_the_bench_strip_can_be_made_vegas_wide():
assert render_bench.build_strip(W, H, "t", screens=30).width >= W * 30
def test_a_visible_patch_writes_its_columns_on_screen_and_nothing_else():
helper = _helper()
spy = _NoteSpy()
work = render_bench.StripWork(helper, spy, helper.cached_image,
patch_bytes=40 * H * 3, patch_every=1)
helper.scroll_position = 200.0
before = helper.cached_array.copy()
work.before_frame()
changed = np.flatnonzero((helper.cached_array != before).any(axis=(0, 2)))
assert changed.size
assert changed.min() >= 200 and changed.max() < 200 + W
assert changed.max() - changed.min() < 40
assert spy.notes == [("patch", 40 * H * 3)]
assert work.patches == 1
def test_an_ahead_patch_lands_past_the_viewport():
helper = _helper()
work = render_bench.StripWork(helper, _NoteSpy(), helper.cached_image,
patch_bytes=20 * H * 3, patch_every=1,
patch_where="ahead")
helper.scroll_position = 100.0
before = helper.cached_array.copy()
work.before_frame()
changed = np.flatnonzero((helper.cached_array != before).any(axis=(0, 2)))
assert changed.min() >= 100 + W
def test_patches_run_every_k_frames():
helper = _helper()
spy = _NoteSpy()
work = render_bench.StripWork(helper, spy, helper.cached_image,
patch_bytes=10 * H * 3, patch_every=5)
for _ in range(20):
work.before_frame()
assert work.patches == 4 and len(spy.notes) == 4
def test_extensions_keep_the_strip_bounded_and_the_scroll_going():
helper = _helper(screens=8)
helper.set_pixels_per_frame(4)
spy = _NoteSpy()
work = render_bench.StripWork(helper, spy, helper.cached_image,
extend_every_screens=2)
widths = []
for _ in range(3000):
work.before_frame()
helper.update_scroll_position()
assert not helper.is_scroll_complete()
widths.append(helper.cached_array.shape[1])
assert work.extensions >= 10
assert {kind for kind, _ in spy.notes} == {"extend"}
# Appends and trims balance: the strip holds its width (within one
# extension of it) instead of growing or running out ahead of the viewport.
assert max(widths) - min(widths) <= 3 * W
assert widths[-1] >= widths[0] - W
assert helper.remaining_unscrolled() > 0