perf(scroll): build the strip's PIL image only when something reads it (#695)

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

* docs(changelog): note the frame-op attribution and bench modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 20:50:34 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 77862b631b
commit 596809acc3
5 changed files with 322 additions and 29 deletions
+14
View File
@@ -332,6 +332,20 @@ read any of them:
worker, which applies the latest one as soon as the lock frees, and before worker, which applies the latest one as soon as the lock frees, and before
the plugin's next update() at the latest. The plugin API is unchanged. the plugin's next update() at the latest. The plugin API is unchanged.
### Scrolling
- A Vegas strip extension costs the render thread about a third of what it
did. Appending the next group and trimming what has scrolled past each
rebuilt the strip's PIL image from its numpy array in full
(`Image.fromarray`: 1.7ms for an 8,000px strip, 3.8ms for 20,000px, on a
Pi 4 -- twice per extension), though every frame is cut from the array and
nothing on the frame path reads the image's pixels. `ScrollHelper` now
builds `cached_image` only when something reads it, which in Vegas means
only a multi-display sync push, and the strip is no longer held in memory
twice. Assigning `cached_image` still stores exactly what was assigned.
New `ScrollHelper.has_strip()` says whether there is a strip without
building its image; the frame path and Vegas use it.
### Tooling ### Tooling
- The frame-timing recorder says which render-thread work a late frame - The frame-timing recorder says which render-thread work a late frame
+70 -19
View File
@@ -111,7 +111,7 @@ class ScrollHelper:
self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds
self.scroll_speed = 1.0 self.scroll_speed = 1.0
self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms) self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms)
self.cached_image: Optional[Image.Image] = None self.cached_image = None # see the property below
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
self.total_scroll_width = 0 self.total_scroll_width = 0
@@ -172,7 +172,53 @@ class ScrollHelper:
# Scrolling state management # Scrolling state management
self.is_scrolling = False self.is_scrolling = False
self.scroll_complete = False self.scroll_complete = False
# -- the strip as a PIL image ---------------------------------------------
#
# Every frame is cut from cached_array; nothing on the frame path reads the
# PIL image's pixels. Extending and trimming a strip (append_content,
# drop_scrolled_prefix) used to rebuild that image in full each time
# anyway: Image.fromarray of a Vegas-sized strip is 1.7-3.8ms on a Pi 4,
# twice per extension, on the render thread. Those two now leave it to be
# built from the array on first read, which in Vegas means only by a
# multi-display sync push -- and the strip is not held twice in memory.
#
# Assigning cached_image still stores exactly what was assigned; a lazy
# image is only ever one the helper derived from its own array.
@property
def cached_image(self) -> Optional[Image.Image]:
"""The strip as a PIL image, built from ``cached_array`` if deferred."""
image = self.__dict__.get('_cached_image')
if image is not None:
return image
source = self.__dict__.get('_image_source')
if source is None:
return None
# Built from the array this read started with. Another thread (the
# sync push) may read while the render thread extends the strip; it
# then gets the strip as it was, as it did when the image was built
# eagerly, and the stale build is not kept.
image = Image.fromarray(source)
if self.__dict__.get('_image_source') is source:
self._cached_image = image
return image
@cached_image.setter
def cached_image(self, image: Optional[Image.Image]) -> None:
self._cached_image = image
self._image_source = None
def _defer_image(self) -> None:
"""The array just changed under the image: rebuild it only if read."""
self._cached_image = None
self._image_source = self.cached_array
def has_strip(self) -> bool:
"""Whether there is a strip (an image, or one deferred), not reading it."""
return (self.__dict__.get('_cached_image') is not None
or self.__dict__.get('_image_source') is not None)
def create_scrolling_image(self, content_items: list, def create_scrolling_image(self, content_items: list,
item_gap: int = 32, item_gap: int = 32,
element_gap: int = 16, element_gap: int = 16,
@@ -283,7 +329,7 @@ class ScrollHelper:
Otherwise the position advances by elapsed time at the configured Otherwise the position advances by elapsed time at the configured
speed. speed.
""" """
if not self.cached_image: if not self.has_strip():
return return
# Calculate frame time for consistent scroll speed regardless of FPS # Calculate frame time for consistent scroll speed regardless of FPS
@@ -427,7 +473,7 @@ class ScrollHelper:
Returns: Returns:
PIL Image showing the visible portion, or None if no cached image PIL Image showing the visible portion, or None if no cached image
""" """
if not self.cached_image or self.cached_array is None: if self.cached_array is None or not self.has_strip():
return None return None
start_x_int = int(self.scroll_position) start_x_int = int(self.scroll_position)
@@ -501,7 +547,7 @@ class ScrollHelper:
slices (128×32 = 12 KB) used here. slices (128×32 = 12 KB) used here.
""" """
_size = (self.display_width, self.display_height) _size = (self.display_width, self.display_height)
img_w = self.cached_image.width img_w = self.cached_array.shape[1]
if end_x <= img_w: if end_x <= img_w:
# Normal case: single contiguous slice (fastest path) # Normal case: single contiguous slice (fastest path)
@@ -646,7 +692,7 @@ class ScrollHelper:
if not content_items: if not content_items:
return False return False
if self.cached_image is None or self.cached_array is None: if self.cached_array is None or not self.has_strip():
# Nothing to extend yet — this is just the first build. # Nothing to extend yet — this is just the first build.
self.create_scrolling_image( self.create_scrolling_image(
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0) content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
@@ -666,13 +712,14 @@ class ScrollHelper:
addition.paste(img, (x, 0)) addition.paste(img, (x, 0))
x += img.width + element_gap x += img.width + element_gap
# numpy concatenate then one conversion back, rather than allocating a # numpy concatenate, and no conversion back: the strip can be tens of
# full-width PIL image and pasting twice: the strip can be tens of # thousands of columns wide and this runs on the render path. The PIL
# thousands of columns wide and this runs on the render path. # image is built from the array only if something reads it (see the
# cached_image property).
self.cached_array = np.concatenate( self.cached_array = np.concatenate(
(self.cached_array, np.array(addition)), axis=1) (self.cached_array, np.array(addition)), axis=1)
self.cached_image = Image.fromarray(self.cached_array) self._defer_image()
self.total_scroll_width = self.cached_image.width self.total_scroll_width = self.cached_array.shape[1]
self.scroll_complete = False self.scroll_complete = False
self.logger.info( self.logger.info(
@@ -699,14 +746,15 @@ class ScrollHelper:
Returns: Returns:
Number of columns actually removed Number of columns actually removed
""" """
if self.cached_image is None or self.cached_array is None: if self.cached_array is None or not self.has_strip():
return 0 return 0
strip_width = self.cached_array.shape[1]
# While the viewport wraps, get_visible_portion fills its right-hand side # While the viewport wraps, get_visible_portion fills its right-hand side
# from the *head* of the strip, so trimming the head would change what # from the *head* of the strip, so trimming the head would change what
# is on screen. Continuous mode extends before ever reaching that state; # is on screen. Continuous mode extends before ever reaching that state;
# refusing here keeps "trimming is invisible" true unconditionally. # refusing here keeps "trimming is invisible" true unconditionally.
if self.scroll_position + self.display_width > self.cached_image.width: if self.scroll_position + self.display_width > strip_width:
return 0 return 0
cut = int(self.scroll_position) - max(0, keep_before) cut = int(self.scroll_position) - max(0, keep_before)
@@ -714,15 +762,15 @@ class ScrollHelper:
return 0 return 0
# Never trim so far that the remaining strip is narrower than the # Never trim so far that the remaining strip is narrower than the
# viewport, or get_visible_portion has nothing to slice. # viewport, or get_visible_portion has nothing to slice.
cut = min(cut, max(0, self.cached_image.width - self.display_width)) cut = min(cut, max(0, strip_width - self.display_width))
if cut <= 0: if cut <= 0:
return 0 return 0
# .copy() so the original buffer is released rather than kept alive by # .copy() so the original buffer is released rather than kept alive by
# a numpy view. # a numpy view. The PIL image is deferred, as in append_content.
self.cached_array = self.cached_array[:, cut:].copy() self.cached_array = self.cached_array[:, cut:].copy()
self.cached_image = Image.fromarray(self.cached_array) self._defer_image()
self.total_scroll_width = self.cached_image.width self.total_scroll_width = self.cached_array.shape[1]
self.scroll_position -= cut self.scroll_position -= cut
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut) self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
@@ -734,7 +782,7 @@ class ScrollHelper:
def remaining_unscrolled(self) -> int: def remaining_unscrolled(self) -> int:
"""Columns of strip still to the right of the viewport.""" """Columns of strip still to the right of the viewport."""
if self.cached_image is None: if not self.has_strip():
return 0 return 0
return max(0, self.total_scroll_width - int(self.scroll_position) return max(0, self.total_scroll_width - int(self.scroll_position)
- self.display_width) - self.display_width)
@@ -1082,5 +1130,8 @@ class ScrollHelper:
'elapsed_time': (time.time() - self.scroll_start_time) 'elapsed_time': (time.time() - self.scroll_start_time)
if self.scroll_start_time if self.scroll_start_time
else None, else None,
'cached_image_size': (self.cached_image.width, self.cached_image.height) if self.cached_image else None # From the array: reading cached_image would build a deferred one.
'cached_image_size': ((self.cached_array.shape[1], self.cached_array.shape[0])
if self.cached_array is not None and self.has_strip()
else None)
} }
+8 -1
View File
@@ -12,6 +12,7 @@ from contextlib import contextmanager, nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.geometry import ( from src.vegas_mode.geometry import (
blank_runs, blank_runs,
separation_gap, separation_gap,
@@ -1354,7 +1355,13 @@ class PluginAdapter:
if helper is None: if helper is None:
continue continue
try: try:
if getattr(helper, 'cached_image', None) is not None: # has_strip() rather than reading cached_image, which would
# build a deferred image only to throw it away.
if isinstance(helper, ScrollHelper):
has_image = helper.has_strip()
else:
has_image = getattr(helper, 'cached_image', None) is not None
if has_image:
helper.cached_image = None helper.cached_image = None
cleared = True cleared = True
if getattr(helper, 'cached_array', None) is not None: if getattr(helper, 'cached_array', None) is not None:
+12 -9
View File
@@ -323,7 +323,7 @@ class RenderPipeline:
) )
# Verify scroll image was created successfully # Verify scroll image was created successfully
if not self.scroll_helper.cached_image: if not self.scroll_helper.has_strip():
logger.error("ScrollHelper failed to create cached image") logger.error("ScrollHelper failed to create cached image")
return False return False
@@ -341,7 +341,7 @@ class RenderPipeline:
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, " "Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
"separator=%dpx between plugins, rows spaced to %dpx of ink " "separator=%dpx between plugins, rows spaced to %dpx of ink "
"(min added %dpx)", "(min added %dpx)",
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0, self.scroll_helper.total_scroll_width if self.scroll_helper.has_strip() else 0,
self.display_height, self.display_height,
len(blocks), len(blocks),
total_rows, total_rows,
@@ -429,7 +429,7 @@ class RenderPipeline:
Cheap enough to call every frame: it is arithmetic over cached state. Cheap enough to call every frame: it is arithmetic over cached state.
""" """
if not self.config.continuous_scroll or not self.scroll_helper.cached_image: if not self.config.continuous_scroll or not self.scroll_helper.has_strip():
return False return False
threshold = int(self.display_width * self.config.extend_threshold_screens) threshold = int(self.display_width * self.config.extend_threshold_screens)
return self.scroll_helper.remaining_unscrolled() <= threshold return self.scroll_helper.remaining_unscrolled() <= threshold
@@ -599,8 +599,10 @@ class RenderPipeline:
else: else:
content.append((pid, images)) content.append((pid, images))
grouped = content grouped = content
strip_end = (self.scroll_helper.cached_image.width # From the helper's own bookkeeping, never cached_image: reading
if self.scroll_helper.cached_image is not None else 0) # that would build the full PIL strip the helper now defers.
strip_end = (self.scroll_helper.total_scroll_width
if self.scroll_helper.has_strip() else 0)
# Plugins the background thread had to defer need the shared canvas, # Plugins the background thread had to defer need the shared canvas,
# so they can only be fetched here. Queue them rather than doing all # so they can only be fetched here. Queue them rather than doing all
@@ -636,7 +638,7 @@ class RenderPipeline:
total_rows += len(images) total_rows += len(images)
blocks.append(self._join_plugin_rows(images)) blocks.append(self._join_plugin_rows(images))
had_strip = self.scroll_helper.cached_image is not None had_strip = self.scroll_helper.has_strip()
appended = self.scroll_helper.append_content( appended = self.scroll_helper.append_content(
content_items=blocks, content_items=blocks,
item_gap=self.config.separator_width, item_gap=self.config.separator_width,
@@ -740,7 +742,7 @@ class RenderPipeline:
frame_start = time.time() frame_start = time.time()
try: try:
if not self.scroll_helper.cached_image: if not self.scroll_helper.has_strip():
return False return False
# Update scroll position # Update scroll position
@@ -980,10 +982,11 @@ class RenderPipeline:
self.sync_manager.send_new_cycle() self.sync_manager.send_new_cycle()
# Push the actual scroll image over TCP so follower has identical pixels. # Push the actual scroll image over TCP so follower has identical pixels.
# Done in a background thread to not block the render loop (~15ms transfer). # Done in a background thread to not block the render loop (~15ms transfer).
if self.scroll_helper.cached_image is not None: image = self.scroll_helper.cached_image
if image is not None:
threading.Thread( threading.Thread(
target=self.sync_manager.send_scroll_image, target=self.sync_manager.send_scroll_image,
args=(self.scroll_helper.cached_image,), args=(image,),
daemon=True, name="sync-image-push" daemon=True, name="sync-image-push"
).start() ).start()
+218
View File
@@ -0,0 +1,218 @@
"""ScrollHelper builds the strip's PIL image only when something reads it.
Vegas extends and trims one long strip on the render thread. Each of those used
to rebuild ``cached_image`` from ``cached_array`` in full -- 1.7-3.8ms apiece on
a Pi 4 for a Vegas-sized strip, twice per extension -- though nothing on the
frame path reads the image's pixels. These tests pin that the frame path never
builds it, that a read still gets the right pixels, and that the two threads
which do read it (a multi-display sync push, the render thread) cannot leave a
stale image behind.
"""
import sys
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import scroll_helper as scroll_helper_module # noqa: E402
from src.common.scroll_helper import ScrollHelper # noqa: E402
W, H = 64, 16
def _block(width, seed):
# frombytes, not fromarray: the no_fromarray fixture refuses the latter
# everywhere, and a test block is not the strip.
rng = np.random.default_rng(seed)
pixels = rng.integers(0, 255, (H, width, 3), dtype=np.uint8)
return Image.frombytes("RGB", (width, H), pixels.tobytes())
def _helper(width=400):
helper = ScrollHelper(W, H)
helper.set_scrolling_image(_block(width, 0))
return helper
@pytest.fixture
def no_fromarray(monkeypatch):
"""Fail if the helper builds a PIL image from its array."""
def refuse(*_a, **_k):
raise AssertionError("the strip's PIL image was built")
monkeypatch.setattr(scroll_helper_module.Image, "fromarray", refuse)
def test_append_and_trim_do_not_build_the_image(no_fromarray):
helper = _helper()
helper.scroll_position = 300.0
assert helper.append_content([_block(200, 1)], item_gap=8)
assert helper.drop_scrolled_prefix(keep_before=W) > 0
assert helper.__dict__["_cached_image"] is None
def test_the_frame_path_never_builds_the_image(no_fromarray):
helper = _helper()
helper.set_pixels_per_frame(2)
for i in range(400):
if helper.remaining_unscrolled() <= 2 * W:
helper.append_content([_block(150, i)], item_gap=8)
helper.drop_scrolled_prefix(keep_before=W)
helper.update_scroll_position()
frame = helper.get_visible_portion()
assert frame is not None and frame.size == (W, H)
helper.get_scroll_info()
assert not helper.is_scroll_complete()
def test_reading_the_image_gives_the_strip_as_it_is():
helper = _helper()
helper.scroll_position = 250.0
helper.append_content([_block(120, 7)], item_gap=8)
helper.drop_scrolled_prefix(keep_before=W)
image = helper.cached_image
assert image.size == (helper.cached_array.shape[1], H)
assert np.array_equal(np.asarray(image), helper.cached_array)
assert helper.total_scroll_width == image.width
def test_the_built_image_is_kept_until_the_strip_changes():
helper = _helper()
helper.append_content([_block(50, 1)], item_gap=0)
first = helper.cached_image
assert helper.cached_image is first
helper.append_content([_block(50, 2)], item_gap=0)
assert helper.cached_image is not first
assert helper.cached_image.width == first.width + 50
def test_an_assigned_image_is_kept_exactly():
# Plugins and the multi-display follower assign cached_image themselves.
helper = _helper()
helper.append_content([_block(50, 1)], item_gap=0)
mine = _block(99, 3)
helper.cached_image = mine
assert helper.cached_image is mine
helper.cached_image = None
assert helper.cached_image is None
def test_frames_are_the_same_as_with_an_eager_image():
lazy = _helper()
eager = _helper()
blocks = [_block(90, 10 + i) for i in range(6)]
for i, block in enumerate(blocks):
for helper in (lazy, eager):
helper.scroll_position = 60.0 * (i + 1)
helper.append_content([block], item_gap=8)
helper.drop_scrolled_prefix(keep_before=W)
eager.cached_image = Image.fromarray(eager.cached_array) # the old way
for x in range(0, lazy.cached_array.shape[1] - W, 7):
lazy.scroll_position = eager.scroll_position = float(x)
assert lazy.get_visible_portion().tobytes() == \
eager.get_visible_portion().tobytes()
def test_a_read_racing_an_extension_does_not_keep_a_stale_image(monkeypatch):
# The sync push reads the image on its own thread. If the render thread
# extends the strip while that read is building the image, the read gets
# the strip as it was, and the next read must not be handed it again.
helper = _helper()
helper.append_content([_block(40, 1)], item_gap=0)
real = Image.fromarray
def build_while_the_strip_changes(array, *a, **k):
monkeypatch.setattr(scroll_helper_module.Image, "fromarray", real)
helper.append_content([_block(40, 2)], item_gap=0) # "render thread"
return real(array, *a, **k)
monkeypatch.setattr(scroll_helper_module.Image, "fromarray",
build_while_the_strip_changes)
before_width = helper.cached_array.shape[1]
raced = helper.cached_image
assert raced.width == before_width
fresh = helper.cached_image
assert fresh.width == before_width + 40
assert np.array_equal(np.asarray(fresh), helper.cached_array)
def test_clearing_the_cache_forgets_a_deferred_image():
helper = _helper()
helper.append_content([_block(40, 1)], item_gap=0)
helper.clear_cache()
assert helper.cached_image is None
assert not helper.has_strip()
assert helper.get_visible_portion() is None
assert helper.remaining_unscrolled() == 0
def test_a_helper_with_no_strip_has_nothing_to_scroll():
helper = ScrollHelper(W, H)
assert not helper.has_strip()
helper.update_scroll_position()
assert helper.scroll_position == 0.0
assert helper.get_scroll_info()["cached_image_size"] is None
def test_dropping_a_plugins_scroll_cache_does_not_build_it_first(no_fromarray):
# Vegas clears a plugin's own scroll cache on the render thread whenever
# the plugin updates; that must not build a deferred image to discard it.
from types import SimpleNamespace
from src.vegas_mode.plugin_adapter import PluginAdapter
helper = _helper()
helper.append_content([_block(40, 1)], item_gap=0)
dm = SimpleNamespace(width=W, height=H, image=Image.new("RGB", (W, H)))
adapter = PluginAdapter(dm)
assert adapter.invalidate_plugin_scroll_cache(
SimpleNamespace(scroll_helper=helper), "p")
assert helper.cached_array is None and not helper.has_strip()
def test_vegas_extends_without_building_the_image(no_fromarray):
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.render_pipeline import RenderPipeline
groups = [[("a", [_block(300, 1)])]] + [[(f"p{i}", [_block(300, i + 2)])]
for i in range(6)]
class Stream:
plugin_manager = type("PM", (), {"plugins": {}})()
plugin_adapter = None
i = 0
def get_grouped_content_for_composition(self):
return groups[0]
def get_active_plugin_ids(self):
return ["a"]
def take_next_group(self, count=None, offscreen_only=False):
self.i += 1
return groups[self.i] if self.i < len(groups) else []
class DM:
width, height = W, H
def __init__(self):
self.image = Image.new("RGB", (W, H))
def set_scrolling_state(self, *a):
pass
def update_display(self):
pass
p = RenderPipeline(VegasModeConfig(lead_in_width=0, continuous_scroll=True),
DM(), Stream())
# compose pastes into a new image and converts that to the array once;
# it never needs fromarray either.
assert p.compose_scroll_content()
for _ in range(5):
p.scroll_helper.scroll_position += 250
assert p.extend_scroll_content()
assert p.render_frame()
assert p.scroll_helper.__dict__["_cached_image"] is None