Skip to content

fix(render): end every render frame's GPU work so staging buffers are freed - #436

Merged
hyperb1iss merged 2 commits into
mainfrom
nova/gpu-upload-leak
Oct 19, 2026
Merged

hyperb1iss merged 2 commits into
mainfrom
nova/gpu-upload-leak

Conversation

@hyperb1iss

@hyperb1iss hyperb1iss commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

A daemon with no LED devices, running a builtin effect on the GPU compositor, leaked one full canvas of GPU staging memory per frame. In the reproduction, shared memory grew 1 GiB every 30 seconds at 30 fps, about 123 GiB an hour. A brand-new install with nothing plugged in yet is exactly that configuration. The leak has been present since 72d914939 (April), so 0.7.0 ships it. With this PR every render frame hands its queue writes to wgpu before it ends, and memory holds flat.

What was leaking

wgpu-core keeps the staging buffer behind each Queue::write_texture and write_buffer in the queue's pending writes until the next Queue::submit. Polling the device does not release it. create_buffer_init on a buffer without MAP_WRITE stages the same way when it unmaps.

The compositor's bypass path uploads every bypassed CPU frame into the front texture with write_texture, so GPU sampling can read it. With LED zones present, the GPU sampler submits every frame and carries those writes along. With no devices there is nothing to sample. A full-size preview takes the CPU canvas directly, and an unconsumed composition's encoder is dropped without being submitted. Nothing ever submits, so the staging is never freed. The composed path leaks the same way through layer uploads and the uniform ring.

What this changes

The GPU frame backpressure in #432 already ends the render loop's normal path with an empty submission, which flushes every pending write no matter which API staged it. This PR adds that same frame end everywhere a frame can stop after doing GPU work:

  • each early exit from execute_frame that can follow GPU work: scene transaction service, the sleep and idle throttles, and the late sleep switch;
  • the paused-loop path in pipeline_driver.rs, after it services scene transactions;
  • the end of each interactive preview frame.

Frame ends now keep only the newest two marks. The interactive preview compositor never waits on its marks, so without the cap they would accumulate.

An earlier version counted queue writes and submitted only when writes were outstanding. Independent verification failed it, because create_buffer_init staging never passed through the counter: a static base layer under an animated overlay, with nothing else submitting, still leaked. The unconditional empty submit covers every staging path, and it costs about 4 µs a frame.

