mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
perf(display): skip preview work nobody reads (lazy checksum, 1 Hz snapshot writer) (#717)
Mid-scroll, update_display() no longer checksums every frame: it asks is_currently_scrolling() once per frame and reuses the answer, hashes only when dirty tracking can skip a static frame, and the preview snapshot asks its policy first and hashes only when a write or touch could follow (decide() is monotone, pinned by a property test). With the preview open the snapshot is written at most once a second (VIEWER_INTERVAL 1.0 s, was 0.2 s; the SSE stream re-read it once a second, so four encodes in five went unread); the stream now polls its mtime every 0.25 s (VIEWER_POLL_INTERVAL), so the preview stays about as fresh. The PNG is written at compress_level=1. --preview soaks are not comparable across this change. Merged with #716: _scan_segments takes the frame's one scrolling answer and #716's static-handover pass-through, as soaked on ledpi (A B B A, 20 min each: main 0.118% / 0.113% late, with #716 and this 0.107% / 0.104%). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -231,7 +231,8 @@ rather than the `set_*` methods. Vegas mode reads a plugin's
|
||||
[`snapshot_policy.py`](snapshot_policy.py). Core-internal. `decide()`
|
||||
tells `DisplayManager` whether to write `/tmp/led_matrix_preview.png`, only
|
||||
touch its mtime, or skip, based on whether a browser is watching the preview.
|
||||
The web health check reads the file's age.
|
||||
The web health check reads the file's age, and the web preview stream checks
|
||||
its mtime every `VIEWER_POLL_INTERVAL`.
|
||||
|
||||
### sports_card
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ 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
|
||||
"Scrolling" is the scroll state ``DisplayManager.update_display`` acted on for
|
||||
the frame, sampled once before the blit and swap, 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
|
||||
@@ -393,7 +394,9 @@ class FrameTimingRecorder:
|
||||
: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 scrolling: whether a scroll was running for this frame: the
|
||||
scroll state ``update_display`` acted on, sampled once before the
|
||||
blit and swap.
|
||||
:param presented_at: ``time.perf_counter()`` when the swap returned.
|
||||
"""
|
||||
previous = self._previous
|
||||
|
||||
@@ -26,14 +26,33 @@ Policy:
|
||||
- Unchanged frames are never re-encoded; the mtime is touched every
|
||||
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||
|
||||
The writer and the SSE reader (web_interface/app.py) have two periods, not
|
||||
one shared value. The reader sends each write it sees, so the preview shows
|
||||
at most one frame per VIEWER_INTERVAL. (That was 0.2 s while the reader
|
||||
slept 1 s between reads, so four encodes in five were overwritten unread.)
|
||||
The reader checks the file's mtime every VIEWER_POLL_INTERVAL, which is only
|
||||
a stat, and so sends each write within that long of it landing. Equal
|
||||
periods would alias: two unsynchronised 1 s clocks leave the preview up to a
|
||||
second stale, and now and then 2 s between frames.
|
||||
|
||||
decide() is monotone in frame_changed: SKIP for a changed frame means SKIP
|
||||
for an unchanged one. DisplayManager relies on that to skip hashing the
|
||||
frame when even a changed one would be skipped; test_snapshot_policy.py
|
||||
checks it.
|
||||
|
||||
If any constant here changes, re-check the health threshold in
|
||||
api_v3/misc.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Snapshot cadence with a browser preview open (seconds).
|
||||
VIEWER_INTERVAL = 0.2
|
||||
# Snapshot cadence with a browser preview open (seconds): the shortest gap
|
||||
# between two preview frames.
|
||||
VIEWER_INTERVAL = 1.0
|
||||
# How often the web SSE reader checks the snapshot's mtime (seconds). Must
|
||||
# stay well under VIEWER_INTERVAL -- half of it at most -- or the two clocks
|
||||
# alias (see above).
|
||||
VIEWER_POLL_INTERVAL = 0.25
|
||||
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||
IDLE_INTERVAL = 30.0
|
||||
# Max age of the last write/touch before bumping mtime for the health
|
||||
|
||||
+81
-23
@@ -80,6 +80,15 @@ _CALENDAR_FONT_PX = 7
|
||||
#: frame, so a fault that persists would otherwise log ~100 lines a second.
|
||||
_UPDATE_ERROR_LOG_INTERVAL = 60.0
|
||||
|
||||
#: zlib level for the preview snapshot PNG. The fastest level: each file is
|
||||
#: read by the web UI and soon replaced by the next, so encode time (paid on
|
||||
#: the render thread for a static screen) matters more than its size.
|
||||
#: Lossless at any level. Against Pillow's default (6), on a desktop with
|
||||
#: Pillow 12.3, a text-dense 512x64 frame encoded in about half the time,
|
||||
#: into 12 KB instead of 7 KB; sparser frames saved less time (10-20%) and
|
||||
#: stayed under 2 KB.
|
||||
_SNAPSHOT_PNG_COMPRESS_LEVEL = 1
|
||||
|
||||
|
||||
def _bdf_native_size(face) -> int:
|
||||
"""The pixel height a BDF Face declares, or 0 if it does not say.
|
||||
@@ -296,8 +305,8 @@ class DisplayManager:
|
||||
self._TEXT_WIDTH_CACHE_MAX = 1024
|
||||
# Snapshot mirror for web preview + health check (service writes, web
|
||||
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
|
||||
# full rate only while the web SSE broadcaster keeps the viewer marker
|
||||
# fresh; unchanged frames are never re-encoded, only mtime-touched.
|
||||
# the viewer rate only while the web SSE broadcaster keeps the viewer
|
||||
# marker fresh; unchanged frames are never re-encoded, only mtime-touched.
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||
self._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
|
||||
self._last_snapshot_ts = 0.0
|
||||
@@ -946,16 +955,32 @@ class DisplayManager:
|
||||
self._write_snapshot_if_due()
|
||||
return
|
||||
|
||||
# Asked once per frame and the answer reused below: the call
|
||||
# has side effects (it expires a stale scroll and drops its
|
||||
# frame hold), so asking again further down could disagree
|
||||
# with what this frame was already treated as. Asked first,
|
||||
# so the dirty check, the scan-order segments, the pacing gate,
|
||||
# the swaps and frame timing all see one answer and the frame
|
||||
# hold it leaves.
|
||||
scrolling = self.is_currently_scrolling()
|
||||
digest = None
|
||||
frame_checksum = None
|
||||
if self._dirty_tracking_enabled:
|
||||
# No digest mid-scroll. The skip it feeds is never taken while
|
||||
# scrolling (see below), so all it bought there was the
|
||||
# snapshot's changed-frame check -- a tobytes() plus adler32
|
||||
# over the whole framebuffer every frame (~0.17ms at 256x64
|
||||
# on a Pi 4, twice that at 512x64) for a decision acted on at
|
||||
# most once a second. _write_snapshot_if_due hashes for
|
||||
# itself when a write or touch is actually due. The cost: the
|
||||
# first static frame after a scroll is always pushed, once.
|
||||
if self._dirty_tracking_enabled and not scrolling:
|
||||
try:
|
||||
brightness = getattr(self.matrix, 'brightness', None)
|
||||
except AttributeError:
|
||||
brightness = None
|
||||
frame_checksum = zlib.adler32(self.image.tobytes())
|
||||
digest = (frame_checksum, brightness)
|
||||
if digest == self._last_pushed_digest and not self.is_currently_scrolling():
|
||||
if digest == self._last_pushed_digest:
|
||||
# Nothing changed since the last push — the panel is
|
||||
# already showing exactly this frame.
|
||||
#
|
||||
@@ -982,7 +1007,7 @@ class DisplayManager:
|
||||
if self._double_sided is not None:
|
||||
segments = [(self._composite_double_sided(), self._frame_hold)]
|
||||
else:
|
||||
segments = self._scan_segments(self.image)
|
||||
segments = self._scan_segments(self.image, scrolling)
|
||||
gate = self.render_gate
|
||||
blit_time = swap_time = 0.0
|
||||
# Usually one segment: the frame, held for _frame_hold
|
||||
@@ -1008,7 +1033,7 @@ class DisplayManager:
|
||||
self._last_blit_seconds = blit_time / len(segments)
|
||||
self.frame_timing.record(
|
||||
blit_time, swap_time,
|
||||
self._frame_hold, self.is_currently_scrolling(), presented_at)
|
||||
self._frame_hold, scrolling, presented_at)
|
||||
|
||||
self._last_pushed_digest = digest
|
||||
|
||||
@@ -1055,7 +1080,9 @@ class DisplayManager:
|
||||
", ".join(f"rows {top}-{bottom - 1} show {lag} refresh(es) behind"
|
||||
for top, bottom, lag in bands))
|
||||
|
||||
def _scan_segments(self, image: Image.Image) -> List[Tuple[Image.Image, int]]:
|
||||
def _scan_segments(self, image: Image.Image,
|
||||
scrolling: Optional[bool] = None
|
||||
) -> List[Tuple[Image.Image, int]]:
|
||||
"""What to present for this frame: ``[(image, refreshes), ...]``.
|
||||
|
||||
Mid-scroll with compensation on, lagging rows are taken from earlier
|
||||
@@ -1068,10 +1095,17 @@ class DisplayManager:
|
||||
after a scroll, while the scroll state is still set (see
|
||||
end_scroll_for_static_screen): one segment, held for the scroll's
|
||||
hold, with no rows from the scroller's frames.
|
||||
|
||||
``scrolling`` is the caller's is_currently_scrolling() answer for this
|
||||
frame. update_display() asks once, before calling this, so the frame
|
||||
hold read here is the one that answer left (an expired scroll's hold
|
||||
is already dropped). None asks here.
|
||||
"""
|
||||
hold = self._frame_hold
|
||||
bands = getattr(self, '_scan_lag_bands', None)
|
||||
if (not bands or not self.is_currently_scrolling()
|
||||
if (not bands
|
||||
or not (scrolling if scrolling is not None
|
||||
else self.is_currently_scrolling())
|
||||
or self._static_handover):
|
||||
if bands:
|
||||
self._scan_history.clear()
|
||||
@@ -1563,9 +1597,10 @@ class DisplayManager:
|
||||
frame -- the bottom half of the old ticker under the new screen on a
|
||||
96x48 panel. At hold 1 that frame stays up for a whole second; at a
|
||||
longer hold its first refresh flashes the old rows. For a second frame
|
||||
in the same call, the rows would come from the first. Dirty tracking
|
||||
compares frames as drawn, so once the scroll is over it skips every
|
||||
identical 1 Hz redraw of such a frame, and nothing would replace it.
|
||||
in the same call, the rows would come from the first, shown for as long
|
||||
as the first's would be. Dirty tracking does not keep such a frame up
|
||||
past the screen's next redraw: a frame pushed while the scroll state
|
||||
is set leaves it no digest to match, so that redraw is pushed.
|
||||
|
||||
The rest of that scroll is left on purpose, until the controller ends
|
||||
it:
|
||||
@@ -1729,11 +1764,14 @@ class DisplayManager:
|
||||
|
||||
Args:
|
||||
frame_checksum: adler32 of the current frame, when the caller has
|
||||
already computed one. Dirty tracking checksums every frame a
|
||||
few lines above the call site, and re-deriving it here meant a
|
||||
second tobytes() plus a second pass over the whole framebuffer
|
||||
on every single frame — ~0.17ms per frame of the two combined
|
||||
at 256x64, paid 100 times a second to reach the same number.
|
||||
already computed one. Dirty tracking checksums every static
|
||||
frame a few lines above the call site, and re-deriving it here
|
||||
meant a second tobytes() plus a second pass over the whole
|
||||
framebuffer on every single frame — ~0.17ms per frame of the
|
||||
two combined at 256x64, paid 100 times a second to reach the
|
||||
same number. None (mid-scroll, dirty tracking off, no
|
||||
hardware): the frame is hashed here, and only when the policy
|
||||
could act on it.
|
||||
"""
|
||||
try:
|
||||
now = time.time()
|
||||
@@ -1744,11 +1782,29 @@ class DisplayManager:
|
||||
self._last_snapshot_ts = 0.0
|
||||
self._viewer_was_fresh = viewer_fresh
|
||||
|
||||
digest = (frame_checksum if frame_checksum is not None
|
||||
else zlib.adler32(self.image.tobytes()))
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, digest != self._last_snapshot_digest)
|
||||
if frame_checksum is not None:
|
||||
digest = frame_checksum
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, digest != self._last_snapshot_digest)
|
||||
else:
|
||||
# Ask as if the frame had changed before paying to find out.
|
||||
# decide() is monotone in frame_changed -- a SKIP for a
|
||||
# changed frame is a SKIP for an unchanged one too (its touch
|
||||
# branch ignores frame_changed) -- so returning here gives the
|
||||
# same answer the hash would have, and on most frames the hash
|
||||
# is never taken. test_snapshot_policy.py holds decide() to it.
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||
viewer_fresh, True)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
return
|
||||
digest = zlib.adler32(self.image.tobytes())
|
||||
if digest == self._last_snapshot_digest:
|
||||
# Unchanged after all: the decision an unchanged frame gets.
|
||||
action = snapshot_policy.decide(
|
||||
now, self._last_snapshot_ts,
|
||||
self._last_snapshot_touch_ts, viewer_fresh, False)
|
||||
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||
return
|
||||
if (action is snapshot_policy.SnapshotAction.TOUCH
|
||||
@@ -1826,7 +1882,8 @@ class DisplayManager:
|
||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(_fd, "wb") as _f:
|
||||
image.save(_f, format='PNG')
|
||||
image.save(_f, format='PNG',
|
||||
compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL)
|
||||
os.chmod(tmp_path, 0o644)
|
||||
os.replace(tmp_path, self._snapshot_path)
|
||||
except Exception:
|
||||
@@ -1837,7 +1894,8 @@ class DisplayManager:
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to direct save if replace not supported
|
||||
image.save(self._snapshot_path, format='PNG')
|
||||
image.save(self._snapshot_path, format='PNG',
|
||||
compress_level=_SNAPSHOT_PNG_COMPRESS_LEVEL)
|
||||
# Set proper file permissions after saving
|
||||
try:
|
||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||
|
||||
Reference in New Issue
Block a user