Files
LEDMatrix/docs/OFFSCREEN_RENDERING.md
T
ChuckandClaude Opus 5.5 9964dd2183 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>
2026-09-24 19:57:03 -04:00

20 KiB
Raw Blame History

Offscreen Rendering

Status (2026-09-24): step 1, offscreen rendering, is implemented (DisplayManager.offscreen(), the adapter on the prefetch thread, the plugin lock). Steps 2 and 3 are proposed. When all three land, this file becomes the reference for how plugin content is rendered off the render thread.

First soak of step 1 on hdpi (50 px/s, pwm_bits 7, preview open, 8-minute runs, A/B/B/A):

build late by 1 2 3–5 6+ freezes render-thread fetches
#628 0.53% 82 2 3 2 3 6
step 1 0.63% 78 63 17 2 1 0
step 1 0.42% 77 23 10 0 0 0
#628 0.37% 84 5 3 3 2 14

It does what it was built to: no plugin is fetched on the render thread, and freezes fell from 5 to 1. But frames 2–5 refreshes late rose. The rendering 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 already happens on a background prefetch thread (RenderPipeline.start_prefetch). But any plugin whose content needs the shared display canvas is deferred to the render thread (RenderPipeline.drain_deferred), one plugin every two seconds. The code's own comments put each of those at 40–600 ms, and the render thread presents no frames while one runs.

On hdpi (Pi 4, 512×64) most plugins take that path: geochron, tide-display, news, hockey-scoreboard, ledmatrix-stocks, incoming-packages, clock-simple, countdown, birdnet-go, ledmatrix-music and odds-ticker. They arrive in bursts ("Whole group deferred; strip will extend as it drains") every minute or so, 12 fetches in five minutes. That is the "occasional pause" a viewer sees.

An 8-minute soak (scripts/frame_soak.py --preview) of the #628 build on hdpi:

late by frames
1 refresh 238
2 32
3–5 30
6+ 5
freezes ≥ 250 ms 2 (0.97 s total)

The 3+ rows and the freezes are the pauses. The single-refresh row is a separate problem: the blit is 6 ms of a 10 ms refresh, so there is little slack. It is covered under What this does not fix.

Why a plugin is canvas-bound

The plugin-facing canvas is a set of shared attributes on DisplayManager: image, draw, matrix, and the width/height properties that read from matrix. Three adapter paths (src/vegas_mode/plugin_adapter.py) need them, and each returns None under offscreen_only=True so the plugin is queued for the render thread:

  1. Display capture (_capture_display_content): clear the canvas, call plugin.display(), copy display_manager.image. Used by any plugin without get_vegas_content() or a populated scroll_helper.
  2. Scroll-content generation (_trigger_scroll_content_generation): a ticker plugin whose scroll_helper.cached_image is empty is made to build it by calling display(force_clear=True) or _create_scrolling_display(). Both draw on the canvas.
  3. Narrowed rendering (DisplayManager.render_size): swaps the shared matrix, image and draw for a narrower set so the plugin lays out for render_width_pct. The render thread would see the swap mid-frame.

The render thread keeps the canvas coherent only because nothing else touches it at the same time. A background thread can't use it.

The design: a per-thread render target

