feat(vegas): render plugin content off the render thread, and keep it off the GIL when the panel needs it (#630)

DisplayManager.offscreen() gives a thread its own canvas, so Vegas renders every plugin's ticker content on its prefetch thread instead of pausing the scroll for canvas-bound plugins on the render thread. A render gate (src/common/render_gate.py, vegas_scroll.prefetch_gate, on by default with the GIL-releasing binding) lets the prefetch thread run Python only while the render thread waits in SwapOnVSync: on hdpi, frames 2+ refreshes late fell eightfold and late frames overall from 0.90% to 0.60%. See docs/OFFSCREEN_RENDERING.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 19:57:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 865d62f67b
commit 9964dd2183
13 changed files with 1903 additions and 89 deletions
+31
View File
@@ -74,6 +74,31 @@ class VegasModeConfig:
# precedence over smooth_scroll's whole-pixel pacing when on.
sub_pixel_blend: bool = False
# Render every plugin's ticker content on the background prefetch thread,
# each on a canvas of its own (DisplayManager.offscreen), instead of
# handing plugins that draw on the display canvas to the render thread one
# at a time. Each of those cost the scroll a 40-600ms pause. False restores
# that path; it is kept for one release in case a plugin misbehaves when
# drawn off the render thread. See docs/OFFSCREEN_RENDERING.md.
offscreen_prefetch: bool = True
# How long another thread may hold the GIL before the render thread's
# request forces it to yield, in ms, while Vegas runs. CPython's default is
# 5ms. Plugin rendering on the prefetch thread and plugin updates hold the
# GIL in Pillow and Python code, and a frame waiting its turn for 5ms at a
# time misses its refresh. 0 leaves the interpreter default alone.
# Experimental. On hdpi it did less than prefetch_gate (0.90% -> 0.78% late
# against 0.60%; see docs/OFFSCREEN_RENDERING.md), so it stays off.
switch_interval_ms: float = 0.0
# Let the prefetch thread run Python only while the render thread is
# blocked waiting for vsync, and park it the rest of the time, so the
# render thread never waits for the GIL when its refresh comes round. Needs
# a binding that releases the GIL in SwapOnVSync; off otherwise. On hdpi
# it cut frames two or more refreshes late eightfold, and late frames
# overall from 0.90% to 0.60%. See src/common/render_gate.py.
prefetch_gate: bool = True
# Keep one continuous strip, extending it with the next group of plugins as
# the scroll approaches the end, instead of composing a fresh strip and
# swapping it in. A swap stops the motion, substitutes every pixel at once
@@ -207,6 +232,9 @@ class VegasModeConfig:
smooth_scroll=get('smooth_scroll', d.smooth_scroll),
sub_pixel_blend=bool(get('sub_pixel_blend', d.sub_pixel_blend)),
continuous_scroll=get('continuous_scroll', d.continuous_scroll),
offscreen_prefetch=bool(get('offscreen_prefetch', d.offscreen_prefetch)),
switch_interval_ms=float(get('switch_interval_ms', d.switch_interval_ms) or 0.0),
prefetch_gate=bool(get('prefetch_gate', d.prefetch_gate)),
extend_threshold_screens=float(
get('extend_threshold_screens', d.extend_threshold_screens)),
auto_trim=get('auto_trim', d.auto_trim),
@@ -250,6 +278,9 @@ class VegasModeConfig:
'smooth_scroll': self.smooth_scroll,
'sub_pixel_blend': self.sub_pixel_blend,
'continuous_scroll': self.continuous_scroll,
'offscreen_prefetch': self.offscreen_prefetch,
'switch_interval_ms': self.switch_interval_ms,
'prefetch_gate': self.prefetch_gate,
'extend_threshold_screens': self.extend_threshold_screens,
'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold,
+64 -1
View File
@@ -13,10 +13,12 @@ Supports three display modes per plugin:
import logging
import math
import sys
import time
import threading
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
from src.common import render_gate
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.vegas_mode.stream_manager import StreamManager
@@ -101,7 +103,8 @@ class VegasModeCoordinator:
self.plugin_manager = plugin_manager
# Initialize components
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
self.plugin_adapter = PluginAdapter(
display_manager, self.vegas_config, plugin_manager=plugin_manager)
self.stream_manager = StreamManager(
self.vegas_config,
plugin_manager,
@@ -276,6 +279,8 @@ class VegasModeCoordinator:
# due immediately so the first sample confirms the marquee is up.
self._fps_last_health_log = 0.0
self._fps_was_degraded = False
self._apply_switch_interval()
self._install_render_gate()
# Line up the next group immediately, so the first extension is already
# warm rather than stalling the scroll to fetch it.
@@ -298,6 +303,9 @@ class VegasModeCoordinator:
self.stats['total_runtime_seconds'] += time.time() - self._start_time
self._start_time = None
self._restore_switch_interval()
self._remove_render_gate()
# Cleanup components
self.render_pipeline.reset()
self.stream_manager.reset()
@@ -305,6 +313,57 @@ class VegasModeCoordinator:
logger.info("Vegas mode stopped")
def _apply_switch_interval(self) -> None:
"""Shorten the GIL switch interval for the run; see VegasModeConfig."""
ms = self.vegas_config.switch_interval_ms
if not ms or ms <= 0:
return
if getattr(self, '_saved_switch_interval', None) is None:
self._saved_switch_interval = sys.getswitchinterval()
sys.setswitchinterval(ms / 1000.0)
logger.info("Vegas: GIL switch interval %.1fms (was %.1fms)",
ms, self._saved_switch_interval * 1000.0)
def _restore_switch_interval(self) -> None:
saved = getattr(self, '_saved_switch_interval', None)
if saved is not None:
sys.setswitchinterval(saved)
self._saved_switch_interval = None
def _install_render_gate(self) -> None:
"""Gate the prefetch thread on the render thread's swaps; see VegasModeConfig."""
if not self.vegas_config.prefetch_gate:
return
if getattr(self.display_manager, 'render_gate', None) is not None:
return
releases = render_gate.swap_releases_gil()
if releases is None:
logger.debug("Vegas: no prefetch gate -- no hardware binding loaded")
return
if not releases:
# On by default, so this is every stock install: say so once per
# run, not as a warning.
logger.info("Vegas: no prefetch gate -- this rgbmatrix binding keeps "
"the GIL in SwapOnVSync (scripts/build_rgbmatrix_nogil.sh)")
return
gate = render_gate.RenderGate()
# Locks the render thread takes too: never park the prefetch holding one.
gate.guard(self._state_lock,
getattr(self.stream_manager, '_buffer_lock', None),
getattr(self.render_pipeline, '_buffer_lock', None),
getattr(self.render_pipeline, '_prefetch_lock', None),
getattr(self.plugin_adapter, '_cache_lock', None))
self.display_manager.render_gate = gate
logger.info("Vegas: prefetch gated on vsync")
def _remove_render_gate(self) -> None:
gate = getattr(self.display_manager, 'render_gate', None)
if gate is None:
return
self.display_manager.render_gate = None
logger.info("Vegas: prefetch gate parked the prefetch %d times, %.1fs in all",
gate.parks, gate.parked_seconds)
def pause(self) -> None:
"""Pause Vegas mode (for live priority interruption)."""
with self._state_lock:
@@ -524,6 +583,10 @@ class VegasModeCoordinator:
fps, target, fps_frame_count,
p99 * 1000.0, frame_worst * 1000.0
)
gate = getattr(self.display_manager, 'render_gate', None)
if gate is not None:
logger.info("Vegas: prefetch parked %d times, %.1fs in all",
gate.parks, gate.parked_seconds)
self._fps_last_health_log = current_time
else:
logger.debug(
+114 -57
View File
@@ -8,7 +8,7 @@ implement get_vegas_content() and fallback capture of display() output.
import logging
import threading
import time
from contextlib import nullcontext
from contextlib import contextmanager, nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image
@@ -33,7 +33,13 @@ class PluginAdapter:
2. Fallback: Capture display_manager.image after calling plugin.display()
"""
def __init__(self, display_manager: Any, config: Optional[Any] = None):
#: How long a background fetch waits for a plugin's update() to finish
#: before skipping the plugin this round. Off the render thread waiting
#: costs nothing visible; it only delays that one plugin's content.
PLUGIN_LOCK_TIMEOUT = 2.0
def __init__(self, display_manager: Any, config: Optional[Any] = None,
plugin_manager: Optional[Any] = None):
"""
Initialize the plugin adapter.
@@ -42,8 +48,13 @@ class PluginAdapter:
config: VegasModeConfig controlling trim behaviour. When omitted,
trimming runs with the dataclass defaults, so existing callers
and tests keep working unchanged.
plugin_manager: Source of the per-plugin lock that keeps a
background fetch from running a plugin's display() while its
update() is mid-flight. Optional: without it, fetches take no
lock, as they always did.
"""
self.display_manager = display_manager
self.plugin_manager = plugin_manager
if config is None:
from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig()
@@ -91,13 +102,14 @@ class PluginAdapter:
Args:
plugin: Plugin instance to get content from
plugin_id: Plugin identifier for logging
offscreen_only: Skip every path that touches the shared display
canvas, for callers running off the render thread. The canvas
and the matrix proxy are process-wide mutable state, so
narrowing or capturing through them from another thread would
corrupt the frame the render loop is pushing. Returns None when
the plugin can only be served that way, leaving the caller to
fetch it on the render thread.
offscreen_only: The caller is off the render thread. Every content
path draws on a canvas of its own (DisplayManager.offscreen),
so all of them are safe there; the fetch also takes the
plugin's lock, waiting up to PLUGIN_LOCK_TIMEOUT for a running
update() to finish. With ``offscreen_prefetch`` switched off,
the old behaviour applies instead: paths that need a canvas
return None, leaving the caller to fetch the plugin on the
render thread.
Returns:
List of PIL Images representing plugin content, or None if no content
@@ -117,11 +129,77 @@ class PluginAdapter:
)
return cached
# The old contract, kept behind the switch: background callers may
# not draw, so anything needing a canvas is left for the render thread.
restricted = offscreen_only and not getattr(
self.config, 'offscreen_prefetch', True)
if not offscreen_only or restricted:
return self._fetch_content(plugin, plugin_id, restricted)
with self._plugin_lock(plugin_id) as acquired:
if not acquired:
logger.warning(
"[%s] update() still running after %.0fs; skipping it this "
"round", plugin_id, self.PLUGIN_LOCK_TIMEOUT
)
return None
return self._fetch_content(plugin, plugin_id, restricted=False)
@contextmanager
def _plugin_lock(self, plugin_id: str):
"""Hold the plugin's update/display lock, waiting a bounded time.
Yields whether it was acquired. Yields True, holding nothing, when
there is no plugin manager to ask -- the behaviour before the lock was
taken here at all.
"""
if not hasattr(self.plugin_manager, 'get_plugin_lock'):
yield True
return
lock = self.plugin_manager.get_plugin_lock(plugin_id)
acquired = lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT)
try:
yield acquired
finally:
if acquired:
lock.release()
@contextmanager
def _isolated_canvas(self, width: Optional[int] = None):
"""A canvas for the plugin to draw on that nothing else sees.
DisplayManager.offscreen() gives the calling thread its own canvas, so
this is safe on any thread and leaves the shared canvas untouched.
Older display managers and test doubles without it get the previous
behaviour: capture on the shared canvas, narrowed with render_size,
then restore it -- which is only safe on the render thread.
"""
offscreen = getattr(self.display_manager, 'offscreen', None)
if offscreen is not None:
with offscreen(width):
yield
return
original_image = self.display_manager.image.copy()
try:
with self._capture(), self._render_at(width or self.display_width):
yield
finally:
self.display_manager.image = original_image
def _fetch_content(
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool
) -> Optional[List[Image.Image]]:
"""Every content path in order: native, scroll helper, display capture.
``restricted`` is the pre-offscreen contract for background callers:
skip every path that needs a canvas and return None instead.
"""
# Try native Vegas content method first
has_native = hasattr(plugin, 'get_vegas_content')
logger.debug("[%s] Has get_vegas_content: %s", plugin_id, has_native)
if has_native:
content = self._get_native_content(plugin, plugin_id, offscreen_only)
content = self._get_native_content(plugin, plugin_id, restricted)
if content:
total_width = sum(img.width for img in content)
logger.debug(
@@ -134,7 +212,7 @@ class PluginAdapter:
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
has_scroll_helper = hasattr(plugin, 'scroll_helper')
logger.debug("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
content = self._get_scroll_helper_content(plugin, plugin_id, restricted)
if content:
total_width = sum(img.width for img in content)
logger.debug(
@@ -145,8 +223,8 @@ class PluginAdapter:
if has_scroll_helper:
logger.debug("[%s] ScrollHelper content returned None", plugin_id)
if offscreen_only:
# Display capture needs the shared canvas; leave it to the caller.
if restricted:
# Display capture needs a canvas; leave it to the caller.
logger.debug(
"[%s] Needs display capture, deferring to the render thread",
plugin_id
@@ -682,7 +760,7 @@ class PluginAdapter:
return img.crop((start, 0, end, img.height))
def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content via plugin's native get_vegas_content() method.
@@ -711,22 +789,21 @@ class PluginAdapter:
plugin._vegas_render_width = render_width
try:
# capture_mode unconditionally, even at full width. Building
# Vegas content is an off-screen operation, but a plugin is free
# to call update_display() while doing it — and outside
# capture_mode that write lands on the hardware, flashing the
# panel mid-scroll. The narrowing context is separate because it
# is a no-op at full width.
if offscreen_only:
# _render_at swaps the shared canvas, so it is unsafe here.
# _vegas_render_width is set regardless: a plugin reading
# get_vegas_render_width() still gets its narrow size, and
# one that only reads matrix.width renders full width and is
# trimmed instead.
# On a canvas of its own even at full width. Building Vegas
# content is an off-screen operation, but a plugin is free to
# call update_display() while doing it, and on the shared canvas
# that write would land on the hardware, flashing the panel
# mid-scroll.
if restricted:
# Restricted (offscreen_prefetch off): no canvas of our own,
# so no narrowing. _vegas_render_width is set regardless: a
# plugin reading get_vegas_render_width() still gets its
# narrow size, and one that only reads matrix.width renders
# full width and is trimmed instead.
with self._capture():
result = plugin.get_vegas_content()
else:
with self._capture(), self._render_at(render_width):
with self._isolated_canvas(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
@@ -807,7 +884,7 @@ class PluginAdapter:
return None
def _get_scroll_helper_content(
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content from plugin's scroll_helper if available.
@@ -841,7 +918,7 @@ class PluginAdapter:
"[%s] scroll_helper.cached_image is None, triggering content generation",
plugin_id
)
if offscreen_only:
if restricted:
# Generating it calls display(), which needs the canvas.
logger.debug(
"[%s] scroll_helper cache empty; deferring generation "
@@ -991,12 +1068,8 @@ class PluginAdapter:
Returns:
The generated cached_image or None
"""
original_image = None
try:
# Save display state to restore after
original_image = self.display_manager.image.copy()
with self._capture():
with self._isolated_canvas():
# Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'):
logger.debug(
@@ -1052,11 +1125,6 @@ class PluginAdapter:
logger.exception("[%s] Error triggering scroll content", plugin_id)
return None
finally:
# Restore original display state
if original_image is not None:
self.display_manager.image = original_image
def _capture_display_content(
self, plugin: 'BasePlugin', plugin_id: str
) -> Optional[List[Image.Image]]:
@@ -1070,12 +1138,7 @@ class PluginAdapter:
Returns:
List with single captured image, or None
"""
original_image = None
try:
# Save current display state
original_image = self.display_manager.image.copy()
logger.debug("[%s] Fallback: saved original display state", plugin_id)
# Ensure plugin has fresh data before capturing
has_update_data = hasattr(plugin, 'update_data')
logger.debug("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
@@ -1086,12 +1149,12 @@ class PluginAdapter:
except (AttributeError, RuntimeError, OSError):
logger.exception("[%s] Fallback: update_data() failed", plugin_id)
# Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display().
# Clear and call plugin display on a canvas of its own: nothing it
# draws, and no update_display() it calls, reaches the panel.
#
# render_size narrows the canvas the plugin lays out against, so a
# plugin that spreads across the whole panel produces a compact
# arrangement rather than one that has to be cropped afterwards.
# The canvas is render_width wide, so a plugin that spreads across
# the whole panel produces a compact arrangement rather than one
# that has to be cropped afterwards.
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.debug(
@@ -1099,7 +1162,7 @@ class PluginAdapter:
plugin_id, render_width, self.display_width
)
with self._capture(), self._render_at(render_width):
with self._isolated_canvas(render_width):
self.display_manager.clear()
logger.debug("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -1133,7 +1196,7 @@ class PluginAdapter:
plugin_id
)
# Try once more with force_clear=True
with self._capture(), self._render_at(render_width):
with self._isolated_canvas(render_width):
self.display_manager.clear()
plugin.display(force_clear=True)
captured = self.display_manager.image.copy()
@@ -1170,12 +1233,6 @@ class PluginAdapter:
)
return None
finally:
# Always restore original image to prevent display corruption
if original_image is not None:
self.display_manager.image = original_image
logger.debug("[%s] Fallback: restored original display state", plugin_id)
def _is_blank_image(
self, img: Image.Image, return_ratio: bool = False
) -> Union[bool, Tuple[bool, float]]:
+12 -4
View File
@@ -10,6 +10,7 @@ import os
import time
import threading
from collections import deque
from contextlib import nullcontext
from typing import Optional, List, Any, Dict, Deque
from PIL import Image
@@ -383,8 +384,12 @@ class RenderPipeline:
os.nice(10)
except (OSError, AttributeError):
pass
# With vegas_scroll.prefetch_gate on, run only while the render
# thread waits on vsync; see src/common/render_gate.py.
gate = getattr(self.display_manager, 'render_gate', None)
try:
group = self.stream_manager.take_next_group(offscreen_only=True)
with gate.yielding() if gate is not None else nullcontext():
group = self.stream_manager.take_next_group(offscreen_only=True)
except Exception:
logger.exception("Background prefetch failed")
group = []
@@ -509,9 +514,12 @@ class RenderPipeline:
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
if not grouped:
# Everything in this group is queued; the queue will extend the
# strip as it drains, so this is not a failure.
logger.info("Whole group deferred; strip will extend as it drains")
if deferred:
# Everything in this group is queued; the queue will extend
# the strip as it drains, so this is not a failure.
logger.info("Whole group deferred; strip will extend as it drains")
else:
logger.info("Nothing to show in this group; fetching the next")
self.start_prefetch()
return bool(deferred)
+11 -1
View File
@@ -688,6 +688,10 @@ class StreamManager:
Ordered list of (plugin_id, images). ``images`` is None when the
plugin could not be served under ``offscreen_only``, so the caller
can fetch just those on the render thread while keeping the order.
That only happens with ``offscreen_prefetch`` switched off: every
content path now draws on a canvas of its own, so a background
fetch that comes back empty had nothing to show, and ``images``
is an empty list rather than a request for the render thread.
"""
if count is None:
count = self.config.plugins_per_cycle
@@ -705,6 +709,9 @@ class StreamManager:
plugins = getattr(self.plugin_manager, 'plugins', {})
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
# Only the old contract hands anything back to the render thread.
defer_empty = offscreen_only and not getattr(
self.config, 'offscreen_prefetch', True)
for plugin_id in ids:
plugin = plugins.get(plugin_id)
@@ -719,7 +726,10 @@ class StreamManager:
continue
if images:
self.stats['segments_fetched'] += 1
group.append((plugin_id, images if images else None))
if images:
group.append((plugin_id, images))
else:
group.append((plugin_id, None if defer_empty else []))
return group