Skip to content

perf(render): park the render loop while nothing consumes frames - #445

Open
hyperb1iss wants to merge 9 commits into
mainfrom
nova/render-demand-gate
Open

hyperb1iss wants to merge 9 commits into
mainfrom
nova/render-demand-gate

Conversation

@hyperb1iss

Copy link
Copy Markdown
Owner

On a Mac with no lights and the desktop app closed, the daemon running one HTML effect at 60 fps used 31% of a core rendering frames that nothing received. The idle throttle in frame_executor.rs only engaged when no effect ran (effect_running || has_active_input_demand), so with an effect active the loop rendered at its full tier whether or not any device, preview, display, or capture consumed the result. With this PR the render loop parks when nothing consumes its frames and wakes at full tier the moment something does.

This is not a lowered baseline. The contended resource is CPU and GPU spent on frames no one receives, and no observable cadence changes: whenever anything can observe a frame, the loop renders at the rate it does today.

💡 What counts as demand

Demand is defined once, in crates/hypercolor-daemon/src/output_demand.rs:

  • Devices: any device the registry reports Connected or Active, or that the backend manager routes frames to. Simulators are excluded, so a rig with a real device never parks.
  • Stream receivers: a live receiver on any bus output stream, including frames, spectrum, the canvas and scene-canvas previews, screen canvas and screen zones, the web viewport, zone previews, and per-zone canvases. WebSocket subscriptions, display backdrops, and captures all hold one.
  • Input-event subscribers: WebSocket or diagnostic subscribers to host input events, because the render loop republishes them.
  • Explicit holds: a new OutputDemandHold. Display output takes one while it has targets, which is how a previewed simulator counts.

🛠️ How it parks and wakes

The gate (render_thread/output_gate.rs) runs before every frame, after scene transactions are serviced. With no consumers it parks only once every producer has shown its content, any scene transition has finished, and loading media has a frame, capped at 10 s, so an effect applied while nobody watches is already loaded when a client or capture arrives.

On park it withdraws audio, screen, and interaction capture demand (screen capture stops through its existing demand path), throttles Servo pages, drops live media streams on a teardown thread, discards the frame so the FPS controller sees nothing, and holds render time the way a pause does. Servo needed the explicit throttle: its script thread schedules requestAnimationFrame ticks every 30 ms on its own, so an unobserved WebGL page kept drawing even when the daemon stopped asking for frames. The next render unthrottles as before.

The parked loop sleeps on the bus demand signal, which fires on every subscription, hold, and published event, and also listens for device registry changes, input-demand changes, and scene transactions, with a 1 s safety tick. On resume the frame clock restarts one interval back, so effect time steps by exactly one frame, and media layers and time bindings continue from where they paused.

render_loop.output_demand in /api/v1/system and the diagnose snapshot reports the parked state, the idle reason (no_output_consumers), parked time, consumer counts, and the last wake reason; frame_liveness passes while parked, and hypercolor status prints a parked line.

🧪 Validation

Measured on an isolated Linux daemon (unshare -Urnm, a private /dev with no GPU, USB, HID, or input nodes, private XDG, HOME, and TMPDIR, discovery, audio, capture, and input off), Arc Storm at 60 fps, CPU from /proc/<pid>/stat over 20 s windows. With /dev hidden, Servo WebGL ran on llvmpipe, so absolute numbers are far above the Mac's; the ratio is what carries over.

Case Baseline (ce477c8d8) This PR
No consumers 333% of a core 0.3 to 0.7% (parked)
WebSocket canvas subscription 329%, 55 frames/s 318 to 328%, 55 frames/s
After the WebSocket closes 330% 0.4%, parked again within 3 s
GET /api/v1/canvas/frame 10 to 19 ms 25 to 29 ms, X-Canvas-Fresh: 1
WebSocket subscribe to first frame 11 to 20 ms 4 to 8 ms

A capture skips the first frame after a wake, so it costs about two frames. Device connect, preview subscriptions, display backdrops, and input-event streams are covered by tests asserting a wake under 500 ms; with a 1 s safety tick, passing them proves the wake is event-driven.

