Skip to content

feat: GPUBuffer.readSync() - synchronous small-buffer readback - #447

Open
mrousavy wants to merge 5 commits into
wcandillon:mainfrom
mrousavy:feat/buffer-read-sync
Open

feat: GPUBuffer.readSync() - synchronous small-buffer readback#447
mrousavy wants to merge 5 commits into
wcandillon:mainfrom
mrousavy:feat/buffer-read-sync

Conversation

@mrousavy

Copy link
Copy Markdown
Contributor

Motivation

There is a GPU-compute readback pattern that mapAsync cannot serve: a render loop (or a react-native-worklets frame callback) that must consume a small compute result in the same frame it was produced. Examples: hand/pose landmarks driving what gets rendered this frame, histogram/range reductions feeding a later pass's uniforms, GPU picking ids, atomic counters. Awaiting mapAsync from such a loop forces at least one frame of staleness, because the promise cannot resolve until the loop yields.

readSync(offset?, size?) blocks the calling thread until all previously submitted work touching the buffer completes, then returns an owned copy of the bytes:

encoder.copyBufferToBuffer(storageResult, 0, staging, 0, 16);
device.queue.submit([encoder.finish()]);
const values = new Float32Array(staging.readSync()); // same tick, no staleness

Implementation

MapAsync with CallbackMode::WaitAnyOnly + a bounded Instance::WaitAny - the library already creates its instance with the TimedWaitAny feature (timedWaitAnyMaxCount: 64), so no instance changes are needed. External instances without the feature (e.g. the Skia-provided instance path) fail the wait and throw instead of hanging; a hung GPU trips the 2s timeout rather than deadlocking JS.

Deliberate limits that keep this a scalpel rather than a footgun:

  • capped at 1 MiB - this is for tiny results; mapAsync remains the right tool for bulk transfers
  • requires MAP_READ usage, whose only valid companion is COPY_DST - so the API pushes callers into the correct copy-to-staging pattern by construction
  • returns an owned copy and unmaps immediately - no mapped-range lifetime to manage

Typed in the existing non-spec declare global block alongside the other RN extensions.

Tests

ReadSync.spec.ts: same-tick compute-pipeline readback, offset/size sub-reads, repeated reads on one buffer, and the size-cap error.

Real-world validation

Running in a VisionCamera + TypeGPU app that executes a monocular depth model (~260 compute dispatches) per camera frame inside a worklet: a 16-byte probe result (hand depth sampled from the disparity buffer) is read back with readSync and drives the same frame's lighting uniforms, at 30fps sustained on an M1 Max. Replacing the previous mapAsync-on-an-interval design removed one frame of control latency and all of the cross-runtime plumbing it required.

🤖 Generated with Claude Code

Adds a non-spec extension for the GPU-compute readback pattern that
mapAsync cannot serve: render/worklet loops that must consume a compute
result in the SAME frame (hand/pose landmarks, histogram ranges, GPU
picking ids, counters). Awaiting mapAsync from such a loop forces at
least one frame of staleness; readSync blocks the calling thread until
previously submitted work touching the buffer completes and returns an
owned copy of the bytes.

Implementation: MapAsync with CallbackMode::WaitAnyOnly + a 2s
Instance::WaitAny - the instance already enables TimedWaitAny. External
instances without the feature (e.g. Skia-provided) fail the wait and
throw instead of hanging. Capped at 1 MiB: this is a primitive for tiny
results, not bulk transfers; the async path remains the right tool
there. Requires MAP_READ usage, so the usage rules push callers to the
correct copy-to-staging pattern by construction.

Typed via the existing non-spec declare-global block, with tests
covering the same-tick compute readback, offset/size, repeated reads,
and the size cap.
@reczkok

reczkok commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Interesting idea, is the 1 frame cadence a big deal? If I understand the use case is synchronizing the landmark info with the depth info? I feel like the readback is skippable or at least the synchronization is

@wcandillon
wcandillon self-requested a review August 21, 2026 11:38

@wcandillon wcandillon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it.

  • Can we find a better name than OwnedArrayBuffer?
  • Also isn't readSync misleading if we are doing a full copy of the buffer? I guess readSync is an ok name, we are just reading, not mapping.
  • Shouldn't the timeout be an input parameter?
  • Can we document it in apps/docs. There are many native WebGPU APIs which we expose in this module already.

@reczkok made an interesting comment, I'm not sure what it means exactly?

@wcandillon

Copy link
Copy Markdown
Owner

also shouldn't the external memory of the buffer be reported to hermes? @mrousavy do you think we should stay within the standard for this one? (like @reczkok suggested).

@mrousavy

Copy link
Copy Markdown
Contributor Author

Interesting idea, is the 1 frame cadence a big deal?

It is, the stream feels much more sluggish if you have 1 frame delay for gesture driven recognition. Needs to be sync unfortunately, but I am wondering if we can pack state into the GPU

@wcandillon

Copy link
Copy Markdown
Owner

but doesn't it make it slower if you block the thread to do a buffer copy?

@mrousavy

mrousavy commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

I like it.

:)

  • Can we find a better name than OwnedArrayBuffer?

Suggestions? It creates memory and will free it - so per definition it owns it. But Nitro ArrayBuffer has more APIs to construct exactly what we need like ArrayBuffer::allocate(..), I just think you don't have those yet in your old Nitro fork/copy here. Will double-check.

  • Also isn't readSync misleading if we are doing a full copy of the buffer? I guess readSync is an ok name, we are just reading, not mapping.

Suggestions? I think readSync is fine, we can also do copySync?

  • Shouldn't the timeout be an input parameter?

Good point, will add it!

  • Can we document it in apps/docs. There are many native WebGPU APIs which we expose in this module already.

Will do!

also shouldn't the external memory of the buffer be reported to hermes? @mrousavy do you think we should stay within the standard for this one? (like @reczkok suggested).

What do you mean by that? An ArrayBuffer (from Nitro) exposes its size to JS, that is already being tracked. It is external memory.

@mrousavy

Copy link
Copy Markdown
Contributor Author

but doesn't it make it slower if you block the thread to do a buffer copy?

Slower than what? What is the alternative?

@wcandillon

Copy link
Copy Markdown
Owner

slower than being a frame delayed?

@mrousavy

Copy link
Copy Markdown
Contributor Author

How come? This runs in the same frame, and "frame delayed" runs in the next frame?

@mrousavy

Copy link
Copy Markdown
Contributor Author

I guess it's an architectural choice - if you want to not do any extra CPU syncs you can run it on the next Frame, but this will cause visual delay for stuff like hand tracking, even if it's very subtle.

My approach is fully sync in the same Frame, so it looks much more snappy but causes an extra CPU sync. My machine has the budget for that so imo that is the better approach for my demo here https://x.com/mrousavy/status/2090591615639269829?s=20

@wcandillon

Copy link
Copy Markdown
Owner

ok sounds good. Let's name it readbackSync to really hammer that this is a GPU -> CPU copy

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.

3 participants