feat(vegas): prefetch_gate on by default, after an A/B/C soak on hdpi

Two runs per arm, about 81,000 frames each, order A B C C B A:

  A  step 1 as is            0.90% late, 20.1 per 10k two+ refreshes late
  B  switch_interval_ms 1    0.78% late, 15.8 per 10k
  C  prefetch_gate           0.60% late,  2.5 per 10k

No freezes in any arm, and the next group was ready at every strip
extension, so parking the prefetch thread (3-6s per 8-minute run) cost
nothing visible. The gate is now on unless vegas_scroll.prefetch_gate is
false; on a stock binding it cannot work and says so at INFO once a run
rather than warning on every install. switch_interval_ms stays off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 16:07:26 -04:00
co-authored by Claude Opus 5.5
parent de54fc879a
commit 1afb2383cd
5 changed files with 54 additions and 21 deletions
+2
View File
@@ -128,6 +128,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `min_cut_gap` | int, `6` | | `min_cut_gap` | int, `6` |
| `continuous_scroll` | bool, `true` | | `continuous_scroll` | bool, `true` |
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) | | `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts | | `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on | | `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
| `extend_threshold_screens` | float, `2.0` | | `extend_threshold_screens` | float, `2.0` |
+26 -4
View File
@@ -21,6 +21,28 @@ moved to the prefetch thread still needs the GIL, and the render thread waits
for it (risk 5 below). The late rate did not improve overall. The 1–2 s for it (risk 5 below). The late rate did not improve overall. The 1–2 s
freezes appear in both builds and have a separate, not yet identified cause. freezes appear in both builds and have a separate, not yet identified cause.
The GIL fix, measured on hdpi (90 px/s, `pwm_bits` 8, preview open, 8-minute
runs after a 2-minute warm-up, order A B C C B A, 2026-09-24). Each arm pools
two runs, about 81,000 frames:
| arm | late | by 1 | 2 | 3–5 | 6+ | 2+ late per 10k frames | freezes |
|---|---|---|---|---|---|---|---|
| A: step 1 as is | 0.90% | 575 | 64 | 91 | 9 | 20.1 | 0 |
| B: `switch_interval_ms` 1 | 0.78% | 510 | 105 | 23 | 2 | 15.8 | 0 |
| C: `prefetch_gate` | **0.60%** | 471 | 11 | 7 | 2 | **2.5** | 0 |
The gate removes the frames the render thread spent waiting for the GIL, and
it costs the prefetch nothing that shows: it parked the thread for 3–6 s per
run, and the next group was ready at every strip extension in every arm.
`prefetch_gate` is therefore on by default; `switch_interval_ms` stays an
off-by-default experiment. What is left is almost all one refresh late, which
is the per-frame budget (a 6.75 ms p50 blit in a refresh the panel holds at
83–85 Hz while rendering), not contention.
The runs restart the service, so the hourly sports refresh never fell inside
one. That refresh is its own case: about twenty ESPN chunk-fetch threads at
once, which the gate does not cover (it gates only the prefetch thread).
## The problem ## The problem
Vegas mode builds its ticker from every plugin's content. Most of that work Vegas mode builds its ticker from every plugin's content. Most of that work
@@ -285,9 +307,8 @@ updates are disabled while sync is active.
it, and a waiting thread only gets it back after the switch interval it, and a waiting thread only gets it back after the switch interval
(default 5 ms). Expect some single-refresh late frames while a prefetch (default 5 ms). Expect some single-refresh late frames while a prefetch
runs. Measure with the soak. A render process separate from plugin work runs. Measure with the soak. A render process separate from plugin work
is the structural answer (the "native presenter" step). Two opt-in is the structural answer (the "native presenter" step). Two experiments
experiments try to get most of the way first, both off by default until get most of the way first (results under Status, above):
the soak says otherwise:
- `vegas_scroll.switch_interval_ms` lowers the switch interval for a Vegas - `vegas_scroll.switch_interval_ms` lowers the switch interval for a Vegas
run (1 ms is the obvious try), so the render thread waits at most that run (1 ms is the obvious try), so the render thread waits at most that
long behind bytecode. It does nothing for a C call that keeps the GIL. long behind bytecode. It does nothing for a C call that keeps the GIL.
@@ -297,7 +318,8 @@ updates are disabled while sync is active.
parks it the rest of the time. That covers C calls too, since the gate is parks it the rest of the time. That covers C calls too, since the gate is
checked before each one starts. It never parks the thread while it holds checked before each one starts. It never parks the thread while it holds
a lock the render thread takes, and never for more than 50 ms. It needs a lock the render thread takes, and never for more than 50 ms. It needs
the rebuilt binding, which releases the GIL during the swap. the rebuilt binding, which releases the GIL during the swap. On by
default.
## What this does not fix ## What this does not fix
+7 -6
View File
@@ -87,16 +87,17 @@ class VegasModeConfig:
# 5ms. Plugin rendering on the prefetch thread and plugin updates hold the # 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 # 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. # time misses its refresh. 0 leaves the interpreter default alone.
# Experimental: measured with scripts/frame_soak.py before it gets a default. # 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 switch_interval_ms: float = 0.0
# Let the prefetch thread run Python only while the render thread is # 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 # 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 # render thread never waits for the GIL when its refresh comes round. Needs
# a binding that releases the GIL in SwapOnVSync; ignored otherwise. # a binding that releases the GIL in SwapOnVSync; off otherwise. On hdpi
# Experimental: see src/common/render_gate.py and measure with # it cut frames two or more refreshes late eightfold, and late frames
# scripts/frame_soak.py before it gets a default. # overall from 0.90% to 0.60%. See src/common/render_gate.py.
prefetch_gate: bool = False prefetch_gate: bool = True
# Keep one continuous strip, extending it with the next group of plugins as # 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 # the scroll approaches the end, instead of composing a fresh strip and
@@ -229,7 +230,7 @@ class VegasModeConfig:
continuous_scroll=vegas_config.get('continuous_scroll', True), continuous_scroll=vegas_config.get('continuous_scroll', True),
offscreen_prefetch=bool(vegas_config.get('offscreen_prefetch', True)), offscreen_prefetch=bool(vegas_config.get('offscreen_prefetch', True)),
switch_interval_ms=float(vegas_config.get('switch_interval_ms', 0.0) or 0.0), switch_interval_ms=float(vegas_config.get('switch_interval_ms', 0.0) or 0.0),
prefetch_gate=bool(vegas_config.get('prefetch_gate', False)), prefetch_gate=bool(vegas_config.get('prefetch_gate', True)),
extend_threshold_screens=float( extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)), vegas_config.get('extend_threshold_screens', 2.0)),
auto_trim=vegas_config.get('auto_trim', True), auto_trim=vegas_config.get('auto_trim', True),
+7 -5
View File
@@ -340,12 +340,14 @@ class VegasModeCoordinator:
if getattr(self.display_manager, 'render_gate', None) is not None: if getattr(self.display_manager, 'render_gate', None) is not None:
return return
releases = render_gate.swap_releases_gil() releases = render_gate.swap_releases_gil()
if releases is None:
logger.debug("Vegas: no prefetch gate -- no hardware binding loaded")
return
if not releases: if not releases:
logger.warning( # On by default, so this is every stock install: say so once per
"Vegas: prefetch_gate ignored -- %s", # run, not as a warning.
"this rgbmatrix binding keeps the GIL in SwapOnVSync " logger.info("Vegas: no prefetch gate -- this rgbmatrix binding keeps "
"(scripts/build_rgbmatrix_nogil.sh)" if releases is False "the GIL in SwapOnVSync (scripts/build_rgbmatrix_nogil.sh)")
else "no hardware binding loaded")
return return
gate = render_gate.RenderGate() gate = render_gate.RenderGate()
# Locks the render thread takes too: never park the prefetch holding one. # Locks the render thread takes too: never park the prefetch holding one.
+12 -6
View File
@@ -311,10 +311,16 @@ class TestVegasWiring:
c.display_manager = type("DM", (), {"render_gate": None})() c.display_manager = type("DM", (), {"render_gate": None})()
return c return c
def test_off_by_default(self, monkeypatch): def test_on_by_default(self, monkeypatch):
monkeypatch.setattr(render_gate, "swap_releases_gil", lambda: True) monkeypatch.setattr(render_gate, "swap_releases_gil", lambda: True)
c = self._coordinator() c = self._coordinator()
c._install_render_gate() c._install_render_gate()
assert isinstance(c.display_manager.render_gate, RenderGate)
def test_can_be_turned_off(self, monkeypatch):
monkeypatch.setattr(render_gate, "swap_releases_gil", lambda: True)
c = self._coordinator(prefetch_gate=False)
c._install_render_gate()
assert c.display_manager.render_gate is None assert c.display_manager.render_gate is None
def test_installed_for_the_run_and_removed_after(self, monkeypatch): def test_installed_for_the_run_and_removed_after(self, monkeypatch):
@@ -338,12 +344,12 @@ class TestVegasWiring:
def test_read_from_config(self): def test_read_from_config(self):
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
on = VegasModeConfig.from_config( off = VegasModeConfig.from_config(
{"display": {"vegas_scroll": {"prefetch_gate": True}}}) {"display": {"vegas_scroll": {"prefetch_gate": False}}})
assert on.prefetch_gate is True assert off.prefetch_gate is False
assert on.to_dict()["prefetch_gate"] is True assert off.to_dict()["prefetch_gate"] is False
assert VegasModeConfig.from_config( assert VegasModeConfig.from_config(
{"display": {"vegas_scroll": {}}}).prefetch_gate is False {"display": {"vegas_scroll": {}}}).prefetch_gate is True
def test_the_prefetch_runs_inside_the_gate(self): def test_the_prefetch_runs_inside_the_gate(self):
from src.vegas_mode.render_pipeline import RenderPipeline from src.vegas_mode.render_pipeline import RenderPipeline