Command Result
cargo fmt --all -- --check, cargo check --workspace pass
clippy -D warnings: daemon default, --no-default-features --lib, wgpu,persistence-test-hooks; core default and media-video; CLI and TUI clean
Daemon lib suite 1272 passed
output_demand_tests (new) 16 passed
render_thread_tests, canvas capture, display output, simulator, API, and MCP suites, plus the core suites pass
Python client generation check and pytest pass

An independent reviewer returned FAIL on the first pass; every finding is fixed (transitions and media now settle before parking, telemetry is exact while parked, render time holds while parked), and the re-review passed.

⚠️ Known limits

  • Audio-level events are not demand, because every client holds the default events channel (Home Assistant included), so counting them would mean the loop never parks. A UI page that shows audio meters with no canvas preview mounted will see its meters freeze; the fix is for that page to subscribe to the spectrum topic.
  • Throttling stops requestAnimationFrame but not timers, because Servo's timer minimum is 4 ms. A third-party effect that animates from setTimeout or setInterval keeps running while parked; every bundled effect uses requestAnimationFrame.
  • On macOS the screen parity diagnostic needs active screen capture, so it reports "no active screen capture" while parked. Not tested on a Mac.
  • Extensions that read a lane without subscribing need to take an OutputDemandHold. A simulator preview wakes the loop on display output's next 16 ms tick.
  • A crossfade longer than 10 s pauses partway until a consumer returns, and Servo's pending-render age telemetry absorbs the parked span once after a wake. The worker-side throttle path is not tested against real Servo.

🤖 Generated with Claude Code

hyperb1iss and others added 9 commits October 20, 2026 00:02
Watch channels never announce a new subscriber, so a producer that wants
to idle while nothing consumes its output has no way to learn that a
consumer arrived short of polling receiver counts. The bus now owns an
OutputDemandSignal: every lane subscription, every OutputDemandHold, and
every published event notifies it, and output_receiver_count sums the
receivers on every latest-value output stream, per-zone canvases
included. Holds cover consumers that read published frames by borrowing
a sender instead of holding a receiver.

The device registry gains a change notification that fires whenever its
generation moves, and the backend manager exposes its routed devices
with their backend ids. Together these let the render loop sleep on real
signals and wake the instant a device or consumer appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Servo page keeps working after the render loop stops asking for
frames: its script thread schedules its own animation ticks every 30 ms
while requestAnimationFrame callbacks are live, and its timers keep
firing, so an unobserved WebGL effect keeps the WebGL thread and the GPU
driver busy. EffectRenderer::park lets the render loop quiesce that
work. Servo and web viewport renderers queue a fire-and-forget
SetThrottled command, which throttles the webview (no self-scheduled
ticks, timers clamped to the minimum duration); the next render command
unthrottles it as it always has, so resume needs no counterpart.

EffectRenderer::is_warming reports a renderer that has not yet shown its
current content. Servo tracks a page from load start to its first
completed frame, so the render loop can keep a freshly applied effect
rendering until it is warm instead of parking on a placeholder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With an HTML effect active the loop rendered at its full tier whether or
not anything received the frames: a Mac with no devices and no client
spent about 5 ms of CPU per frame, 31% of a core at 60 fps, on output no
one saw. The idle throttle only engaged when no effect ran. The
contended resource is CPU and GPU time spent on unobserved frames; no
observable cadence changes, because the moment anything can observe a
frame the loop renders at its full tier again. This is not a nerf.

Before each frame the output gate counts consumers: renderable devices
in the registry or routed by the backend manager (simulated displays
only while display output holds demand for a preview), receivers on any
bus output stream (WebSocket streams, display backdrops, and canvas
captures all hold one), stream subscribers to republished host input
events, and explicit demand holds. With none, and once every producer
is warm for the current scene, effects, and canvas, the loop parks: it
withdraws its input capture demand, parks the producers so Servo pages
throttle, and sleeps on the bus demand signal, the registry, input
demand revisions, and the scene transaction queue, with a one second
safety tick. Rigs with a connected device never park.

