Skip to content

perf(webgpu): run queue work on a per-device thread - #199

Open
triniwiz wants to merge 1 commit into
perf/webgpu-uploads-and-adapterfrom
perf/webgpu-queue-thread
Open

triniwiz wants to merge 1 commit into
perf/webgpu-uploads-and-adapterfrom
perf/webgpu-queue-thread

Conversation

@triniwiz

@triniwiz triniwiz commented Nov 9, 2026

Copy link
Copy Markdown
Member

Stacked on #198.

What

encoder.finish(), queue.submit(), writeBuffer/writeTexture and present now run in order on one thread per device, instead of inside the JS call.

  • finish() returns at once. The command buffer is encoded on the queue thread just before the submit that uses it.
  • Uploads. writeBuffer/writeTexture copy their data on the JS thread and are recorded without waking the queue thread. Recorded work is sent with the next submit, present or drain, so a frame costs a few wakeups rather than one per write.
  • Drawing buffer. The page draws into a per-canvas drawing-buffer texture. Present then runs on the queue thread: it acquires the swapchain image, copies the frame into it and presents. getCurrentTexture never waits on the swapchain.
    • That copy replaces the readback copy present already made, so there is no extra GPU work. toDataURL / drawImage(canvas) read the drawing buffer.
    • Each frame starts transparent black via Texture::discard_contents, which is why wgpu goes to 04e9fef72. It is cleared lazily, as a new swapchain image is.
    • The page runs at most one frame ahead of the queue thread.
  • Errors. wgpu-core keeps error scopes per thread, so the JS thread's scope stack is mirrored and queued work reports into the scope open when it was called. Uncaptured errors are delivered on the JS thread: through its loop on Android and Apple, otherwise on the next WebGPU call. A routing handler is installed at device creation, since wgpu's default panics.
  • Calls that wait for queued work first: configure, unconfigure, resize, detach, readbacks, mapAsync, buffer/texture/device destroy, and the image-copy helpers. getCurrentTexture also waits on paths without a drawing buffer.

This applies on every platform. On Linux, frames still reach GTK through glib::MainContext::invoke.

Measurements

Galaxy S25 Ultra at 60 Hz, three.js r184 WebGPURenderer, mixed-material scene. Same session: this PR vs #198. Times are median render().

draws/frame #198 this PR
~700 60.1 fps, 10.4 ms 60.1 fps, 6.6 ms
~2,600 41.9 fps, 23.1 ms 58.3 fps, 16.7 ms
~5,200 18.4 fps, 53.1 ms 24.8 fps, 39.3 ms

A PixiJS scene with 32,768 sprites rendered 54.9 fps and displayed 55.2 on SurfaceFlinger, so no frames are dropped between rendering and display.

Testing

  • Android: canvas WebGPU spec suite on device, 19/23, the same results as before this change. The 2 webgpu.canvas timing specs and the 2 webgpu.video specs fail on the base too.
  • iOS: simulator target compiles.
  • Windows / Linux: not built locally; this PR's CI is their first build.

finish, submit, writeBuffer/writeTexture and present now run in order on one
thread per device instead of inside the JS call:

- encoder.finish() returns at once; the command buffer is encoded on the queue
  thread just before the submit that uses it.
- writeBuffer/writeTexture copy their data and are recorded without waking the
  thread; recorded work is sent with the next submit, present or drain.
- The page draws into a drawing-buffer texture. Present acquires the swapchain
  image, copies the frame into it and presents on the queue thread, so
  getCurrentTexture never waits on the swapchain. The copy replaces the readback
  copy present already made; readbacks read the drawing buffer. Each frame starts
  cleared through Texture::discard_contents (wgpu bump to 04e9fef72). The page
  runs at most one frame ahead.
- Error scopes are mirrored onto the queue thread, so errors from queued work
  reach the scope open when the call was made. Uncaptured errors go to the JS
  thread: through its loop on Android and Apple, otherwise on its next WebGPU call.
- getCurrentTexture (non-drawing-buffer paths), configure/resize, readbacks,
  mapAsync, destroy and the copy helpers wait for queued work first.
@coderabbitai

coderabbitai Bot commented Nov 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: bb1d2e69-7ea2-4a0f-a1d7-e7524e4392f1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

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