Verification

  • Added or updated tests
  • Added or updated docs (README, AGENTS.md, relevant spec, or guide)
  • just verify passes locally (Rust fmt + lint + test)
  • just deny passes (required for dependency or license changes)
  • just ui-test and just ui-build pass (required for crates/hypercolor-ui/)
  • just sdk-lint, just sdk-check, and just sdk-build pass (required for sdk/)
  • just python-verify passes (required for python/)
  • just compat-check passes (required for data/drivers/vendors/*.toml)
  • just docs-build passes (required for docs or README changes)
  • cd docs && zola check passes (required for docs link/content changes)
  • Packaging scripts were syntax-checked (required for scripts/ or packaging/)
  • just e2e-build passes with the normal Servo stack (required for daemon/UI/effect integration changes)
  • just e2e-build-cpu passes when validating the CPU smoke fallback
  • just e2e passes against the Servo stack (required for end-to-end behavior changes; starts daemon/browser)
  • Tested on real hardware, simulator, or e2e harness (describe below)

Five regression tests count wgpu's live "(wgpu internal) Staging" allocations, through Device::generate_allocator_report, across 48 frames of changing content. With the frame end emptied, they fail:

Case Staging buffers live (baseline 1)
Bypass, no consumers 49
Bypass, full-size preview 49
Bypass, CPU sampling 49
Two changing composed layers, no consumers 98
Static base under an animated overlay, no consumers 51

With the frame end in place, all five return to baseline. The allocator report exists on Vulkan and DX12, so the WARP lane runs them; on backends without one, the tests skip.

Command Result
cargo test -p hypercolor-daemon --lib -- --test-threads=1 render_thread (HYPERCOLOR_REQUIRE_GPU_TESTS=1) 533 passed
cargo test -p hypercolor-daemon --features persistence-test-hooks --test render_thread_tests -- --test-threads=1 65 passed
cargo clippy -p hypercolor-daemon --all-targets -- -D warnings, --no-default-features --lib, and --no-default-features --features wgpu,persistence-test-hooks --all-targets clean
cargo fmt --all -- --check clean

The last clippy shape also caught a pedantic assigning_clones finding in the Studio layout persistence tests. That file compiles only with persistence-test-hooks, which the CI clippy lanes don't enable, so the finding went unnoticed. It's fixed in a separate commit here.

An isolated preview daemon ran Rainbow with no devices for 5.5 minutes per scenario, in a sandbox with no hardware nodes. Before the fix, shared memory grew about 35 MiB/s with no clients. After it:

Scenario RssShmem start → end
No clients 67.5 → 67.7 MB
Full-resolution preview reader 67.4 → 81.9 MB

With the reader, the one-time step happens when it attaches; memory is flat after that. An independent verifier passed this version after failing the counted one. That issue's other suspected trigger, stalled WebSocket clients, was ruled out with 15 to 20-minute soaks, including four stalled clients at once.

Notes for reviewers

The September 30 report in #399 grew anonymous memory, not shared memory, and dropped suddenly. That pattern matches the Servo script leak and starved garbage collection fixed in 0.7.0 better than it matches this leak. This PR fixes the reproducible leak that issue's investigation turned up. If anonymous growth like the original report ever reappears, it belongs in a new issue.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved GPU frame cleanup during previews and rendering, including when frames are skipped or the render loop is inactive.
    • Limited retained GPU frame tracking to help prevent staging-buffer allocations from accumulating during repeated rendering.
    • Updated frame-budget handling to reflect the work still in flight, supporting more consistent GPU resource use.

hyperb1iss and others added 2 commits October 19, 2026 13:02
A device-less daemon running a native effect on the GPU compositor grew
by one canvas per frame in NVIDIA host mappings: RssShmem rose 1 GiB
every 30 s at 30 fps (about 123 GiB/h) while anonymous memory stayed
flat, and a reading full-resolution preview client did not stop it.

The bypass compose path (sparkleflinger/gpu/compositor/bypass.rs)
uploads every bypassed CPU frame into the front texture with
Queue::write_texture so GPU sampling can read it, which it has done
since 72d9149 (keep bypassed outputs sampleable). wgpu-core 30 keeps
the staging behind each queue write in the queue's pending writes until
the next Queue::submit, and polling the device never releases it.
create_buffer_init on a buffer without MAP_WRITE stages the same way
when it unmaps, which composed layer uploads and uniform ring overflow
use. With LED zones the GPU sampler submits every frame and carries all
of it. With no devices there is no sampling plan, a full-size preview
takes the CPU canvas directly, and an unconsumed composition's staged
encoder is superseded and dropped unsubmitted, so nothing submits and
the staging is never freed.

The GPU frame backpressure already ends the render loop's normal path
with an empty submission, which hands wgpu every queue write the frame
made whichever API staged it. This adds the same frame end on each
early exit that can follow GPU work (scene transaction service, sleep
and idle throttles, the late sleep switch, paused-loop transactions)
and at the end of each interactive preview frame. Frame ends keep only
the newest marks, so the preview compositor, which never waits on them,
does not accumulate them.

Regression tests count wgpu's "(wgpu internal) Staging" allocations
over 48 frames: with end_gpu_frame emptied 49 stay live for each bypass
case, 98 for two changing composed layers, and 51 for a static base
under an animated overlay; with it all return to baseline. On an
isolated preview daemon with Rainbow and no devices, RssShmem holds
flat over 5.5 minutes with no clients (67.7 MB) and with a
full-resolution preview reader (81.9 MB) after warm-up.

Fixes #399

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

The Studio layout persistence tests compile only with the
persistence-test-hooks feature, which the CI clippy lanes do not
enable, so a pedantic assigning_clones finding there went unnoticed
until clippy ran with that feature.

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

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: aa152a8b-67d0-495f-83b1-258ed7db57ed
📥 Commits

Reviewing files that changed from the base of the PR and between a1e6f10 and 19557b4.

📒 Files selected for processing: 8
  • crates/hypercolor-daemon/src/interactive_preview.rs
  • crates/hypercolor-daemon/src/render_thread/frame_executor.rs
  • crates/hypercolor-daemon/src/render_thread/pipeline_driver.rs
  • crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/submission.rs
  • crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs
  • crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs
  • crates/hypercolor-daemon/src/render_thread/sparkleflinger/tests/gpu.rs
  • crates/hypercolor-daemon/tests/studio_layout_persistence_tests.rs

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

Render paths now end GPU frames on additional exits. GPU submission marks are capped, and tests check staging allocations across several render plans. A layout persistence test helper also updates a device ID using clone_into.

Changes: GPU frame lifecycle

Layer / File(s) Summary
End GPU frames across render paths
crates/hypercolor-daemon/src/interactive_preview.rs, crates/hypercolor-daemon/src/render_thread/frame_executor.rs, crates/hypercolor-daemon/src/render_thread/pipeline_driver.rs, crates/hypercolor-daemon/src/render_thread/sparkleflinger/mod.rs
Preview rendering and additional render-thread paths now call end_gpu_frame, including paths that return early. The documentation describes the frame-ending contract.
Bound retained GPU frame marks
crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/submission.rs, crates/hypercolor-daemon/src/render_thread/sparkleflinger/tests/gpu.rs
end_gpu_frame removes the oldest marks when their count exceeds MAX_GPU_FRAMES_IN_FLIGHT. The frame-budget test expects two frames in flight after five frame endings.
Check staging allocations across render plans
crates/hypercolor-daemon/src/render_thread/sparkleflinger/gpu/tests.rs
GPU tests run 48 frames across bypass, preview, sampling, and composition cases, then compare staging allocation counts with their initial values.

Layout persistence test helper

Layer / File(s) Summary
Copy the test device ID
crates/hypercolor-daemon/tests/studio_layout_persistence_tests.rs
The helper uses clone_into to update the device ID. The resulting ID is unchanged.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 19557

No actionable merge-blocking risk is identified; the change is ready for normal merge checks.

Security architecture risk: ⚪ Minimal · up to 19557

The reviewed changes improve resource cleanup without adding access or privileges. No material security risk introduced or worsened by this change was identified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The directly affected security-relevant resource is GPU queue and staging-memory availability for existing daemon rendering and preview workloads. The inspected change adds submission points within those workloads rather than introducing another external input path or privilege transition.

Resilience and Maintainability Implications

  • observed — The two-mark cap is not a universal GPU-work bound. Interactive preview does not explicitly call the frame-budget wait, and the render wait logs timeout or polling failure and continues. These behaviors already existed at the comparison base and are not retained as introduced security concerns.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 73.68% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 8 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the main change: ending GPU work for every render frame to free staging buffers.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@hyperb1iss
hyperb1iss merged commit cf4c299 into main Oct 19, 2026
43 checks passed
@hyperb1iss
hyperb1iss deleted the nova/gpu-upload-leak branch October 19, 2026 21:37
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