On wake the frame clock restarts one interval back, so effect time steps
by a single frame across the park with no catch-up burst. A canvas
capture skips the wake frame, whose asynchronous producers still show
output requested before the park, and returns the next one. Status and
diagnose report render_loop.output_demand with the park state, idle
reason, consumer counts, and last wake reason; delivered and actual FPS
read zero while parked and frame_liveness passes instead of failing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A daemon with nothing consuming its frames now parks its render loop,
so the render line reads 0 fps on an idle machine. Without a note that
looks like a stall. The status table adds one line when the loop is
parked saying that nothing consumes frames and that a device, preview,
or capture resumes rendering.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The render pipeline page gains a section on what counts as a consumer,
how a parked loop wakes, how producers settle before parking, and how
effect time and capture freshness carry across a park. The SparkleFlinger
design notes record the gate next to the existing sleep and idle
throttles, the REST status example shows render_loop.output_demand, and
the performance troubleshooting page explains a parked render_loop check
so 0 fps on an idle daemon reads as expected rather than as a stall.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The daemon's status and diagnose contracts gained output_demand, which
reports whether the render loop parked because nothing consumes its
frames. Both fields are optional in the schema, so this client still
parses responses from daemons that predate them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
RenderLoop::park excludes parked time from elapsed the way a pause does
without leaving the Running state, so media layers and time bindings
that read render time continue where they stopped instead of jumping by
the parked span. A pause and a park that overlap are excluded once.
RenderLoop::abandon_frame drops a ticked frame without recording it, so
a park feeds no timing into tier decisions and does not advance the
frame number.

OutputDemandSignal now notifies its waiter list only while a producer
has armed a wait, with full fences on both sides so a waiter armed
before a state change always sees it or is woken. Every event
publication notifies the signal and tokio's notify_waiters takes a lock
on each call, so the armed check keeps the publish path free of that
cost while nothing is parked. MediaProducer::is_live_stream lets the
renderer drop live streams, whose decoder runs on its own worker, while
it renders nothing.

Servo comments now say what throttling does here: it stops the page's
self-scheduled requestAnimationFrame ticks, while timers keep the 4 ms
minimum Hypercolor configures. Coverage lands for every park and pause
interleaving, the abandoned frame, routed device targets, live stream
detection, effect pool park and warming, and Servo warming from load
start to the first completed frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…etry

A crossfade advances only as frames render, so parking mid transition
froze it with the outgoing scene's producers alive; the loop now keeps
rendering until a transition finishes or the settle limit passes, and
until loading media has a frame. Live media streams kept decoding on
their own worker while parked; parking now drops them, on a short-lived
thread because dropping one joins a decoder that waits on its network
source. Parking also calls RenderLoop::park and abandon_frame, so render
time stops and the parked frame feeds no tier statistics.

The parked flag cleared only when a frame was recorded, so a loop woken
into the idle or sleep throttle, or paused while parked, kept reporting
a park. The gate now clears it whenever it decides to render and when
the loop leaves the running state. Status and diagnose measure frame
age on render time, the clock frame timestamps use, rather than daemon
uptime, which also stops pauses inflating frame_age_ms. One-off frame
and spectrum reads borrow the lane instead of subscribing, so they no
longer wake a parked loop, and the backend manager is locked only when
nothing else proves demand.

Tests cover a device routed without a registry entry, input event
streams, pausing while parked, render time across a park, a transition
finishing before the park, the display output hold, and the consumer
counting helpers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bus events make a parked loop re-check its work rather than wake it to
render. Throttled pages stop their self-scheduled requestAnimationFrame
loops but keep their timers, so an effect that animates from timers
keeps running while parked. Parking waits for transitions within the
settle limit and drops live media streams, and render time stops while
parked, so media layers and time bindings continue where they left off.
The architecture page and the SparkleFlinger notes now say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

  • Run on-demand review

This review includes 55 billable files and costs up to $13.75.

Or wait 33 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e84e842c-53f6-4db9-9af4-181606e710d8
📥 Commits

Reviewing files that changed from the base of the PR and between ce477c8 and 89a30b0.

