Files
LEDMatrix/test/test_frame_ops.py
T
ChuckandClaude Opus 5.5 1252df9bad 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>
2026-09-30 18:36:38 -04:00

364 lines
11 KiB
Python

"""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