From 1afb2383cd90d2ca28020408f99ecfc62695929e Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:07:26 -0400 Subject: [PATCH] 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 --- docs/CONFIG_REFERENCE.md | 2 ++ docs/OFFSCREEN_RENDERING.md | 30 ++++++++++++++++++++++++++---- src/vegas_mode/config.py | 13 +++++++------ src/vegas_mode/coordinator.py | 12 +++++++----- test/test_render_gate.py | 18 ++++++++++++------ 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 829430c5..2e5be9c0 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -128,6 +128,8 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See | `min_cut_gap` | int, `6` | | `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) | +| `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 | | `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` | diff --git a/docs/OFFSCREEN_RENDERING.md b/docs/OFFSCREEN_RENDERING.md index c0f64f39..b7b7cc65 100644 --- a/docs/OFFSCREEN_RENDERING.md +++ b/docs/OFFSCREEN_RENDERING.md @@ -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 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 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 (default 5 ms). Expect some single-refresh late frames while a prefetch runs. Measure with the soak. A render process separate from plugin work - is the structural answer (the "native presenter" step). Two opt-in - experiments try to get most of the way first, both off by default until - the soak says otherwise: + is the structural answer (the "native presenter" step). Two experiments + get most of the way first (results under Status, above): - `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 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 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 - 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 diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 0a8f55bc..aed12aba 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -87,16 +87,17 @@ class VegasModeConfig: # 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: 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 # 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; ignored otherwise. - # Experimental: see src/common/render_gate.py and measure with - # scripts/frame_soak.py before it gets a default. - prefetch_gate: bool = False + # 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 @@ -229,7 +230,7 @@ class VegasModeConfig: continuous_scroll=vegas_config.get('continuous_scroll', 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), - prefetch_gate=bool(vegas_config.get('prefetch_gate', False)), + prefetch_gate=bool(vegas_config.get('prefetch_gate', True)), extend_threshold_screens=float( vegas_config.get('extend_threshold_screens', 2.0)), auto_trim=vegas_config.get('auto_trim', True), diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index fda5af2e..6713af02 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -340,12 +340,14 @@ class VegasModeCoordinator: 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: - logger.warning( - "Vegas: prefetch_gate ignored -- %s", - "this rgbmatrix binding keeps the GIL in SwapOnVSync " - "(scripts/build_rgbmatrix_nogil.sh)" if releases is False - else "no hardware binding loaded") + # 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. diff --git a/test/test_render_gate.py b/test/test_render_gate.py index 98a65e39..a4481777 100644 --- a/test/test_render_gate.py +++ b/test/test_render_gate.py @@ -311,10 +311,16 @@ class TestVegasWiring: c.display_manager = type("DM", (), {"render_gate": None})() 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) c = self._coordinator() 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 def test_installed_for_the_run_and_removed_after(self, monkeypatch): @@ -338,12 +344,12 @@ class TestVegasWiring: def test_read_from_config(self): from src.vegas_mode.config import VegasModeConfig - on = VegasModeConfig.from_config( - {"display": {"vegas_scroll": {"prefetch_gate": True}}}) - assert on.prefetch_gate is True - assert on.to_dict()["prefetch_gate"] is True + off = VegasModeConfig.from_config( + {"display": {"vegas_scroll": {"prefetch_gate": False}}}) + assert off.prefetch_gate is False + assert off.to_dict()["prefetch_gate"] is False 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): from src.vegas_mode.render_pipeline import RenderPipeline