capture_mode() is already per-thread (#423 made its state a threading.local, so a background capture no longer suppresses the render loop's pushes). The same move applies to the canvas itself:

with display_manager.offscreen(width=None, height=None) as surface:
    plugin.display(force_clear=True)
    content = surface.image.copy()

For the calling thread only, inside the block:

accessor resolves to
display_manager.image, .draw the surface's own image and draw: a fresh black canvas, fontmode = "1"
display_manager.matrix a logical proxy reporting the surface size, so width/height and plugins that read matrix.width follow it. Hardware calls through it (SetImage, SwapOnVSync, Clear, brightness writes) are inert.
update_display(), clear() canvas-only: the block implies capture mode, which is already per-thread
set_scrolling_state(), set_frame_hold() no-ops, so a plugin's display() cannot re-pace the live scroll. Today it can, when it is captured on the render thread.

Every other thread sees the real canvas, unchanged. The render loop in particular keeps presenting while a plugin draws elsewhere.

Implementation sketch

  • image, draw and matrix become properties over _image, _draw and _matrix, plus a thread-local current surface. The getter returns the surface's value when the calling thread has one, else the shared one; setters mirror that. That costs about 0.1 µs per access, and update_display() reads each a handful of times per frame. Every existing self.image = ... in DisplayManager (clear(), setup, fallback) keeps working and becomes thread-correct for free.
  • render_size() is rebuilt on offscreen(): it creates or narrows the calling thread's surface instead of swapping shared state.
  • offscreen() nests and always restores on exit, including when the plugin raises.
  • VisualDisplayManager (the plugin test harness) gets the same method, for parity.

Adapter changes

  • get_content(offscreen_only=True) stops returning None for the three paths above. Each runs inside display_manager.offscreen(render_width).
  • _capture_display_content and _trigger_scroll_content_generation drop their "copy the shared image, restore it afterwards" bookkeeping, since the shared image is never touched.
  • Take the plugin's lock. PluginManager.get_plugin_lock() keeps update() and display() mutually exclusive in normal rotation, but Vegas never takes it, so today's render-thread captures already race update(). Off the render thread the adapter can afford to wait: blocking acquire with a timeout (proposed 2 s). On timeout it keeps the cached segment and tries again next group.
  • drain_deferred() and the deferred queue are deleted. The only render-thread fetch left is the inline fallback when no prepared group is ready, which in practice is the first extension. Prefetching at start removes that too.

Keeping live content fresh

Offscreen rendering is also what makes fresh sports scores possible. Today a plugin's segment is drawn when its group is prefetched, and the strip carries 7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen 70–100 seconds later. When a plugin reports new data, Vegas only drops its cache (invalidate_pending_updates), so the change is drawn on the plugin's next turn, several minutes later. A segment already in the strip scrolls by with the data it was drawn with.

That was the right trade while every redraw of a canvas-bound plugin stalled the scroll. Off the render thread a redraw costs the scroll nothing, so the strip can afford three things.

1. Refresh at the gate

Before a segment enters the viewport, check whether its plugin has updated since the segment was drawn. If it has, redraw it offscreen and replace it while it is still out of sight. Width changes are fine here, because everything from that segment onward is still invisible.

The gate sits lead pixels ahead of the viewport's right edge: lead = max(one screen, speed × (render time + margin)). The render time is the plugin's own, measured on each render (sports cards take the longest, hundreds of ms up to seconds per the prefetch notes). A plugin whose render does not finish before its segment reaches the viewport keeps the old segment. The scroll never waits for it.

Content is then at most lead / speed seconds old when it appears, a few seconds instead of minutes, without changing how far ahead the rotation fetches.

2. Replace ahead of the screen

When a plugin reports new data (the Vegas update tick already names them), any of its segments that are anywhere ahead of the viewport are redrawn and replaced straight away, not only at the gate. That covers the long stretch of strip between prefetch and the gate.

3. Update on screen

A segment that is already visible is patched in place when the redrawn version has the same geometry: the same total width, and the same width for each card (a sports plugin returns one image per game, joined with intra_plugin_gap). Scoreboard cards keep a fixed layout, so a score change patches in and the digits update as the card scrolls past. The patch is a pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread between frames, so a frame never shows half of a patch.

When the geometry differs (a game added or dropped, a card that grew), the visible part cannot change without a jump. Only the cards not yet on screen are replaced, and only if the geometry up to that point is unchanged. Otherwise the segment keeps its snapshot until it has scrolled off.

Avoiding wasted work

  • Change detection. run_scheduled_updates_with_changes() names a plugin whenever its update() ran, not when its data changed. On hdpi clock-simple and ledmatrix-music are named on every 4-second tick. A redraw whose pixels hash the same as the segment's is discarded without a swap.
  • Redraw on real updates only. Vegas makes no API calls. Each plugin fetches on its own schedule, and a redraw is triggered only when the plugin's update() has run since its segment was drawn. On hdpi live football, baseball and hockey poll every 30 s (live odds every 60 s, everything else hourly), so a live sports card is redrawn once per poll.
  • Floor. A plugin is redrawn at most once per vegas_scroll.refresh_min_interval (proposed 10 s), and never while its previous redraw is still running. The floor never holds back a sports card polling every 30 s. It exists for chatty plugins: clock-simple updates every second and ledmatrix-music polls every 2 s.
  • One worker. Redraws go through the same background worker as prefetch, one plugin at a time at nice 10, under the plugin's lock.

Data freshness is still bounded by each plugin's own fetch interval (how often it polls live scores). Drawing faster cannot beat the data source.

The strip becomes a list of segments

All three need the strip to be replaceable by segment. Today it is one image (ScrollHelper.cached_array, 8,000–20,000 px wide, 1.5–3.8 MB), and append_content() rebuilds the whole thing on the render thread for every appended block. That is also a pause source.

Proposed SegmentStrip, used by Vegas in place of the single image:

  • an ordered list of segments: plugin id, card boundaries, a pixel array, the render time, and the plugin data version it was drawn from, plus its x-offset in the strip;
  • visible(x, width) assembles the viewport by slicing across at most a few segments: the same ~100 KB copy per frame that slicing the single image costs today;
  • append and trim become O(block) list operations, not a copy of the strip;
  • replace swaps one list entry and shifts the offsets of the segments after it (dozens at most). A same-geometry patch copies pixels into the existing array.

Every mutation is prepared off the render thread and applied by the render thread at a frame boundary, so the strip the render loop reads is never half-changed.

Multi-display sync

The follower renders from its own copy of the strip, offset from the leader's scroll position. Today the leader sends that copy whole, and only in start_new_cycle() (send_scroll_image), plus the scroll position every frame. Continuous scroll, the default, extends and trims the strip without starting a new cycle, and nothing sends those changes. From reading the code, the follower therefore probably falls out of step after the first extension already, before any of this design. That is untested; it needs a two-Pi rig.

With a segment strip, keeping the follower identical becomes replaying the leader's operations:

  • Every strip mutation (append, trim, replace, patch) is one operation in strip coordinates. The leader applies it and sends the same operation to the follower over the existing TCP channel. Segments are small: a card is ~29 KB raw and compresses well.
  • Operations on off-screen segments apply on arrival. A patch to a segment that is on either panel carries an apply at scroll position X stamp a couple of hundred milliseconds ahead. Both sides apply it when their scroll position passes X, so both panels change on the same frame, within the existing position-sync jitter.
  • Each operation carries a sequence number. A follower that sees a gap (a reconnect, a dropped message) asks for a full snapshot, which is today's send_scroll_image path.

That also fixes the probable continuous-mode gap as a side effect, since appends and trims become operations too. Until it is in place, fresh-content updates are disabled while sync is active.

Risks, and what was checked

  1. Plugins holding their own reference to the shared draw or image. They would keep drawing into the shared canvas, and routing by thread can't redirect them. A grep of the 49 plugins installed on hdpi found none storing display_manager.draw or .image in an attribute (a pattern search, so indirect aliasing would slip past it). A plugin that did would draw into an image nobody displays, which trims to a blank segment. That is not corruption, and it is no worse than today.
  2. Plugins calling the matrix directly. None in the audit. Inside offscreen() the proxy makes it inert anyway.
  3. Font thread-safety. FontManager shares font objects across plugins. Measured on Pillow 12.3, two threads rendering text take 1.94× as long as one, so text rendering holds the GIL and FreeType is never entered concurrently. Re-check if Pillow changes that.
  4. Plugin thread-safety. display() moves to the prefetch thread. The plugin lock makes it exclusive with update(), which is more protection than it has today. Threads a plugin starts itself are not covered, as today.
  5. The GIL. Moving 40–600 ms of plugin rendering off the render thread removes the pauses, but the work still needs the GIL. Pillow drawing holds 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 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.
    • vegas_scroll.prefetch_gate (src/common/render_gate.py) lets the prefetch thread run Python only while the render thread is blocked in SwapOnVSync, up to just before the refresh the swap returns on, and 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. On by default.

What this does not fix

  • The blit. Copying a 512×64 frame into the matrix (SetImage) is ~6 ms at 8 PWM bits on a Pi 4, leaving ~4 ms of slack per refresh. That is the main source of the single-refresh late frames. Holding frames for two refreshes (≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter step.
  • Live refreshes pushed from update(). Some sports plugins call display() and update_display() from inside update(), which runs on the update worker and can push to the panel mid-Vegas. That is a separate hazard. offscreen() gives a tool for it (run the update worker offscreen while Vegas owns the panel), but it is out of scope here.

Test plan

  • Unit, DisplayManager: one thread inside offscreen() draws while another reads image/draw/matrix/width/height and sees the real canvas. Also: update_display() and set_scrolling_state() are inert inside; render_size() narrows only the calling thread; nesting and exceptions restore state.
  • Unit, adapter: a stub display-capture plugin and a stub scroll-helper plugin both return content with offscreen_only=True, and nothing is queued for the render thread. The plugin lock is taken, and a timeout keeps the cached segment.
  • Emulator integration: a stub canvas-bound plugin whose display() sleeps 300 ms. The Vegas render loop never goes a frame without presenting (frame timing recorder: zero freezes).
  • Unit, SegmentStrip: the viewport assembled across segment boundaries matches slicing one concatenated image, pixel for pixel. Append, trim, replace-ahead and same-geometry patch each leave every other column unchanged. A geometry-changing patch of a visible segment is refused.
  • Freshness: a stub sports plugin whose score changes every second. The score on screen is never older than lead / speed plus the plugin's fetch interval. A visible card's digits change without the frame-timing recorder seeing a late frame. An unchanged redraw is discarded.
  • Hardware: an hdpi soak, A/B against the #628 build, alternating order. Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late rate below 0.66%. Plus, for freshness: log each segment's age when it enters the viewport, and compare the median and max before and after.

Rollout

Three changes, each soaked on hdpi before the next:

  1. Offscreen rendering: offscreen(), the adapter on the prefetch thread, and the plugin lock. Removes the render-thread pauses.
  2. SegmentStrip: Vegas's strip becomes a list of segments. Removes the whole-strip copy on append. No visible behaviour change.
  3. Fresh content: refresh at the gate, replace ahead, patch on screen, with change detection and the rate limit.

display.vegas_scroll.offscreen_prefetch (default true) restores today's deferred path when false, and display.vegas_scroll.live_refresh (default true) turns off step 3. Keep both for one release, then delete the old paths.

Open questions

  1. Keep the kill switch, or ship without one?
  2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed), or wait longer?
  3. refresh_min_interval: 10 s proposed. It only limits chatty plugins; live sports are redrawn once per 30 s poll regardless.
  4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is proposed as part of the segment strip (step 2), with fresh content disabled under sync until it has been verified on real hardware.