⛔ Files ignored due to path filters: 4
  • python/src/hypercolor/_generated/models/__init__.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/diagnose_render_snapshot.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/render_loop_status.py is excluded by !**/_generated/**
  • python/src/hypercolor/_generated/models/render_output_demand_status.py is excluded by !**/_generated/**
📒 Files selected for processing: 55
  • crates/hypercolor-cli/src/commands/status.rs
  • crates/hypercolor-core/src/bus/demand.rs
  • crates/hypercolor-core/src/bus/mod.rs
  • crates/hypercolor-core/src/device/manager/mapping.rs
  • crates/hypercolor-core/src/device/registry.rs
  • crates/hypercolor-core/src/effect/builtin/web_viewport.rs
  • crates/hypercolor-core/src/effect/media/producer.rs
  • crates/hypercolor-core/src/effect/pool.rs
  • crates/hypercolor-core/src/effect/servo/renderer.rs
  • crates/hypercolor-core/src/effect/servo/renderer/frame_poll.rs
  • crates/hypercolor-core/src/effect/servo/renderer/load.rs
  • crates/hypercolor-core/src/effect/servo/renderer/tests.rs
  • crates/hypercolor-core/src/effect/servo/session.rs
  • crates/hypercolor-core/src/effect/servo/worker.rs
  • crates/hypercolor-core/src/effect/servo/worker/test_support.rs
  • crates/hypercolor-core/src/effect/servo/worker_client.rs
  • crates/hypercolor-core/src/effect/traits.rs
  • crates/hypercolor-core/src/engine/fps.rs
  • crates/hypercolor-core/src/engine/mod.rs
  • crates/hypercolor-core/tests/backend_manager_tests.rs
  • crates/hypercolor-core/tests/bus_tests.rs
  • crates/hypercolor-core/tests/device_tests.rs
  • crates/hypercolor-core/tests/engine_tests.rs
  • crates/hypercolor-core/tests/media_producer_tests.rs
  • crates/hypercolor-daemon/Cargo.toml
  • crates/hypercolor-daemon/src/api/system.rs
  • crates/hypercolor-daemon/src/canvas_capture.rs
  • crates/hypercolor-daemon/src/display_output/mod.rs
  • crates/hypercolor-daemon/src/domain/diagnostics.rs
  • crates/hypercolor-daemon/src/domain/output.rs
  • crates/hypercolor-daemon/src/lib.rs
  • crates/hypercolor-daemon/src/mcp/resources.rs
  • crates/hypercolor-daemon/src/mcp/tools/system.rs
  • crates/hypercolor-daemon/src/output_demand.rs
  • crates/hypercolor-daemon/src/performance.rs
  • crates/hypercolor-daemon/src/preview_runtime.rs
  • crates/hypercolor-daemon/src/render_thread.rs
  • crates/hypercolor-daemon/src/render_thread/frame_executor.rs
  • crates/hypercolor-daemon/src/render_thread/frame_policy.rs
  • crates/hypercolor-daemon/src/render_thread/input_publication.rs
  • crates/hypercolor-daemon/src/render_thread/output_gate.rs
  • crates/hypercolor-daemon/src/render_thread/pipeline_driver.rs
  • crates/hypercolor-daemon/src/render_thread/pipeline_runtime.rs
  • crates/hypercolor-daemon/src/render_thread/render_zones.rs
  • crates/hypercolor-daemon/src/render_thread/render_zones/tests/pipeline_acceptance.rs
  • crates/hypercolor-daemon/src/scene_transactions.rs
  • crates/hypercolor-daemon/tests/output_demand_tests.rs
  • crates/hypercolor-daemon/tests/render_thread_tests.rs
  • crates/hypercolor-tui/tests/rest_client_tests.rs
  • crates/hypercolor-types/src/api/diagnose.rs
  • crates/hypercolor-types/src/api/system.rs
  • docs/content/api/rest.md
  • docs/content/architecture/render-pipeline.md
  • docs/content/troubleshooting/performance.md
  • docs/design/30-sparkleflinger-implementation.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant