From 8cbc056b7e7e98e204407f14c2932ceec49582c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 11:35:52 +0000 Subject: [PATCH 1/5] docs: plan for event-driven async + auto-present refactor Adds the Phase 0 handoff plan: background WaitAny GPU thread to replace the JS-thread polling loop, and a global Choreographer/CADisplayLink-driven auto-present to remove the non-standard context.present(). https://claude.ai/code/session_01TY7QS4Kiqo1oGQoBodCH56 --- docs/refactor-async-present-plan.md | 211 ++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/refactor-async-present-plan.md diff --git a/docs/refactor-async-present-plan.md b/docs/refactor-async-present-plan.md new file mode 100644 index 000000000..6c22cc98d --- /dev/null +++ b/docs/refactor-async-present-plan.md @@ -0,0 +1,211 @@ +# Refactor: event-driven async + auto-present + +Status: **planning / Phase 0 (local spikes)** +Branch: `claude/keen-darwin-xeywa` + +This document is the handoff for moving the async + present refactor forward. Phase 0 +(spikes) needs a real local machine: installed `node_modules`, a Dawn build, and the +iOS/Android toolchains. Everything below the "How to resume locally" section is meant to +be executed on your computer, not in the web container. + +--- + +## Goals (locked) + +- **Async**: replace the JS-thread polling loop with a **background `WaitAny` GPU thread** + (Dawn `TimedWaitAny` is already enabled — `packages/webgpu/cpp/rnwgpu/api/GPU.cpp:17-23`). +- **Present**: **remove `context.present()` entirely** (breaking) in favor of a **global + Choreographer / CADisplayLink-driven auto-present**. +- **Scope**: first-class for **all runtimes** — main JS, the reanimated UI runtime, and + `createWorkletRuntime` dedicated runtimes. + +--- + +## What exists today (the two problems) + +### Async (polling) — `packages/webgpu/cpp/rnwgpu/async/` +- Every async op (`requestAdapter`, `requestDevice`, `mapAsync`, `onSubmittedWorkDone`, + `createRender/ComputePipelineAsync`, `popErrorScope`) registers a Dawn callback with + `CallbackMode::AllowProcessEvents` and calls `AsyncRunner::postTask`. +- `AsyncRunner::requestTick` (`async/AsyncRunner.cpp:89-177`) schedules `tick()` via + `setImmediate` / `setTimeout(4ms)` / `queueMicrotask`; `tick()` calls + `_instance.ProcessEvents()` and **re-schedules itself while any task is "pumping"** + (`AsyncRunner.cpp:189-191`). This is a busy reschedule loop: wasted CPU when idle, added + latency, and `JSIMicrotaskDispatcher`'s `queueMicrotask` dispatch is only thread-safe when + called on the runtime's own thread. + +### Present (manual, non-standard) +`api/GPUCanvasContext.cpp:56-65` → `SurfaceRegistry.h:116-121` → `wgpu::Surface::Present()`. +The user must call `context.present()` after every `queue.submit` (**16 JS/TS call sites**). +No CADisplayLink/Choreographer exists; RN's `requestAnimationFrame` is the only frame driver. +On Apple, present also does a blocking `WaitForCommandsToBeScheduled` on the JS thread. + +--- + +## Target architecture + +Three new pieces: + +### A. `RuntimeScheduler` — thread-safe "post to this runtime's JS thread" +Replaces `AsyncDispatcher` / `JSIMicrotaskDispatcher` (which use non-thread-safe +`queueMicrotask`). +- Interface: `void scheduleOnJS(std::function)`, callable from any thread. +- **Main runtime**: wraps `react::CallInvoker::invokeAsync` (already available — + `apple/WebGPUModule.mm:70`, `android/cpp/cpp-adapter.cpp:25-29`). +- **Worklet runtimes**: wraps the worklet runtime's own thread executor from + `react-native-worklets` 0.8.3 (**see Phase 0 spike #1**). +- Stored per-runtime in a `RuntimeContext` (the "per-JS-thread event loop"), created on first + WebGPU use, torn down via the existing `RuntimeLifecycleMonitor` / `RuntimeAwareCache` + (`cpp/jsi/RuntimeAwareCache.h`). + +### B. `GpuEventLoop` — background `WaitAny` thread (no polling) +One per `wgpu::Instance` (effectively global). +- All async sites switch `CallbackMode::AllowProcessEvents` → **`CallbackMode::WaitAnyOnly`**, + returning a `wgpu::Future`. +- A **small bounded thread pool**; each pending future is waited via + `instance.WaitAny(future, /*timeout*/UINT64_MAX)` on a pool thread → genuinely event-driven, + **zero idle CPU**, resolves the instant GPU work completes. No wake/interrupt problem (each + thread owns one future). **See Phase 0 spike #2.** +- On completion the worker marshals the result and calls the owning runtime's + `RuntimeScheduler.scheduleOnJS` to settle the JS Promise. `AsyncTaskHandle` / `Promise` + settle logic is reused; `AsyncRunner` + its tick loop are deleted. +- Fallback (if concurrent `WaitAny` on one instance is unsafe): single worker thread waiting on + the batched future set with a condition-variable re-arm. + +### C. `FrameDriver` — global vsync source for auto-present +One UI-thread singleton; removes the need for `present()`. +- **iOS**: `CADisplayLink` on the main run loop. **Android**: NDK + `AChoreographer_postFrameCallback` from C++ (API 24+, avoids JNI). **See Phase 0 spike #3.** +- Lifecycle: started when ≥1 surface is configured, stopped at 0. +- **Auto-present semantics** (spec-aligned "update the rendering" after rAF): + 1. `GPUCanvasContext::getCurrentTexture()` marks its `SurfaceInfo` dirty and registers a + present request with `FrameDriver`, tagged with the owning runtime. + 2. Each vsync (UI thread), `FrameDriver` dispatches each dirty context's present onto its + **owning runtime's `RuntimeScheduler`** — so `Surface.Present()` + the Apple Metal + scheduling wait run on the same thread that did `getCurrentTexture` / `submit`, preserving + Dawn surface thread-affinity and guaranteeing present-after-submit ordering (FIFO on that + loop). Clear dirty after present. +- Offscreen path (`SurfaceRegistry` `switchToOffscreen`, `src/Offscreen.ts`) has no surface → + present is a no-op; tests keep reading back the CPU texture. + +--- + +## Phase 0 — Local spikes (DO THESE FIRST, on your machine) + +These de-risk the refactor before any large change. Run from repo root. + +```bash +# 0. install deps (web container can't do this) +yarn install +``` + +### Spike 1 — worklet-runtime scheduler (HIGHEST RISK) +Goal: obtain a **thread-safe** "schedule this lambda on runtime R's thread" for an arbitrary +worklet runtime (UI runtime + a `createWorkletRuntime` runtime) using +`react-native-worklets@0.8.3`. + +```bash +# inspect the worklets native API actually shipped at 0.8.3 +find node_modules/react-native-worklets -name "*.h" | grep -iE "Runtime|Scheduler|Invoker|Queue" +# look for: WorkletRuntime, RuntimeManager / WorkletsModuleProxy, UIScheduler / JSScheduler, +# and any per-runtime executor / async queue we can call from a background C++ thread. +``` +Deliverable: a one-paragraph note on the exact symbol(s) to use (or "not exposed → needs JS +shim / worklets PR"). This determines whether Phase 3 (first-class worklet runtimes) is cheap +or needs a workaround. + +### Spike 2 — concurrent `WaitAny` on one Dawn instance +Goal: confirm multiple threads can each call `instance.WaitAny(singleFuture, UINT64_MAX)` +concurrently on the **same** instance safely. If not, switch `GpuEventLoop` to the +single-worker + condition-variable fallback. +- Search Dawn headers/docs in `externals/dawn` (or built `libs/`) for `WaitAny` threading + guarantees. A tiny throwaway C++ test against the built Dawn is ideal. + +### Spike 3 — Android frame callback +Goal: confirm NDK `AChoreographer_postFrameCallback` is usable at the project `minSdk` +(`packages/webgpu/android/build.gradle`). If `minSdk < 24` for that API, plan the Java +`Choreographer` + JNI bridge instead. + +--- + +## Implementation phases (after Phase 0) + +**Phase 1 — Event-driven async** (no public API change; `present()` untouched) +- Add `RuntimeScheduler` (+ main-runtime CallInvoker impl) and `GpuEventLoop`. +- Switch all 7 async sites to `WaitAnyOnly` + `GpuEventLoop.addFuture(...)`: + `api/GPU.cpp`, `api/GPUAdapter.cpp`, `api/GPUDevice.cpp` (×3), `api/GPUBuffer.cpp`, + `api/GPUQueue.cpp`, `api/GPUShaderModule.cpp`. +- Delete `async/AsyncRunner.*` polling + `async/JSIMicrotaskDispatcher.*`; keep + `AsyncTaskHandle` / `Promise` settle path on the new scheduler. + +**Phase 2 — Auto-present + remove `present()`** +- Add `FrameDriver` (iOS `CADisplayLink`, Android `AChoreographer`); wire + `getCurrentTexture` → register; vsync → dispatch present to owning runtime. +- Remove `GPUCanvasContext::present` (`api/GPUCanvasContext.h:50,58`, `.cpp:56-65`) and + `SurfaceInfo::present` (`SurfaceRegistry.h:116-121`). +- JS: drop `present` from `RNCanvasContext` (`src/Canvas.tsx:22-24`, `src/types.ts`). +- Migrate all 16 example / `useWebGPU` call sites + `README.md` + `packages/webgpu/README.md`. + +**Phase 3 — First-class worklet runtimes** +- Worklet-runtime `RuntimeScheduler` impl (per Spike 1); verify auto-present dispatch on UI + + dedicated runtimes; update `apps/example/src/Reanimated/Reanimated.tsx` (drop `present()`, + keep its own rAF loop). + +**Phase 4 — Validation** +```bash +yarn tsc && yarn lint +yarn workspace react-native-wgpu test # offscreen readback + demo specs +yarn build:ios # or: yarn workspace example ios +yarn build:android # or: yarn workspace example android +``` +Verify: no idle-CPU polling (logging), correct frame pacing, no present-ordering glitches, +Reanimated UI/Dedicated examples render. + +--- + +## 16 `present()` call sites to migrate (Phase 2) + +``` +apps/example/src/StorageBufferVertices/StorageBufferVertices.tsx +apps/example/src/components/useWebGPU.ts +apps/example/src/components/Texture.tsx +apps/example/src/SharedTextureMemory/SharedTextureMemory.tsx +apps/example/src/ThreeJS/Helmet.tsx +apps/example/src/ComputeToys/engine/index.ts +apps/example/src/CanvasAPI/CanvasAPI.tsx +apps/example/src/ThreeJS/PostProcessing.tsx +apps/example/src/ThreeJS/Cube.tsx +apps/example/src/Triangle/HelloTriangle.tsx +apps/example/src/Triangle/HelloTriangleMSAA.tsx +apps/example/src/ThreeJS/InstancedMesh.tsx +apps/example/src/ThreeJS/Retargeting.tsx +apps/example/src/ThreeJS/components/FiberCanvas.tsx +apps/example/src/Reanimated/Reanimated.tsx +apps/example/src/ThreeJS/Backdrop.tsx +``` +Plus `README.md` and `packages/webgpu/README.md`. + +--- + +## Risks / open questions +- **Worklet-runtime scheduler** access in worklets 0.8.3 (Spike 1 — highest risk). +- **Concurrent `WaitAny`** semantics on one Dawn instance (Spike 2; single-worker fallback ready). +- **Present timing**: vsync-dispatched-to-owning-loop must land after submit (FIFO on that loop) + and before the next `getCurrentTexture`. +- **Breaking change**: `present()` removed — type, examples, README updated together. +- **Apple Metal wait** moves into the frame-boundary present task, off the synchronous call path. + +--- + +## How to resume locally + +```bash +git fetch origin claude/keen-darwin-xeywa +git checkout claude/keen-darwin-xeywa +git pull origin claude/keen-darwin-xeywa +# open this file and run Phase 0 spikes, then start Claude Code: +# claude +# suggested kickoff prompt: +# "Read docs/refactor-async-present-plan.md. Run the Phase 0 spikes and report +# findings before implementing. Develop on this branch." +``` From 234384d810f6e4874eefb57a3f8cd81f7bf44ef9 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Tue, 2 Jun 2026 14:17:23 +0200 Subject: [PATCH 2/5] :wrench: --- packages/webgpu/android/CMakeLists.txt | 3 +- .../webgpu/cpp/rnwgpu/RNWebGPUManager.cpp | 4 +- packages/webgpu/cpp/rnwgpu/api/GPU.cpp | 21 ++- packages/webgpu/cpp/rnwgpu/api/GPU.h | 7 +- packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 16 +- packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp | 50 ++--- packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 84 +++++---- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 8 +- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 7 +- .../webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp | 7 +- .../webgpu/cpp/rnwgpu/async/AsyncDispatcher.h | 28 --- .../webgpu/cpp/rnwgpu/async/AsyncRunner.cpp | 172 +++--------------- .../webgpu/cpp/rnwgpu/async/AsyncRunner.h | 48 ++--- .../cpp/rnwgpu/async/AsyncTaskHandle.cpp | 31 +--- .../webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h | 12 +- .../cpp/rnwgpu/async/CallInvokerScheduler.cpp | 21 +++ .../cpp/rnwgpu/async/CallInvokerScheduler.h | 32 ++++ .../webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp | 102 +++++++++++ .../webgpu/cpp/rnwgpu/async/GpuEventLoop.h | 70 +++++++ .../rnwgpu/async/JSIMicrotaskDispatcher.cpp | 23 --- .../cpp/rnwgpu/async/JSIMicrotaskDispatcher.h | 22 --- .../cpp/rnwgpu/async/RuntimeScheduler.h | 31 ++++ 22 files changed, 444 insertions(+), 355 deletions(-) delete mode 100644 packages/webgpu/cpp/rnwgpu/async/AsyncDispatcher.h create mode 100644 packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.cpp create mode 100644 packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.h create mode 100644 packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp create mode 100644 packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.h delete mode 100644 packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.cpp delete mode 100644 packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.h create mode 100644 packages/webgpu/cpp/rnwgpu/async/RuntimeScheduler.h diff --git a/packages/webgpu/android/CMakeLists.txt b/packages/webgpu/android/CMakeLists.txt index fcab0baa1..33704d56c 100644 --- a/packages/webgpu/android/CMakeLists.txt +++ b/packages/webgpu/android/CMakeLists.txt @@ -51,7 +51,8 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/jsi/RuntimeAwareCache.cpp ../cpp/rnwgpu/async/AsyncRunner.cpp ../cpp/rnwgpu/async/AsyncTaskHandle.cpp - ../cpp/rnwgpu/async/JSIMicrotaskDispatcher.cpp + ../cpp/rnwgpu/async/CallInvokerScheduler.cpp + ../cpp/rnwgpu/async/GpuEventLoop.cpp ) target_include_directories( diff --git a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp index 38f675d37..fe42b9020 100644 --- a/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp +++ b/packages/webgpu/cpp/rnwgpu/RNWebGPUManager.cpp @@ -31,8 +31,8 @@ #include "GPURenderPassEncoder.h" #include "GPURenderPipeline.h" #include "GPUSampler.h" -#include "GPUSharedTextureMemory.h" #include "GPUShaderModule.h" +#include "GPUSharedTextureMemory.h" #include "GPUSupportedLimits.h" #include "GPUTexture.h" #include "GPUTextureView.h" @@ -63,7 +63,7 @@ RNWebGPUManager::RNWebGPUManager( // Register main runtime for RuntimeAwareCache BaseRuntimeAwareCache::setMainJsRuntime(_jsRuntime); - auto gpu = std::make_shared(*_jsRuntime); + auto gpu = std::make_shared(*_jsRuntime, _jsCallInvoker); auto rnWebGPU = std::make_shared(gpu, _platformContext, _jsCallInvoker); _gpu = gpu->get(); diff --git a/packages/webgpu/cpp/rnwgpu/api/GPU.cpp b/packages/webgpu/cpp/rnwgpu/api/GPU.cpp index 764a9aa32..7332ac394 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPU.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPU.cpp @@ -9,11 +9,14 @@ #include "Convertors.h" #include "JSIConverter.h" -#include "rnwgpu/async/JSIMicrotaskDispatcher.h" +#include "rnwgpu/async/CallInvokerScheduler.h" +#include "rnwgpu/async/GpuEventLoop.h" namespace rnwgpu { -GPU::GPU(jsi::Runtime &runtime) : NativeObject(CLASS_NAME) { +GPU::GPU(jsi::Runtime &runtime, + std::shared_ptr callInvoker) + : NativeObject(CLASS_NAME) { static const auto kTimedWaitAny = wgpu::InstanceFeatureName::TimedWaitAny; wgpu::InstanceDescriptor instanceDesc{.requiredFeatureCount = 1, .requiredFeatures = &kTimedWaitAny}; @@ -22,8 +25,11 @@ GPU::GPU(jsi::Runtime &runtime) : NativeObject(CLASS_NAME) { instanceDesc.requiredLimits = &limits; _instance = wgpu::CreateInstance(&instanceDesc); - auto dispatcher = std::make_shared(runtime); - _async = async::AsyncRunner::getOrCreate(runtime, _instance, dispatcher); + auto scheduler = + std::make_shared(std::move(callInvoker)); + auto eventLoop = std::make_shared(_instance); + _async = async::AsyncRunner::getOrCreate(runtime, std::move(scheduler), + std::move(eventLoop)); } async::AsyncTaskHandle GPU::requestAdapter( @@ -41,9 +47,10 @@ async::AsyncTaskHandle GPU::requestAdapter( aOptions.backendType = kDefaultBackendType; return _async->postTask( [this, aOptions](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction &reject) { - _instance.RequestAdapter( - &aOptions, wgpu::CallbackMode::AllowProcessEvents, + const async::AsyncTaskHandle::RejectFunction &reject) + -> wgpu::Future { + return _instance.RequestAdapter( + &aOptions, wgpu::CallbackMode::WaitAnyOnly, [asyncRunner = _async, resolve, reject](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, wgpu::StringView message) { diff --git a/packages/webgpu/cpp/rnwgpu/api/GPU.h b/packages/webgpu/cpp/rnwgpu/api/GPU.h index f6bb4ede3..89f46526b 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPU.h @@ -19,6 +19,10 @@ #include +namespace facebook::react { +class CallInvoker; +} // namespace facebook::react + namespace rnwgpu { namespace jsi = facebook::jsi; @@ -27,7 +31,8 @@ class GPU : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPU"; - explicit GPU(jsi::Runtime &runtime); + GPU(jsi::Runtime &runtime, + std::shared_ptr callInvoker); public: std::string getBrand() { return CLASS_NAME; } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index 57f77b625..0a35a39e8 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -92,14 +92,14 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( [this, aDescriptor, descriptor, label = std::move(label), deviceLostBinding, creationRuntime](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction &reject) { + const async::AsyncTaskHandle::RejectFunction &reject) + -> wgpu::Future { (void)descriptor; - _instance.RequestDevice( - &aDescriptor, wgpu::CallbackMode::AllowProcessEvents, + return _instance.RequestDevice( + &aDescriptor, wgpu::CallbackMode::WaitAnyOnly, [asyncRunner = _async, resolve, reject, label, creationRuntime, deviceLostBinding](wgpu::RequestDeviceStatus status, - wgpu::Device device, - wgpu::StringView message) { + wgpu::Device device, wgpu::StringView message) { if (message.length) { fprintf(stderr, "%s", message.data); } @@ -123,14 +123,12 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( case wgpu::LoggingType::Warning: logLevel = "Warning"; Logger::warnToJavascriptConsole( - *creationRuntime, - std::string(msg.data, msg.length)); + *creationRuntime, std::string(msg.data, msg.length)); break; case wgpu::LoggingType::Error: logLevel = "Error"; Logger::errorToJavascriptConsole( - *creationRuntime, - std::string(msg.data, msg.length)); + *creationRuntime, std::string(msg.data, msg.length)); break; case wgpu::LoggingType::Verbose: logLevel = "Verbose"; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp index 4d6012621..a53d97940 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.cpp @@ -54,31 +54,31 @@ async::AsyncTaskHandle GPUBuffer::mapAsync(uint64_t modeIn, return _async->postTask( [bufferHandle, mode, resolvedOffset, rangeSize](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction &reject) { - bufferHandle.MapAsync(mode, resolvedOffset, rangeSize, - wgpu::CallbackMode::AllowProcessEvents, - [resolve, reject](wgpu::MapAsyncStatus status, - wgpu::StringView message) { - switch (status) { - case wgpu::MapAsyncStatus::Success: - resolve(nullptr); - break; - case wgpu::MapAsyncStatus::CallbackCancelled: - reject("MapAsyncStatus::CallbackCancelled"); - break; - case wgpu::MapAsyncStatus::Error: - reject("MapAsyncStatus::Error"); - break; - case wgpu::MapAsyncStatus::Aborted: - reject("MapAsyncStatus::Aborted"); - break; - default: - reject( - "MapAsyncStatus: " + - std::to_string(static_cast(status))); - break; - } - }); + const async::AsyncTaskHandle::RejectFunction &reject) + -> wgpu::Future { + return bufferHandle.MapAsync( + mode, resolvedOffset, rangeSize, wgpu::CallbackMode::WaitAnyOnly, + [resolve, reject](wgpu::MapAsyncStatus status, + wgpu::StringView message) { + switch (status) { + case wgpu::MapAsyncStatus::Success: + resolve(nullptr); + break; + case wgpu::MapAsyncStatus::CallbackCancelled: + reject("MapAsyncStatus::CallbackCancelled"); + break; + case wgpu::MapAsyncStatus::Error: + reject("MapAsyncStatus::Error"); + break; + case wgpu::MapAsyncStatus::Aborted: + reject("MapAsyncStatus::Aborted"); + break; + default: + reject("MapAsyncStatus: " + + std::to_string(static_cast(status))); + break; + } + }); }); } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index f80d7fadf..98d06b75a 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -19,23 +19,33 @@ namespace rnwgpu { void GPUDevice::notifyDeviceLost(wgpu::DeviceLostReason reason, std::string message) { - if (_lostSettled) { - return; - } + std::optional resolveToCall; + std::shared_ptr info; + { + std::lock_guard lock(_lostMutex); + if (_lostSettled) { + return; + } - _lostSettled = true; - _lostInfo = std::make_shared(reason, std::move(message)); + _lostSettled = true; + _lostInfo = std::make_shared(reason, std::move(message)); + info = _lostInfo; + + if (_lostResolve.has_value()) { + resolveToCall = std::move(*_lostResolve); + _lostResolve.reset(); + } - if (_lostResolve.has_value()) { - auto resolve = std::move(*_lostResolve); - _lostResolve.reset(); - resolve([info = _lostInfo](jsi::Runtime &runtime) mutable { + _lostHandle.reset(); + } + + // Settle outside the lock: resolve() only enqueues onto the JS thread. + if (resolveToCall.has_value()) { + (*resolveToCall)([info](jsi::Runtime &runtime) mutable { return JSIConverter>::toJSI(runtime, info); }); } - - _lostHandle.reset(); } void GPUDevice::forceLossForTesting() { @@ -302,10 +312,10 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction - &reject) { + &reject) -> wgpu::Future { (void)descriptor; - device.CreateComputePipelineAsync( - &desc, wgpu::CallbackMode::AllowProcessEvents, + return device.CreateComputePipelineAsync( + &desc, wgpu::CallbackMode::WaitAnyOnly, [pipelineHolder, resolve, reject](wgpu::CreatePipelineAsyncStatus status, wgpu::ComputePipeline pipeline, wgpu::StringView msg) { @@ -316,9 +326,9 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( runtime, pipelineHolder); }); } else { - std::string error = - msg.length ? std::string(msg.data, msg.length) - : "Failed to create compute pipeline"; + std::string error = msg.length + ? std::string(msg.data, msg.length) + : "Failed to create compute pipeline"; reject(std::move(error)); } }); @@ -344,10 +354,10 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction - &reject) { + &reject) -> wgpu::Future { (void)descriptor; - device.CreateRenderPipelineAsync( - &desc, wgpu::CallbackMode::AllowProcessEvents, + return device.CreateRenderPipelineAsync( + &desc, wgpu::CallbackMode::WaitAnyOnly, [pipelineHolder, resolve, reject](wgpu::CreatePipelineAsyncStatus status, wgpu::RenderPipeline pipeline, wgpu::StringView msg) { @@ -358,9 +368,8 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( runtime, pipelineHolder); }); } else { - std::string error = - msg.length ? std::string(msg.data, msg.length) - : "Failed to create render pipeline"; + std::string error = msg.length ? std::string(msg.data, msg.length) + : "Failed to create render pipeline"; reject(std::move(error)); } }); @@ -377,9 +386,9 @@ async::AsyncTaskHandle GPUDevice::popErrorScope() { return _async->postTask([device](const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction - &reject) { - device.PopErrorScope( - wgpu::CallbackMode::AllowProcessEvents, + &reject) -> wgpu::Future { + return device.PopErrorScope( + wgpu::CallbackMode::WaitAnyOnly, [resolve, reject](wgpu::PopErrorScopeStatus status, wgpu::ErrorType type, wgpu::StringView message) { if (status == wgpu::PopErrorScopeStatus::Error || @@ -447,6 +456,11 @@ std::unordered_set GPUDevice::getFeatures() { } async::AsyncTaskHandle GPUDevice::getLost() { + // Held across the whole body: the postTask callback below runs synchronously + // on this (JS) thread and touches the same _lost* fields, so it must not + // re-lock. notifyDeviceLost() takes the same lock from its (possibly worker) + // thread. + std::lock_guard lock(_lostMutex); if (_lostHandle.has_value()) { return *_lostHandle; } @@ -455,29 +469,33 @@ async::AsyncTaskHandle GPUDevice::getLost() { return _async->postTask( [info = _lostInfo]( const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction & /*reject*/) { + const async::AsyncTaskHandle::RejectFunction & /*reject*/) + -> wgpu::Future { resolve([info](jsi::Runtime &runtime) mutable { return JSIConverter>::toJSI( runtime, info); }); - }, - false); + // No Dawn event to wait on: resolved synchronously. + return wgpu::Future{}; + }); } auto handle = _async->postTask( [this](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction & /*reject*/) { + const async::AsyncTaskHandle::RejectFunction & /*reject*/) + -> wgpu::Future { if (_lostSettled && _lostInfo) { resolve([info = _lostInfo](jsi::Runtime &runtime) mutable { return JSIConverter>::toJSI( runtime, info); }); - return; + return wgpu::Future{}; } + // Resolved later from notifyDeviceLost(); no Dawn event to wait on. _lostResolve = resolve; - }, - false); + return wgpu::Future{}; + }); _lostHandle = handle; return handle; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 2ab1ddd14..765a8d794 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -45,10 +45,10 @@ #include "GPURenderPipelineDescriptor.h" #include "GPUSampler.h" #include "GPUSamplerDescriptor.h" -#include "GPUSharedTextureMemory.h" -#include "GPUSharedTextureMemoryDescriptor.h" #include "GPUShaderModule.h" #include "GPUShaderModuleDescriptor.h" +#include "GPUSharedTextureMemory.h" +#include "GPUSharedTextureMemoryDescriptor.h" #include "GPUSupportedLimits.h" #include "GPUTexture.h" #include "GPUTextureDescriptor.h" @@ -251,6 +251,10 @@ class GPUDevice : public NativeObject { wgpu::Device _instance; std::shared_ptr _async; std::string _label; + // Guards the device-lost state below. notifyDeviceLost() may run on a + // GpuEventLoop worker thread (the device-lost callback is Spontaneous), while + // getLost() runs on the JS thread, so these fields need synchronization. + std::mutex _lostMutex; std::optional _lostHandle; std::shared_ptr _lostInfo; bool _lostSettled = false; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index d3c0d65af..9b3365d69 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -82,9 +82,10 @@ async::AsyncTaskHandle GPUQueue::onSubmittedWorkDone() { auto queue = _instance; return _async->postTask( [queue](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction &reject) { - queue.OnSubmittedWorkDone( - wgpu::CallbackMode::AllowProcessEvents, + const async::AsyncTaskHandle::RejectFunction &reject) + -> wgpu::Future { + return queue.OnSubmittedWorkDone( + wgpu::CallbackMode::WaitAnyOnly, [resolve, reject](wgpu::QueueWorkDoneStatus status, wgpu::StringView message) { if (status == wgpu::QueueWorkDoneStatus::Success) { diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp index 113dc407c..5ac6d3634 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.cpp @@ -12,10 +12,11 @@ async::AsyncTaskHandle GPUShaderModule::getCompilationInfo() { return _async->postTask( [module](const async::AsyncTaskHandle::ResolveFunction &resolve, - const async::AsyncTaskHandle::RejectFunction &reject) { + const async::AsyncTaskHandle::RejectFunction &reject) + -> wgpu::Future { auto result = std::make_shared(); - module.GetCompilationInfo( - wgpu::CallbackMode::AllowProcessEvents, + return module.GetCompilationInfo( + wgpu::CallbackMode::WaitAnyOnly, [result, resolve, reject](wgpu::CompilationInfoRequestStatus status, const wgpu::CompilationInfo *compilationInfo) { diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncDispatcher.h b/packages/webgpu/cpp/rnwgpu/async/AsyncDispatcher.h deleted file mode 100644 index 0ec176824..000000000 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncDispatcher.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace rnwgpu::async { - -namespace jsi = facebook::jsi; - -/** - * Abstract dispatcher used by the AsyncRunner to enqueue work back onto the - * JavaScript thread. - */ -class AsyncDispatcher { -public: - using Work = std::function; - - virtual ~AsyncDispatcher() = default; - - /** - * Enqueue a unit of work that will be executed on the JavaScript thread. - */ - virtual void post(Work work) = 0; -}; - -} // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.cpp b/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.cpp index 94bbae230..850e57e8a 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.cpp @@ -1,6 +1,6 @@ #include "AsyncRunner.h" -#include +#include #include #include @@ -16,16 +16,17 @@ struct RuntimeData { constexpr const char *TAG = "AsyncRunner"; } // namespace -AsyncRunner::AsyncRunner(wgpu::Instance instance, - std::shared_ptr dispatcher) - : _instance(std::move(instance)), _dispatcher(std::move(dispatcher)), - _pendingTasks(0), _pumpTasks(0), _tickScheduled(false), - _lastTickTimeNs(0) { - if (!_dispatcher) { - throw std::runtime_error("AsyncRunner requires a valid dispatcher."); +AsyncRunner::AsyncRunner(std::shared_ptr scheduler, + std::shared_ptr eventLoop) + : _scheduler(std::move(scheduler)), _eventLoop(std::move(eventLoop)) { + if (!_scheduler) { + throw std::runtime_error("AsyncRunner requires a valid RuntimeScheduler."); } - Logger::logToConsole("[%s] Created runner (dispatcher=%p)", TAG, - _dispatcher.get()); + if (!_eventLoop) { + throw std::runtime_error("AsyncRunner requires a valid GpuEventLoop."); + } + Logger::logToConsole("[%s] Created runner (scheduler=%p, eventLoop=%p)", TAG, + _scheduler.get(), _eventLoop.get()); } std::shared_ptr AsyncRunner::get(jsi::Runtime &runtime) { @@ -38,173 +39,48 @@ std::shared_ptr AsyncRunner::get(jsi::Runtime &runtime) { } std::shared_ptr -AsyncRunner::getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance, - std::shared_ptr dispatcher) { +AsyncRunner::getOrCreate(jsi::Runtime &runtime, + std::shared_ptr scheduler, + std::shared_ptr eventLoop) { auto existing = get(runtime); if (existing) { return existing; } auto runner = - std::make_shared(std::move(instance), std::move(dispatcher)); + std::make_shared(std::move(scheduler), std::move(eventLoop)); auto data = std::make_shared(); data->runner = runner; runtime.setRuntimeData(runtimeDataUUID(), data); return runner; } -AsyncTaskHandle AsyncRunner::postTask(const TaskCallback &callback, - bool keepPumping) { - auto handle = AsyncTaskHandle::create(shared_from_this(), keepPumping); +AsyncTaskHandle AsyncRunner::postTask(const TaskCallback &callback) { + auto handle = AsyncTaskHandle::create(_scheduler); if (!handle.valid()) { throw std::runtime_error("Failed to create AsyncTaskHandle."); } - _pendingTasks.fetch_add(1, std::memory_order_acq_rel); - if (keepPumping) { - _pumpTasks.fetch_add(1, std::memory_order_acq_rel); - } - requestTick(); - - Logger::logToConsole( - "[%s] postTask (keepPumping=%s, pending=%zu, pumping=%zu)", TAG, - keepPumping ? "true" : "false", - _pendingTasks.load(std::memory_order_acquire), - _pumpTasks.load(std::memory_order_acquire)); - auto resolve = handle.createResolveFunction(); auto reject = handle.createRejectFunction(); + wgpu::Future future{}; try { - callback(resolve, reject); + future = callback(resolve, reject); } catch (const std::exception &exception) { reject(exception.what()); + return handle; } catch (...) { reject("Unknown native error in AsyncRunner::postTask."); + return handle; } + _eventLoop->addFuture(future); return handle; } -void AsyncRunner::requestTick() { - bool expected = false; - if (!_tickScheduled.compare_exchange_strong(expected, true, - std::memory_order_acq_rel)) { - return; - } - - auto self = shared_from_this(); - _dispatcher->post([self](jsi::Runtime &runtime) { - auto tickCallback = jsi::Function::createFromHostFunction( - runtime, jsi::PropNameID::forAscii(runtime, "AsyncRunnerTick"), 0, - [self](jsi::Runtime &runtime, const jsi::Value & /*thisValue*/, - const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value { - self->tick(runtime); - return jsi::Value::undefined(); - }); - -#if defined(ANDROID) || defined(__ANDROID__) - auto global = runtime.global(); - auto setImmediateValue = global.getProperty(runtime, "setImmediate"); - constexpr auto kMinTickInterval = std::chrono::milliseconds(4); - const int64_t nowNs = - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - const int64_t lastNs = - self->_lastTickTimeNs.load(std::memory_order_acquire); - int delayMs = 0; - if (lastNs > 0) { - const int64_t elapsedNs = nowNs - lastNs; - const int64_t minIntervalNs = kMinTickInterval.count() * 1000000LL; - if (elapsedNs < minIntervalNs) { - const int64_t remainingNs = minIntervalNs - elapsedNs; - delayMs = static_cast((remainingNs + 999999) / 1000000); - } - } - - auto tryScheduleTimeout = [&](int ms) { - auto setTimeoutValue = global.getProperty(runtime, "setTimeout"); - if (!setTimeoutValue.isObject()) { - return false; - } - auto setTimeoutObj = setTimeoutValue.asObject(runtime); - if (!setTimeoutObj.isFunction(runtime)) { - return false; - } - Logger::logToConsole("[%s] requestTick scheduled via setTimeout(%d)", TAG, - ms); - auto setTimeoutFn = setTimeoutObj.asFunction(runtime); - jsi::Value callbackArg(runtime, tickCallback); - jsi::Value delayArg(static_cast(ms)); - setTimeoutFn.call(runtime, callbackArg, delayArg); - return true; - }; - - if (delayMs > 0) { - if (tryScheduleTimeout(delayMs)) { - return; - } - // If setTimeout unavailable fall through to immediate scheduling. - } - - if (setImmediateValue.isObject()) { - auto setImmediateObj = setImmediateValue.asObject(runtime); - if (setImmediateObj.isFunction(runtime)) { - Logger::logToConsole("[%s] requestTick scheduled via setImmediate", - TAG); - auto setImmediateFn = setImmediateObj.asFunction(runtime); - jsi::Value callbackArg(runtime, tickCallback); - setImmediateFn.call(runtime, callbackArg); - return; - } - } - - int timeoutDelayMs = delayMs > 0 ? delayMs : 0; - if (tryScheduleTimeout(timeoutDelayMs)) { - return; - } - - Logger::logToConsole("[%s] requestTick scheduled via microtask fallback", - TAG); - runtime.queueMicrotask(std::move(tickCallback)); -#else - Logger::logToConsole("[%s] requestTick scheduled microtask (non-Android)", - TAG); - runtime.queueMicrotask(std::move(tickCallback)); -#endif - }); -} - -void AsyncRunner::tick(jsi::Runtime & /*runtime*/) { - _tickScheduled.store(false, std::memory_order_release); - _instance.ProcessEvents(); - const auto nowNs = std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - _lastTickTimeNs.store(nowNs, std::memory_order_release); - Logger::logToConsole("[%s] tick processed events (pending=%zu, pumping=%zu)", - TAG, _pendingTasks.load(std::memory_order_acquire), - _pumpTasks.load(std::memory_order_acquire)); - if (_pumpTasks.load(std::memory_order_acquire) > 0) { - requestTick(); - } -} - -void AsyncRunner::onTaskSettled(bool keepPumping) { - _pendingTasks.fetch_sub(1, std::memory_order_acq_rel); - if (keepPumping) { - _pumpTasks.fetch_sub(1, std::memory_order_acq_rel); - } - Logger::logToConsole( - "[%s] onTaskSettled (keepPumping=%s, pending=%zu, pumping=%zu)", TAG, - keepPumping ? "true" : "false", - _pendingTasks.load(std::memory_order_acquire), - _pumpTasks.load(std::memory_order_acquire)); -} - -std::shared_ptr AsyncRunner::dispatcher() const { - return _dispatcher; +std::shared_ptr AsyncRunner::scheduler() const { + return _scheduler; } jsi::UUID AsyncRunner::runtimeDataUUID() { diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.h b/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.h index f81101d10..7c01d0f69 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.h +++ b/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.h @@ -1,14 +1,13 @@ #pragma once -#include -#include #include #include #include -#include "AsyncDispatcher.h" #include "AsyncTaskHandle.h" +#include "GpuEventLoop.h" +#include "RuntimeScheduler.h" #include "webgpu/webgpu_cpp.h" @@ -16,38 +15,43 @@ namespace jsi = facebook::jsi; namespace rnwgpu::async { +/** + * Per-runtime coordinator for asynchronous WebGPU operations. + * + * Bundles the runtime's RuntimeScheduler (how to settle Promises back on the + * owning JS thread) with the GpuEventLoop (how to wait on Dawn futures off the + * JS thread). This replaces the previous ProcessEvents polling design: there is + * no tick loop and no idle CPU usage. + * + * A task callback registers a Dawn async op with CallbackMode::WaitAnyOnly and + * returns the resulting wgpu::Future, which is handed to the GpuEventLoop. A + * returned future with id == 0 means "no event to wait on" (deferred/immediate + * resolution, e.g. GPUDevice::getLost). + */ class AsyncRunner : public std::enable_shared_from_this { public: using TaskCallback = - std::function; + std::function; - AsyncRunner(wgpu::Instance instance, - std::shared_ptr dispatcher); + AsyncRunner(std::shared_ptr scheduler, + std::shared_ptr eventLoop); static std::shared_ptr get(jsi::Runtime &runtime); static std::shared_ptr - getOrCreate(jsi::Runtime &runtime, wgpu::Instance instance, - std::shared_ptr dispatcher); + getOrCreate(jsi::Runtime &runtime, + std::shared_ptr scheduler, + std::shared_ptr eventLoop); - AsyncTaskHandle postTask(const TaskCallback &callback, - bool keepPumping = true); + AsyncTaskHandle postTask(const TaskCallback &callback); - void requestTick(); - void tick(jsi::Runtime &runtime); - void onTaskSettled(bool keepPumping); - - std::shared_ptr dispatcher() const; + std::shared_ptr scheduler() const; private: static jsi::UUID runtimeDataUUID(); - wgpu::Instance _instance; - std::shared_ptr _dispatcher; - std::atomic _pendingTasks; - std::atomic _pumpTasks; - std::atomic _tickScheduled; - std::atomic _lastTickTimeNs; + std::shared_ptr _scheduler; + std::shared_ptr _eventLoop; }; } // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp index 6b262005a..c0876c1e3 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.cpp @@ -1,20 +1,19 @@ #include "AsyncTaskHandle.h" +#include #include #include #include "Promise.h" -#include "AsyncRunner.h" - namespace rnwgpu::async { using Action = std::function; struct AsyncTaskHandle::State : public std::enable_shared_from_this { - State(std::shared_ptr runner, bool keepPumping) - : runner(std::move(runner)), keepPumping(keepPumping) {} + explicit State(std::shared_ptr scheduler) + : scheduler(std::move(scheduler)) {} void settle(Action action); void attachPromise(const std::shared_ptr &promise); @@ -26,12 +25,11 @@ struct AsyncTaskHandle::State std::shared_ptr currentPromise(); std::mutex mutex; - std::weak_ptr runner; + std::shared_ptr scheduler; std::shared_ptr promise; std::optional pendingAction; bool settled = false; std::shared_ptr keepAlive; - bool keepPumping; }; // MARK: - State helpers @@ -77,26 +75,18 @@ void AsyncTaskHandle::State::attachPromise( } void AsyncTaskHandle::State::schedule(Action action) { - auto runnerRef = runner.lock(); - if (!runnerRef) { + if (!scheduler) { return; } auto promiseRef = currentPromise(); if (!promiseRef) { - runnerRef->onTaskSettled(keepPumping); - return; - } - - auto dispatcherRef = runnerRef->dispatcher(); - if (!dispatcherRef) { - runnerRef->onTaskSettled(keepPumping); return; } - dispatcherRef->post([self = shared_from_this(), action = std::move(action), - runnerRef, promiseRef](jsi::Runtime &runtime) mutable { - runnerRef->onTaskSettled(self->keepPumping); + scheduler->scheduleOnJS([self = shared_from_this(), + action = std::move(action), + promiseRef](jsi::Runtime &runtime) mutable { action(runtime, *promiseRef); std::lock_guard lock(self->mutex); self->keepAlive.reset(); @@ -149,9 +139,8 @@ AsyncTaskHandle::AsyncTaskHandle(std::shared_ptr state) bool AsyncTaskHandle::valid() const { return _state != nullptr; } AsyncTaskHandle -AsyncTaskHandle::create(const std::shared_ptr &runner, - bool keepPumping) { - auto state = std::make_shared(runner, keepPumping); +AsyncTaskHandle::create(const std::shared_ptr &scheduler) { + auto state = std::make_shared(scheduler); state->keepAlive = state; return AsyncTaskHandle(std::move(state)); } diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h index cb6c7a2a4..2f910fd3f 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h +++ b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h @@ -8,7 +8,7 @@ #include -#include "AsyncDispatcher.h" +#include "RuntimeScheduler.h" namespace rnwgpu { class Promise; @@ -16,11 +16,13 @@ class Promise; namespace rnwgpu::async { -class AsyncRunner; - /** * Represents a pending asynchronous WebGPU operation that can be converted into * a JavaScript Promise. + * + * The native callback (resolve/reject) may be invoked from any thread (e.g. a + * GpuEventLoop worker); the actual Promise settlement is marshalled onto the + * owning runtime's JS thread via a RuntimeScheduler. */ class AsyncTaskHandle { public: @@ -45,8 +47,8 @@ class AsyncTaskHandle { void attachPromise(const std::shared_ptr &promise) const; - static AsyncTaskHandle create(const std::shared_ptr &runner, - bool keepPumping); + static AsyncTaskHandle + create(const std::shared_ptr &scheduler); private: std::shared_ptr _state; diff --git a/packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.cpp b/packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.cpp new file mode 100644 index 000000000..2ef72f407 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.cpp @@ -0,0 +1,21 @@ +#include "CallInvokerScheduler.h" + +#include +#include + +namespace rnwgpu::async { + +CallInvokerScheduler::CallInvokerScheduler( + std::shared_ptr invoker) + : _invoker(std::move(invoker)) {} + +void CallInvokerScheduler::scheduleOnJS( + std::function job) { + if (!_invoker || !job) { + return; + } + _invoker->invokeAsync( + [job = std::move(job)](jsi::Runtime &runtime) { job(runtime); }); +} + +} // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.h b/packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.h new file mode 100644 index 000000000..cbb6a9174 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/async/CallInvokerScheduler.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include +#include + +#include "RuntimeScheduler.h" + +namespace rnwgpu::async { + +namespace jsi = facebook::jsi; +namespace react = facebook::react; + +/** + * RuntimeScheduler for the main React Native JS runtime, backed by + * react::CallInvoker::invokeAsync. invokeAsync is safe to call from any thread + * and delivers the work on the JS thread with the runtime, which is exactly the + * contract RuntimeScheduler requires. + */ +class CallInvokerScheduler final : public RuntimeScheduler { +public: + explicit CallInvokerScheduler(std::shared_ptr invoker); + + void scheduleOnJS(std::function job) override; + +private: + std::shared_ptr _invoker; +}; + +} // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp b/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp new file mode 100644 index 000000000..91119dd96 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp @@ -0,0 +1,102 @@ +#include "GpuEventLoop.h" + +#include +#include +#include +#include + +#include "WGPULogger.h" + +namespace rnwgpu::async { + +namespace { +constexpr const char *TAG = "GpuEventLoop"; + +std::size_t computeMaxWorkers() { + unsigned int hw = std::thread::hardware_concurrency(); + if (hw == 0) { + hw = 4; + } + // A small bounded pool: enough to overlap the handful of async GPU ops that + // are realistically in flight at once, without spawning unbounded threads. + return std::max(2, std::min(8, hw)); +} +} // namespace + +GpuEventLoop::GpuEventLoop(wgpu::Instance instance) + : _state(std::make_shared(std::move(instance))) { + _state->maxWorkers = computeMaxWorkers(); + Logger::logToConsole("[%s] Created (maxWorkers=%zu)", TAG, + _state->maxWorkers); +} + +GpuEventLoop::~GpuEventLoop() { + { + std::lock_guard lock(_state->mutex); + _state->running.store(false, std::memory_order_release); + } + // Wake idle workers so they can observe !running and exit. Workers that are + // currently blocked in WaitAny keep the shared State (and its wgpu::Instance + // ref) alive until their future completes, then exit; we intentionally do not + // join here to avoid blocking teardown on in-flight GPU work. + _state->cv.notify_all(); +} + +void GpuEventLoop::addFuture(wgpu::Future future) { + if (future.id == 0) { + // No event to wait on (deferred/immediate resolution). The callback path + // settles the promise without involving the event loop. + return; + } + + std::lock_guard lock(_state->mutex); + if (!_state->running.load(std::memory_order_acquire)) { + return; + } + + _state->queue.push(future); + + // Grow the pool if every worker is busy and we are still under the cap; + // otherwise wake an idle worker. A freshly spawned worker picks the job up + // via the queue-non-empty predicate, so it needs no separate notify. + if (_state->idleWorkers == 0 && _state->totalWorkers < _state->maxWorkers) { + _state->totalWorkers++; + std::thread(&GpuEventLoop::worker, _state).detach(); + Logger::logToConsole("[%s] grew pool to %zu worker(s)", TAG, + _state->totalWorkers); + } else { + _state->cv.notify_one(); + } +} + +void GpuEventLoop::worker(std::shared_ptr state) { + for (;;) { + wgpu::Future future{}; + { + std::unique_lock lock(state->mutex); + state->idleWorkers++; + state->cv.wait(lock, [&state] { + return !state->running.load(std::memory_order_acquire) || + !state->queue.empty(); + }); + state->idleWorkers--; + + if (state->queue.empty()) { + // Only happens when shutting down. + state->totalWorkers--; + return; + } + + future = state->queue.front(); + state->queue.pop(); + } + + // Single-future wait: always a legal single-source WaitAny. Blocks with no + // CPU cost until the GPU work completes, at which point Dawn invokes the + // future's callback on this thread (it then marshals back to the owning + // runtime via its RuntimeScheduler). + state->instance.WaitAny(future, UINT64_MAX); + } +} + +} // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.h b/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.h new file mode 100644 index 000000000..07e90cd98 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "webgpu/webgpu_cpp.h" + +namespace rnwgpu::async { + +/** + * Background, event-driven driver for Dawn async operations. Replaces the old + * JS-thread ProcessEvents polling loop. + * + * Each pending wgpu::Future (registered with CallbackMode::WaitAnyOnly) is + * handed to addFuture() and waited on by a worker thread via + * `instance.WaitAny(future, UINT64_MAX)`. The wait is genuinely event-driven + * (zero idle CPU) and resolves the instant the GPU work completes, at which + * point Dawn fires the future's callback on the worker thread. That callback is + * responsible for marshalling back to the owning runtime's JS thread (via a + * RuntimeScheduler) to settle the JS Promise. + * + * Threading model (validated in Phase 0, spike 2): each WaitAny call waits on a + * *single* future, which is always a legal single-source wait. Multiple workers + * may block in WaitAny on the same instance concurrently; Dawn's EventManager + * is designed for this. + * + * The worker pool grows lazily up to a small cap as concurrent work demands, + * and threads are reused. Shared state is held behind a shared_ptr so detached + * workers (and the wgpu::Instance ref they need) outlive this object safely. + */ +class GpuEventLoop { +public: + explicit GpuEventLoop(wgpu::Instance instance); + ~GpuEventLoop(); + + GpuEventLoop(const GpuEventLoop &) = delete; + GpuEventLoop &operator=(const GpuEventLoop &) = delete; + + /** + * Wait for `future` to complete on a background thread. A future with id == 0 + * (no event to wait on, e.g. a deferred/immediate resolution) is ignored. + * Thread-safe. + */ + void addFuture(wgpu::Future future); + +private: + struct State { + explicit State(wgpu::Instance instance) : instance(std::move(instance)) {} + + wgpu::Instance instance; + std::mutex mutex; + std::condition_variable cv; + std::queue queue; + std::atomic_bool running{true}; + std::size_t idleWorkers = 0; + std::size_t totalWorkers = 0; + std::size_t maxWorkers = 1; + }; + + static void worker(std::shared_ptr state); + + std::shared_ptr _state; +}; + +} // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.cpp b/packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.cpp deleted file mode 100644 index 6231a833c..000000000 --- a/packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "JSIMicrotaskDispatcher.h" - -#include - -namespace rnwgpu::async { - -JSIMicrotaskDispatcher::JSIMicrotaskDispatcher(jsi::Runtime &runtime) - : _runtime(runtime) {} - -void JSIMicrotaskDispatcher::post(Work work) { - auto microtask = jsi::Function::createFromHostFunction( - _runtime, jsi::PropNameID::forAscii(_runtime, "AsyncMicrotask"), 0, - [work = std::move(work)]( - jsi::Runtime &runtime, const jsi::Value & /*thisValue*/, - const jsi::Value * /*args*/, size_t /*count*/) -> jsi::Value { - work(runtime); - return jsi::Value::undefined(); - }); - - _runtime.queueMicrotask(std::move(microtask)); -} - -} // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.h b/packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.h deleted file mode 100644 index bae208c5d..000000000 --- a/packages/webgpu/cpp/rnwgpu/async/JSIMicrotaskDispatcher.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "AsyncDispatcher.h" - -namespace rnwgpu::async { - -/** - * Dispatcher implementation backed by `jsi::Runtime::queueMicrotask`. - */ -class JSIMicrotaskDispatcher final - : public AsyncDispatcher, - public std::enable_shared_from_this { -public: - explicit JSIMicrotaskDispatcher(jsi::Runtime &runtime); - - void post(Work work) override; - -private: - jsi::Runtime &_runtime; -}; - -} // namespace rnwgpu::async diff --git a/packages/webgpu/cpp/rnwgpu/async/RuntimeScheduler.h b/packages/webgpu/cpp/rnwgpu/async/RuntimeScheduler.h new file mode 100644 index 000000000..926b494c3 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeScheduler.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include + +namespace rnwgpu::async { + +namespace jsi = facebook::jsi; + +/** + * Thread-safe "post this job onto a specific runtime's JS thread". + * + * Replaces the old AsyncDispatcher / JSIMicrotaskDispatcher, whose + * queueMicrotask-based dispatch was only safe to call from the runtime's own + * thread. A RuntimeScheduler can be called from any thread (e.g. the + * GpuEventLoop background threads) and guarantees the job runs on the owning + * runtime's JS thread. + */ +class RuntimeScheduler { +public: + virtual ~RuntimeScheduler() = default; + + /** + * Schedule `job` to run on this runtime's JS thread. Callable from any + * thread. Jobs are delivered in FIFO order relative to one another. + */ + virtual void scheduleOnJS(std::function job) = 0; +}; + +} // namespace rnwgpu::async From e2acc776990a72dbf4ebcb8e21c805f01d2795fc Mon Sep 17 00:00:00 2001 From: William Candillon Date: Tue, 2 Jun 2026 14:38:50 +0200 Subject: [PATCH 3/5] :wrench: --- docs/refactor-async-present-plan.md | 110 +++++++++++++++++- packages/webgpu/android/CMakeLists.txt | 2 +- packages/webgpu/cpp/rnwgpu/api/GPU.cpp | 10 +- packages/webgpu/cpp/rnwgpu/api/GPU.h | 5 +- packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 4 +- packages/webgpu/cpp/rnwgpu/api/GPUAdapter.h | 6 +- packages/webgpu/cpp/rnwgpu/api/GPUBuffer.h | 6 +- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 6 +- packages/webgpu/cpp/rnwgpu/api/GPUQueue.h | 6 +- .../webgpu/cpp/rnwgpu/api/GPUShaderModule.h | 6 +- .../webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h | 2 +- .../{AsyncRunner.cpp => RuntimeContext.cpp} | 37 +++--- .../async/{AsyncRunner.h => RuntimeContext.h} | 10 +- 13 files changed, 158 insertions(+), 52 deletions(-) rename packages/webgpu/cpp/rnwgpu/async/{AsyncRunner.cpp => RuntimeContext.cpp} (56%) rename packages/webgpu/cpp/rnwgpu/async/{AsyncRunner.h => RuntimeContext.h} (83%) diff --git a/docs/refactor-async-present-plan.md b/docs/refactor-async-present-plan.md index 6c22cc98d..e69706534 100644 --- a/docs/refactor-async-present-plan.md +++ b/docs/refactor-async-present-plan.md @@ -1,6 +1,6 @@ # Refactor: event-driven async + auto-present -Status: **planning / Phase 0 (local spikes)** +Status: **Phase 0 complete — all spikes GREEN, ready for Phase 1** Branch: `claude/keen-darwin-xeywa` This document is the handoff for moving the async + present refactor forward. Phase 0 @@ -128,9 +128,77 @@ Goal: confirm NDK `AChoreographer_postFrameCallback` is usable at the project `m --- +## Phase 0 — Findings (completed 2026-06-02, branch `claude/keen-darwin-xeywa`) + +Environment verified: `node_modules` installed, `externals/dawn` present, RN **0.81.4**, +`react-native-worklets` **0.8.3**, Android `minSdk` **26**, NDK 26/27 available. + +### Spike 1 — worklet-runtime scheduler → **GREEN (symbol exists, thread-safe)** +`worklets/WorkletRuntime/WorkletRuntime.h` exposes exactly what we need: +- `WorkletRuntime::schedule(std::function job)` — posts `job` onto the + runtime's own `AsyncQueue` (`WorkletRuntime.cpp:211-227`). It is **callable from any thread** + (the underlying `AsyncQueueImpl` is a mutex+condvar queue; `AsyncQueueUI` forwards to the + `UIScheduler`). The job runs on the runtime's event-loop thread, under `runtimeMutex_`, and + uses `weak_from_this()` so it is a **safe no-op if the runtime was torn down**. This is a + drop-in for `RuntimeScheduler::scheduleOnJS` for worklet runtimes. +- `WorkletRuntime::getWeakRuntimeFromJSIRuntime(jsi::Runtime &rt)` (RN ≥ 0.81, we have 0.81.4) + maps a bare `jsi::Runtime&` → `weak_ptr`, so the per-runtime + `RuntimeContext` can recover the scheduler from any worklet runtime (UI + dedicated + `createWorkletRuntime`) with no JS shim. + +**Caveat (build wiring, not API):** webgpu does **not** currently link worklets natively +(no worklets entry in `packages/webgpu/*.podspec` or `android/CMakeLists.txt`; only JS-level +serialization helpers exist). Phase 3 must add the native dependency: +- iOS: depend on `RNWorklets` pod (it ships public headers under `worklets/`, + `header_dir = "worklets"`). +- Android: import the worklets **prefab** module `worklets` (`prefabPublishing` is on in + `react-native-worklets/android/build.gradle`). +Worklets is already a `peerDependency`, so this adds no new install. Phase 3 stays cheap; no +worklets PR or JS shim needed. + +### Spike 2 — concurrent `WaitAny` on one instance → **GREEN (designed for it)** +Dawn's native `EventManager` (`externals/dawn/src/dawn/native/EventManager.{h,cpp}`) is built +for multi-threaded waits: +- State is `MutexProtected`; `mNextFutureID` is atomic; a code comment + (`EventManager.h:78-82`) explicitly notes "another thread can race to complete the event … + via a WaitAny call". +- Each `WaitAny` call with a non-zero timeout creates a **stack-local `Waiter`** with its **own** + `MutexCondVarProtected` (`EventManager.cpp:338`, `:106`), registers it per-FutureID in + the shared map, then blocks on its own condvar. `SetFutureReady` signals the registered + waiters. → **N threads can each block in `WaitAny` on the same instance concurrently, each + owning its own future.** This is exactly the plan's primary "one future per pool thread" model. + +**Hard constraint discovered (`EventManager.cpp:341-354`):** within a *single* `WaitAny` call +with a non-zero timeout, you may **not** mix events from multiple queues, nor a queue event +together with a non-queue event — it returns `WaitStatus::Error` ("Mixed source waits with +timeouts are not currently supported"). Note `mapAsync`/`onSubmittedWorkDone` are *queue* +events while `requestAdapter`/`requestDevice`/`createPipelineAsync`/`popErrorScope` are +*non-queue* events. +→ **Implication:** adopt the **per-future-per-thread** design (each pool thread waits on exactly +one future) — it is single-source and always legal. The plan's stated fallback ("single worker +waiting on the batched future set") is **not viable** as written, because batching mixed sources +hits this restriction. If a bounded pool is undesirable, the correct fallback is one +worker-thread *per future* (still single-source), not one worker for a batched set. + +### Spike 3 — Android frame callback → **GREEN (no JNI bridge needed)** +In `android/choreographer.h`, `AChoreographer_getInstance()` and +`AChoreographer_postFrameCallback()` are both `__INTRODUCED_IN(24)`; `minSdk` is **26**, so the +pure-NDK path works with no Java `Choreographer`/JNI bridge. +- `postFrameCallback` is `__DEPRECATED_IN(29)` in favor of `postFrameCallback64` (API 29) / + `postVsyncCallback` (API 33). Recommendation: call `postFrameCallback64` when + `android_get_device_api_level() >= 29`, else `postFrameCallback` (works on 26-28). Both are + acceptable; the 64-bit variant just avoids the deprecation warning and 32-bit time wrap. +- `AChoreographer_getInstance()` must be called on a thread with a `Looper` (the main/UI + thread) — `FrameDriver` already lives on the UI thread, so this is satisfied. + +### Net go/no-go +All three risks clear. Proceed to Phase 1. Two plan amendments: (1) Phase 3 must add the +worklets native build dependency (podspec + prefab); (2) `GpuEventLoop` must use +per-future-per-thread waits (drop the batched-future fallback). + ## Implementation phases (after Phase 0) -**Phase 1 — Event-driven async** (no public API change; `present()` untouched) +**Phase 1 — Event-driven async** (no public API change; `present()` untouched) — **DONE** - Add `RuntimeScheduler` (+ main-runtime CallInvoker impl) and `GpuEventLoop`. - Switch all 7 async sites to `WaitAnyOnly` + `GpuEventLoop.addFuture(...)`: `api/GPU.cpp`, `api/GPUAdapter.cpp`, `api/GPUDevice.cpp` (×3), `api/GPUBuffer.cpp`, @@ -138,6 +206,44 @@ Goal: confirm NDK `AChoreographer_postFrameCallback` is usable at the project `m - Delete `async/AsyncRunner.*` polling + `async/JSIMicrotaskDispatcher.*`; keep `AsyncTaskHandle` / `Promise` settle path on the new scheduler. +### Phase 1 — what shipped (branch `claude/keen-darwin-xeywa`) +New files (`cpp/rnwgpu/async/`): +- `RuntimeScheduler.h` — interface `scheduleOnJS(std::function)`, + callable from any thread. +- `CallInvokerScheduler.{h,cpp}` — main-runtime impl wrapping + `react::CallInvoker::invokeAsync(CallFunc&&)` (RN 0.81 delivers the job on the JS thread + with the runtime). +- `GpuEventLoop.{h,cpp}` — background `WaitAny` driver. Lazily-grown bounded worker pool + (cap = `clamp(hardware_concurrency, 2, 8)`); each worker does a single-future + `instance.WaitAny(future, UINT64_MAX)` (always a legal single-source wait, per Phase 0 + spike 2). Shared state held behind a `shared_ptr` so detached workers (and the + `wgpu::Instance` ref they need) outlive the object safely; teardown sets `running=false` + and notifies idle workers without joining in-flight GPU waits. + +Deviations from the original plan (intentional): +1. **`AsyncRunner` was replaced by `RuntimeContext`** (`async/RuntimeContext.{h,cpp}`), the + per-runtime coordinator the plan's Target-architecture §A already named. It bundles + `{RuntimeScheduler, GpuEventLoop}` and exposes `postTask`; all polling internals + (`tick`/`requestTick`/`ProcessEvents`/pump counters) are gone. `AsyncTaskHandle` depends + only on `RuntimeScheduler`. The old `AsyncRunner` name/files no longer exist anywhere + (the 6 `api/*` classes now hold `std::shared_ptr _async`); the dead + `GPU::getAsyncRunner()` accessor was deleted. +2. **`postTask`'s callback now returns a `wgpu::Future`** (the value returned by the Dawn + `WaitAnyOnly` call), which `AsyncRunner` hands to `GpuEventLoop.addFuture`. A returned + future with `id == 0` means "no event to wait on" and is ignored — used by + `GPUDevice::getLost` (resolved synchronously or later via `notifyDeviceLost`). This + replaced the old `keepPumping` bool argument, which is gone. + +`GPU`'s constructor now takes the `CallInvoker` (threaded through from `RNWebGPUManager`, +which already held it) to build the `CallInvokerScheduler`. `AsyncDispatcher.h` and +`JSIMicrotaskDispatcher.{h,cpp}` deleted; `android/CMakeLists.txt` updated (iOS podspec +globs `cpp/**` so it needs no change). + +Validation run locally: all changed + new TUs syntax-check under the Android NDK toolchain; +the full `react-native-wgpu` native lib **compiles and links** for `arm64-v8a` (ninja); +`cpplint` clean (project filters); `clang-format` (pinned 15.0.0) applied; `yarn tsc` passes +(no TS changed). On-device runtime behaviour (frame pacing, zero idle CPU) is Phase 4. + **Phase 2 — Auto-present + remove `present()`** - Add `FrameDriver` (iOS `CADisplayLink`, Android `AChoreographer`); wire `getCurrentTexture` → register; vsync → dispatch present to owning runtime. diff --git a/packages/webgpu/android/CMakeLists.txt b/packages/webgpu/android/CMakeLists.txt index 33704d56c..bb7c2bacb 100644 --- a/packages/webgpu/android/CMakeLists.txt +++ b/packages/webgpu/android/CMakeLists.txt @@ -49,7 +49,7 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/jsi/Promise.cpp ../cpp/jsi/RuntimeLifecycleMonitor.cpp ../cpp/jsi/RuntimeAwareCache.cpp - ../cpp/rnwgpu/async/AsyncRunner.cpp + ../cpp/rnwgpu/async/RuntimeContext.cpp ../cpp/rnwgpu/async/AsyncTaskHandle.cpp ../cpp/rnwgpu/async/CallInvokerScheduler.cpp ../cpp/rnwgpu/async/GpuEventLoop.cpp diff --git a/packages/webgpu/cpp/rnwgpu/api/GPU.cpp b/packages/webgpu/cpp/rnwgpu/api/GPU.cpp index 7332ac394..fcffd8a68 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPU.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPU.cpp @@ -28,8 +28,8 @@ GPU::GPU(jsi::Runtime &runtime, auto scheduler = std::make_shared(std::move(callInvoker)); auto eventLoop = std::make_shared(_instance); - _async = async::AsyncRunner::getOrCreate(runtime, std::move(scheduler), - std::move(eventLoop)); + _async = async::RuntimeContext::getOrCreate(runtime, std::move(scheduler), + std::move(eventLoop)); } async::AsyncTaskHandle GPU::requestAdapter( @@ -51,7 +51,7 @@ async::AsyncTaskHandle GPU::requestAdapter( -> wgpu::Future { return _instance.RequestAdapter( &aOptions, wgpu::CallbackMode::WaitAnyOnly, - [asyncRunner = _async, resolve, + [context = _async, resolve, reject](wgpu::RequestAdapterStatus status, wgpu::Adapter adapter, wgpu::StringView message) { if (message.length) { @@ -59,8 +59,8 @@ async::AsyncTaskHandle GPU::requestAdapter( } if (status == wgpu::RequestAdapterStatus::Success && adapter) { - auto adapterHost = std::make_shared( - std::move(adapter), asyncRunner); + auto adapterHost = + std::make_shared(std::move(adapter), context); auto result = std::variant>( adapterHost); diff --git a/packages/webgpu/cpp/rnwgpu/api/GPU.h b/packages/webgpu/cpp/rnwgpu/api/GPU.h index 89f46526b..e7dc15caf 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPU.h @@ -9,8 +9,8 @@ #include "NativeObject.h" -#include "rnwgpu/async/AsyncRunner.h" #include "rnwgpu/async/AsyncTaskHandle.h" +#include "rnwgpu/async/RuntimeContext.h" #include "webgpu/webgpu_cpp.h" @@ -53,11 +53,10 @@ class GPU : public NativeObject { } inline const wgpu::Instance get() { return _instance; } - inline std::shared_ptr getAsyncRunner() { return _async; } private: wgpu::Instance _instance; - std::shared_ptr _async; + std::shared_ptr _async; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index 0a35a39e8..b34b12d96 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -97,7 +97,7 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( (void)descriptor; return _instance.RequestDevice( &aDescriptor, wgpu::CallbackMode::WaitAnyOnly, - [asyncRunner = _async, resolve, reject, label, creationRuntime, + [context = _async, resolve, reject, label, creationRuntime, deviceLostBinding](wgpu::RequestDeviceStatus status, wgpu::Device device, wgpu::StringView message) { if (message.length) { @@ -146,7 +146,7 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( creationRuntime); auto deviceHost = std::make_shared(std::move(device), - asyncRunner, label); + context, label); *deviceLostBinding = deviceHost; // Register the device in the static registry so the uncaptured diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.h b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.h index 66acdc2f7..7f399f0a7 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.h @@ -8,8 +8,8 @@ #include "NativeObject.h" -#include "rnwgpu/async/AsyncRunner.h" #include "rnwgpu/async/AsyncTaskHandle.h" +#include "rnwgpu/async/RuntimeContext.h" #include "webgpu/webgpu_cpp.h" @@ -27,7 +27,7 @@ class GPUAdapter : public NativeObject { static constexpr const char *CLASS_NAME = "GPUAdapter"; explicit GPUAdapter(wgpu::Adapter instance, - std::shared_ptr async) + std::shared_ptr async) : NativeObject(CLASS_NAME), _instance(instance), _async(async) {} public: @@ -53,7 +53,7 @@ class GPUAdapter : public NativeObject { private: wgpu::Adapter _instance; - std::shared_ptr _async; + std::shared_ptr _async; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.h b/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.h index edfc8e41b..036b5af4b 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUBuffer.h @@ -9,8 +9,8 @@ #include "NativeObject.h" -#include "rnwgpu/async/AsyncRunner.h" #include "rnwgpu/async/AsyncTaskHandle.h" +#include "rnwgpu/async/RuntimeContext.h" #include "webgpu/webgpu_cpp.h" @@ -25,7 +25,7 @@ class GPUBuffer : public NativeObject { static constexpr const char *CLASS_NAME = "GPUBuffer"; explicit GPUBuffer(wgpu::Buffer instance, - std::shared_ptr async, + std::shared_ptr async, std::string label) : NativeObject(CLASS_NAME), _instance(instance), _async(async), _label(label) {} @@ -71,7 +71,7 @@ class GPUBuffer : public NativeObject { private: wgpu::Buffer _instance; - std::shared_ptr _async; + std::shared_ptr _async; std::string _label; struct Mapping { uint64_t start; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 765a8d794..facbed161 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -15,8 +15,8 @@ #include "NativeObject.h" -#include "rnwgpu/async/AsyncRunner.h" #include "rnwgpu/async/AsyncTaskHandle.h" +#include "rnwgpu/async/RuntimeContext.h" #include "webgpu/webgpu_cpp.h" @@ -63,7 +63,7 @@ class GPUDevice : public NativeObject { static constexpr const char *CLASS_NAME = "GPUDevice"; explicit GPUDevice(wgpu::Device instance, - std::shared_ptr async, + std::shared_ptr async, std::string label) : NativeObject(CLASS_NAME), _instance(instance), _async(async), _label(label) {} @@ -249,7 +249,7 @@ class GPUDevice : public NativeObject { friend class GPUAdapter; wgpu::Device _instance; - std::shared_ptr _async; + std::shared_ptr _async; std::string _label; // Guards the device-lost state below. notifyDeviceLost() may run on a // GpuEventLoop worker thread (the device-lost callback is Spontaneous), while diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h index be824e781..f322392b7 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h @@ -8,8 +8,8 @@ #include "NativeObject.h" -#include "rnwgpu/async/AsyncRunner.h" #include "rnwgpu/async/AsyncTaskHandle.h" +#include "rnwgpu/async/RuntimeContext.h" #include "webgpu/webgpu_cpp.h" @@ -28,7 +28,7 @@ class GPUQueue : public NativeObject { static constexpr const char *CLASS_NAME = "GPUQueue"; explicit GPUQueue(wgpu::Queue instance, - std::shared_ptr async, + std::shared_ptr async, std::string label) : NativeObject(CLASS_NAME), _instance(instance), _async(async), _label(label) {} @@ -74,7 +74,7 @@ class GPUQueue : public NativeObject { private: wgpu::Queue _instance; - std::shared_ptr _async; + std::shared_ptr _async; std::string _label; }; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.h b/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.h index ab8561090..0e59edf01 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUShaderModule.h @@ -7,8 +7,8 @@ #include "NativeObject.h" -#include "rnwgpu/async/AsyncRunner.h" #include "rnwgpu/async/AsyncTaskHandle.h" +#include "rnwgpu/async/RuntimeContext.h" #include "webgpu/webgpu_cpp.h" @@ -23,7 +23,7 @@ class GPUShaderModule : public NativeObject { static constexpr const char *CLASS_NAME = "GPUShaderModule"; explicit GPUShaderModule(wgpu::ShaderModule instance, - std::shared_ptr async, + std::shared_ptr async, std::string label) : NativeObject(CLASS_NAME), _instance(instance), _async(async), _label(label) {} @@ -59,7 +59,7 @@ class GPUShaderModule : public NativeObject { private: wgpu::ShaderModule _instance; - std::shared_ptr _async; + std::shared_ptr _async; std::string _label; }; diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h index 2f910fd3f..e3a224563 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h +++ b/packages/webgpu/cpp/rnwgpu/async/AsyncTaskHandle.h @@ -36,7 +36,7 @@ class AsyncTaskHandle { AsyncTaskHandle(); /** - * Internal constructor used by AsyncRunner. + * Internal constructor used by RuntimeContext. */ explicit AsyncTaskHandle(std::shared_ptr state); diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.cpp b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp similarity index 56% rename from packages/webgpu/cpp/rnwgpu/async/AsyncRunner.cpp rename to packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp index 850e57e8a..f297ae6b0 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.cpp @@ -1,4 +1,4 @@ -#include "AsyncRunner.h" +#include "RuntimeContext.h" #include #include @@ -11,25 +11,26 @@ namespace rnwgpu::async { namespace { struct RuntimeData { - std::shared_ptr runner; + std::shared_ptr runner; }; -constexpr const char *TAG = "AsyncRunner"; +constexpr const char *TAG = "RuntimeContext"; } // namespace -AsyncRunner::AsyncRunner(std::shared_ptr scheduler, - std::shared_ptr eventLoop) +RuntimeContext::RuntimeContext(std::shared_ptr scheduler, + std::shared_ptr eventLoop) : _scheduler(std::move(scheduler)), _eventLoop(std::move(eventLoop)) { if (!_scheduler) { - throw std::runtime_error("AsyncRunner requires a valid RuntimeScheduler."); + throw std::runtime_error( + "RuntimeContext requires a valid RuntimeScheduler."); } if (!_eventLoop) { - throw std::runtime_error("AsyncRunner requires a valid GpuEventLoop."); + throw std::runtime_error("RuntimeContext requires a valid GpuEventLoop."); } Logger::logToConsole("[%s] Created runner (scheduler=%p, eventLoop=%p)", TAG, _scheduler.get(), _eventLoop.get()); } -std::shared_ptr AsyncRunner::get(jsi::Runtime &runtime) { +std::shared_ptr RuntimeContext::get(jsi::Runtime &runtime) { auto data = runtime.getRuntimeData(runtimeDataUUID()); if (!data) { return nullptr; @@ -38,24 +39,24 @@ std::shared_ptr AsyncRunner::get(jsi::Runtime &runtime) { return stored->runner; } -std::shared_ptr -AsyncRunner::getOrCreate(jsi::Runtime &runtime, - std::shared_ptr scheduler, - std::shared_ptr eventLoop) { +std::shared_ptr +RuntimeContext::getOrCreate(jsi::Runtime &runtime, + std::shared_ptr scheduler, + std::shared_ptr eventLoop) { auto existing = get(runtime); if (existing) { return existing; } - auto runner = - std::make_shared(std::move(scheduler), std::move(eventLoop)); + auto runner = std::make_shared(std::move(scheduler), + std::move(eventLoop)); auto data = std::make_shared(); data->runner = runner; runtime.setRuntimeData(runtimeDataUUID(), data); return runner; } -AsyncTaskHandle AsyncRunner::postTask(const TaskCallback &callback) { +AsyncTaskHandle RuntimeContext::postTask(const TaskCallback &callback) { auto handle = AsyncTaskHandle::create(_scheduler); if (!handle.valid()) { throw std::runtime_error("Failed to create AsyncTaskHandle."); @@ -71,7 +72,7 @@ AsyncTaskHandle AsyncRunner::postTask(const TaskCallback &callback) { reject(exception.what()); return handle; } catch (...) { - reject("Unknown native error in AsyncRunner::postTask."); + reject("Unknown native error in RuntimeContext::postTask."); return handle; } @@ -79,11 +80,11 @@ AsyncTaskHandle AsyncRunner::postTask(const TaskCallback &callback) { return handle; } -std::shared_ptr AsyncRunner::scheduler() const { +std::shared_ptr RuntimeContext::scheduler() const { return _scheduler; } -jsi::UUID AsyncRunner::runtimeDataUUID() { +jsi::UUID RuntimeContext::runtimeDataUUID() { static const auto uuid = jsi::UUID(); return uuid; } diff --git a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.h b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h similarity index 83% rename from packages/webgpu/cpp/rnwgpu/async/AsyncRunner.h rename to packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h index 7c01d0f69..a7a5d46f4 100644 --- a/packages/webgpu/cpp/rnwgpu/async/AsyncRunner.h +++ b/packages/webgpu/cpp/rnwgpu/async/RuntimeContext.h @@ -28,17 +28,17 @@ namespace rnwgpu::async { * returned future with id == 0 means "no event to wait on" (deferred/immediate * resolution, e.g. GPUDevice::getLost). */ -class AsyncRunner : public std::enable_shared_from_this { +class RuntimeContext : public std::enable_shared_from_this { public: using TaskCallback = std::function; - AsyncRunner(std::shared_ptr scheduler, - std::shared_ptr eventLoop); + RuntimeContext(std::shared_ptr scheduler, + std::shared_ptr eventLoop); - static std::shared_ptr get(jsi::Runtime &runtime); - static std::shared_ptr + static std::shared_ptr get(jsi::Runtime &runtime); + static std::shared_ptr getOrCreate(jsi::Runtime &runtime, std::shared_ptr scheduler, std::shared_ptr eventLoop); From e71fcffda9ec2b95cad99d8f866615cf33c4a386 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Tue, 2 Jun 2026 15:41:00 +0200 Subject: [PATCH 4/5] :wrench: --- packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp | 13 ++++++++++++- packages/webgpu/package.json | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp b/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp index 91119dd96..2bd643b39 100644 --- a/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp +++ b/packages/webgpu/cpp/rnwgpu/async/GpuEventLoop.cpp @@ -95,7 +95,18 @@ void GpuEventLoop::worker(std::shared_ptr state) { // CPU cost until the GPU work completes, at which point Dawn invokes the // future's callback on this thread (it then marshals back to the owning // runtime via its RuntimeScheduler). - state->instance.WaitAny(future, UINT64_MAX); + auto status = state->instance.WaitAny(future, UINT64_MAX); + if (status != wgpu::WaitStatus::Success) { + // With an infinite timeout on a single future this is not expected. If it + // happens, Dawn did not invoke the future's callback, so the associated + // JS Promise will never settle. Log it so the otherwise-silent hang is at + // least observable. + Logger::logToConsole( + "[%s] WaitAny returned non-success status %u for future %llu; its " + "Promise will not settle.", + TAG, static_cast(status), + static_cast(future.id)); + } } } diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json index 961528fb3..07a48b53e 100644 --- a/packages/webgpu/package.json +++ b/packages/webgpu/package.json @@ -1,6 +1,6 @@ { "name": "react-native-wgpu", - "version": "0.5.12", + "version": "0.5.13", "description": "React Native WebGPU", "main": "lib/commonjs/index", "module": "lib/module/index", From 162c39c25d1ecb4fc836e7af706feaa6b0773316 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Tue, 2 Jun 2026 15:44:36 +0200 Subject: [PATCH 5/5] Delete docs/refactor-async-present-plan.md --- docs/refactor-async-present-plan.md | 317 ---------------------------- 1 file changed, 317 deletions(-) delete mode 100644 docs/refactor-async-present-plan.md diff --git a/docs/refactor-async-present-plan.md b/docs/refactor-async-present-plan.md deleted file mode 100644 index e69706534..000000000 --- a/docs/refactor-async-present-plan.md +++ /dev/null @@ -1,317 +0,0 @@ -# Refactor: event-driven async + auto-present - -Status: **Phase 0 complete — all spikes GREEN, ready for Phase 1** -Branch: `claude/keen-darwin-xeywa` - -This document is the handoff for moving the async + present refactor forward. Phase 0 -(spikes) needs a real local machine: installed `node_modules`, a Dawn build, and the -iOS/Android toolchains. Everything below the "How to resume locally" section is meant to -be executed on your computer, not in the web container. - ---- - -## Goals (locked) - -- **Async**: replace the JS-thread polling loop with a **background `WaitAny` GPU thread** - (Dawn `TimedWaitAny` is already enabled — `packages/webgpu/cpp/rnwgpu/api/GPU.cpp:17-23`). -- **Present**: **remove `context.present()` entirely** (breaking) in favor of a **global - Choreographer / CADisplayLink-driven auto-present**. -- **Scope**: first-class for **all runtimes** — main JS, the reanimated UI runtime, and - `createWorkletRuntime` dedicated runtimes. - ---- - -## What exists today (the two problems) - -### Async (polling) — `packages/webgpu/cpp/rnwgpu/async/` -- Every async op (`requestAdapter`, `requestDevice`, `mapAsync`, `onSubmittedWorkDone`, - `createRender/ComputePipelineAsync`, `popErrorScope`) registers a Dawn callback with - `CallbackMode::AllowProcessEvents` and calls `AsyncRunner::postTask`. -- `AsyncRunner::requestTick` (`async/AsyncRunner.cpp:89-177`) schedules `tick()` via - `setImmediate` / `setTimeout(4ms)` / `queueMicrotask`; `tick()` calls - `_instance.ProcessEvents()` and **re-schedules itself while any task is "pumping"** - (`AsyncRunner.cpp:189-191`). This is a busy reschedule loop: wasted CPU when idle, added - latency, and `JSIMicrotaskDispatcher`'s `queueMicrotask` dispatch is only thread-safe when - called on the runtime's own thread. - -### Present (manual, non-standard) -`api/GPUCanvasContext.cpp:56-65` → `SurfaceRegistry.h:116-121` → `wgpu::Surface::Present()`. -The user must call `context.present()` after every `queue.submit` (**16 JS/TS call sites**). -No CADisplayLink/Choreographer exists; RN's `requestAnimationFrame` is the only frame driver. -On Apple, present also does a blocking `WaitForCommandsToBeScheduled` on the JS thread. - ---- - -## Target architecture - -Three new pieces: - -### A. `RuntimeScheduler` — thread-safe "post to this runtime's JS thread" -Replaces `AsyncDispatcher` / `JSIMicrotaskDispatcher` (which use non-thread-safe -`queueMicrotask`). -- Interface: `void scheduleOnJS(std::function)`, callable from any thread. -- **Main runtime**: wraps `react::CallInvoker::invokeAsync` (already available — - `apple/WebGPUModule.mm:70`, `android/cpp/cpp-adapter.cpp:25-29`). -- **Worklet runtimes**: wraps the worklet runtime's own thread executor from - `react-native-worklets` 0.8.3 (**see Phase 0 spike #1**). -- Stored per-runtime in a `RuntimeContext` (the "per-JS-thread event loop"), created on first - WebGPU use, torn down via the existing `RuntimeLifecycleMonitor` / `RuntimeAwareCache` - (`cpp/jsi/RuntimeAwareCache.h`). - -### B. `GpuEventLoop` — background `WaitAny` thread (no polling) -One per `wgpu::Instance` (effectively global). -- All async sites switch `CallbackMode::AllowProcessEvents` → **`CallbackMode::WaitAnyOnly`**, - returning a `wgpu::Future`. -- A **small bounded thread pool**; each pending future is waited via - `instance.WaitAny(future, /*timeout*/UINT64_MAX)` on a pool thread → genuinely event-driven, - **zero idle CPU**, resolves the instant GPU work completes. No wake/interrupt problem (each - thread owns one future). **See Phase 0 spike #2.** -- On completion the worker marshals the result and calls the owning runtime's - `RuntimeScheduler.scheduleOnJS` to settle the JS Promise. `AsyncTaskHandle` / `Promise` - settle logic is reused; `AsyncRunner` + its tick loop are deleted. -- Fallback (if concurrent `WaitAny` on one instance is unsafe): single worker thread waiting on - the batched future set with a condition-variable re-arm. - -### C. `FrameDriver` — global vsync source for auto-present -One UI-thread singleton; removes the need for `present()`. -- **iOS**: `CADisplayLink` on the main run loop. **Android**: NDK - `AChoreographer_postFrameCallback` from C++ (API 24+, avoids JNI). **See Phase 0 spike #3.** -- Lifecycle: started when ≥1 surface is configured, stopped at 0. -- **Auto-present semantics** (spec-aligned "update the rendering" after rAF): - 1. `GPUCanvasContext::getCurrentTexture()` marks its `SurfaceInfo` dirty and registers a - present request with `FrameDriver`, tagged with the owning runtime. - 2. Each vsync (UI thread), `FrameDriver` dispatches each dirty context's present onto its - **owning runtime's `RuntimeScheduler`** — so `Surface.Present()` + the Apple Metal - scheduling wait run on the same thread that did `getCurrentTexture` / `submit`, preserving - Dawn surface thread-affinity and guaranteeing present-after-submit ordering (FIFO on that - loop). Clear dirty after present. -- Offscreen path (`SurfaceRegistry` `switchToOffscreen`, `src/Offscreen.ts`) has no surface → - present is a no-op; tests keep reading back the CPU texture. - ---- - -## Phase 0 — Local spikes (DO THESE FIRST, on your machine) - -These de-risk the refactor before any large change. Run from repo root. - -```bash -# 0. install deps (web container can't do this) -yarn install -``` - -### Spike 1 — worklet-runtime scheduler (HIGHEST RISK) -Goal: obtain a **thread-safe** "schedule this lambda on runtime R's thread" for an arbitrary -worklet runtime (UI runtime + a `createWorkletRuntime` runtime) using -`react-native-worklets@0.8.3`. - -```bash -# inspect the worklets native API actually shipped at 0.8.3 -find node_modules/react-native-worklets -name "*.h" | grep -iE "Runtime|Scheduler|Invoker|Queue" -# look for: WorkletRuntime, RuntimeManager / WorkletsModuleProxy, UIScheduler / JSScheduler, -# and any per-runtime executor / async queue we can call from a background C++ thread. -``` -Deliverable: a one-paragraph note on the exact symbol(s) to use (or "not exposed → needs JS -shim / worklets PR"). This determines whether Phase 3 (first-class worklet runtimes) is cheap -or needs a workaround. - -### Spike 2 — concurrent `WaitAny` on one Dawn instance -Goal: confirm multiple threads can each call `instance.WaitAny(singleFuture, UINT64_MAX)` -concurrently on the **same** instance safely. If not, switch `GpuEventLoop` to the -single-worker + condition-variable fallback. -- Search Dawn headers/docs in `externals/dawn` (or built `libs/`) for `WaitAny` threading - guarantees. A tiny throwaway C++ test against the built Dawn is ideal. - -### Spike 3 — Android frame callback -Goal: confirm NDK `AChoreographer_postFrameCallback` is usable at the project `minSdk` -(`packages/webgpu/android/build.gradle`). If `minSdk < 24` for that API, plan the Java -`Choreographer` + JNI bridge instead. - ---- - -## Phase 0 — Findings (completed 2026-06-02, branch `claude/keen-darwin-xeywa`) - -Environment verified: `node_modules` installed, `externals/dawn` present, RN **0.81.4**, -`react-native-worklets` **0.8.3**, Android `minSdk` **26**, NDK 26/27 available. - -### Spike 1 — worklet-runtime scheduler → **GREEN (symbol exists, thread-safe)** -`worklets/WorkletRuntime/WorkletRuntime.h` exposes exactly what we need: -- `WorkletRuntime::schedule(std::function job)` — posts `job` onto the - runtime's own `AsyncQueue` (`WorkletRuntime.cpp:211-227`). It is **callable from any thread** - (the underlying `AsyncQueueImpl` is a mutex+condvar queue; `AsyncQueueUI` forwards to the - `UIScheduler`). The job runs on the runtime's event-loop thread, under `runtimeMutex_`, and - uses `weak_from_this()` so it is a **safe no-op if the runtime was torn down**. This is a - drop-in for `RuntimeScheduler::scheduleOnJS` for worklet runtimes. -- `WorkletRuntime::getWeakRuntimeFromJSIRuntime(jsi::Runtime &rt)` (RN ≥ 0.81, we have 0.81.4) - maps a bare `jsi::Runtime&` → `weak_ptr`, so the per-runtime - `RuntimeContext` can recover the scheduler from any worklet runtime (UI + dedicated - `createWorkletRuntime`) with no JS shim. - -**Caveat (build wiring, not API):** webgpu does **not** currently link worklets natively -(no worklets entry in `packages/webgpu/*.podspec` or `android/CMakeLists.txt`; only JS-level -serialization helpers exist). Phase 3 must add the native dependency: -- iOS: depend on `RNWorklets` pod (it ships public headers under `worklets/`, - `header_dir = "worklets"`). -- Android: import the worklets **prefab** module `worklets` (`prefabPublishing` is on in - `react-native-worklets/android/build.gradle`). -Worklets is already a `peerDependency`, so this adds no new install. Phase 3 stays cheap; no -worklets PR or JS shim needed. - -### Spike 2 — concurrent `WaitAny` on one instance → **GREEN (designed for it)** -Dawn's native `EventManager` (`externals/dawn/src/dawn/native/EventManager.{h,cpp}`) is built -for multi-threaded waits: -- State is `MutexProtected`; `mNextFutureID` is atomic; a code comment - (`EventManager.h:78-82`) explicitly notes "another thread can race to complete the event … - via a WaitAny call". -- Each `WaitAny` call with a non-zero timeout creates a **stack-local `Waiter`** with its **own** - `MutexCondVarProtected` (`EventManager.cpp:338`, `:106`), registers it per-FutureID in - the shared map, then blocks on its own condvar. `SetFutureReady` signals the registered - waiters. → **N threads can each block in `WaitAny` on the same instance concurrently, each - owning its own future.** This is exactly the plan's primary "one future per pool thread" model. - -**Hard constraint discovered (`EventManager.cpp:341-354`):** within a *single* `WaitAny` call -with a non-zero timeout, you may **not** mix events from multiple queues, nor a queue event -together with a non-queue event — it returns `WaitStatus::Error` ("Mixed source waits with -timeouts are not currently supported"). Note `mapAsync`/`onSubmittedWorkDone` are *queue* -events while `requestAdapter`/`requestDevice`/`createPipelineAsync`/`popErrorScope` are -*non-queue* events. -→ **Implication:** adopt the **per-future-per-thread** design (each pool thread waits on exactly -one future) — it is single-source and always legal. The plan's stated fallback ("single worker -waiting on the batched future set") is **not viable** as written, because batching mixed sources -hits this restriction. If a bounded pool is undesirable, the correct fallback is one -worker-thread *per future* (still single-source), not one worker for a batched set. - -### Spike 3 — Android frame callback → **GREEN (no JNI bridge needed)** -In `android/choreographer.h`, `AChoreographer_getInstance()` and -`AChoreographer_postFrameCallback()` are both `__INTRODUCED_IN(24)`; `minSdk` is **26**, so the -pure-NDK path works with no Java `Choreographer`/JNI bridge. -- `postFrameCallback` is `__DEPRECATED_IN(29)` in favor of `postFrameCallback64` (API 29) / - `postVsyncCallback` (API 33). Recommendation: call `postFrameCallback64` when - `android_get_device_api_level() >= 29`, else `postFrameCallback` (works on 26-28). Both are - acceptable; the 64-bit variant just avoids the deprecation warning and 32-bit time wrap. -- `AChoreographer_getInstance()` must be called on a thread with a `Looper` (the main/UI - thread) — `FrameDriver` already lives on the UI thread, so this is satisfied. - -### Net go/no-go -All three risks clear. Proceed to Phase 1. Two plan amendments: (1) Phase 3 must add the -worklets native build dependency (podspec + prefab); (2) `GpuEventLoop` must use -per-future-per-thread waits (drop the batched-future fallback). - -## Implementation phases (after Phase 0) - -**Phase 1 — Event-driven async** (no public API change; `present()` untouched) — **DONE** -- Add `RuntimeScheduler` (+ main-runtime CallInvoker impl) and `GpuEventLoop`. -- Switch all 7 async sites to `WaitAnyOnly` + `GpuEventLoop.addFuture(...)`: - `api/GPU.cpp`, `api/GPUAdapter.cpp`, `api/GPUDevice.cpp` (×3), `api/GPUBuffer.cpp`, - `api/GPUQueue.cpp`, `api/GPUShaderModule.cpp`. -- Delete `async/AsyncRunner.*` polling + `async/JSIMicrotaskDispatcher.*`; keep - `AsyncTaskHandle` / `Promise` settle path on the new scheduler. - -### Phase 1 — what shipped (branch `claude/keen-darwin-xeywa`) -New files (`cpp/rnwgpu/async/`): -- `RuntimeScheduler.h` — interface `scheduleOnJS(std::function)`, - callable from any thread. -- `CallInvokerScheduler.{h,cpp}` — main-runtime impl wrapping - `react::CallInvoker::invokeAsync(CallFunc&&)` (RN 0.81 delivers the job on the JS thread - with the runtime). -- `GpuEventLoop.{h,cpp}` — background `WaitAny` driver. Lazily-grown bounded worker pool - (cap = `clamp(hardware_concurrency, 2, 8)`); each worker does a single-future - `instance.WaitAny(future, UINT64_MAX)` (always a legal single-source wait, per Phase 0 - spike 2). Shared state held behind a `shared_ptr` so detached workers (and the - `wgpu::Instance` ref they need) outlive the object safely; teardown sets `running=false` - and notifies idle workers without joining in-flight GPU waits. - -Deviations from the original plan (intentional): -1. **`AsyncRunner` was replaced by `RuntimeContext`** (`async/RuntimeContext.{h,cpp}`), the - per-runtime coordinator the plan's Target-architecture §A already named. It bundles - `{RuntimeScheduler, GpuEventLoop}` and exposes `postTask`; all polling internals - (`tick`/`requestTick`/`ProcessEvents`/pump counters) are gone. `AsyncTaskHandle` depends - only on `RuntimeScheduler`. The old `AsyncRunner` name/files no longer exist anywhere - (the 6 `api/*` classes now hold `std::shared_ptr _async`); the dead - `GPU::getAsyncRunner()` accessor was deleted. -2. **`postTask`'s callback now returns a `wgpu::Future`** (the value returned by the Dawn - `WaitAnyOnly` call), which `AsyncRunner` hands to `GpuEventLoop.addFuture`. A returned - future with `id == 0` means "no event to wait on" and is ignored — used by - `GPUDevice::getLost` (resolved synchronously or later via `notifyDeviceLost`). This - replaced the old `keepPumping` bool argument, which is gone. - -`GPU`'s constructor now takes the `CallInvoker` (threaded through from `RNWebGPUManager`, -which already held it) to build the `CallInvokerScheduler`. `AsyncDispatcher.h` and -`JSIMicrotaskDispatcher.{h,cpp}` deleted; `android/CMakeLists.txt` updated (iOS podspec -globs `cpp/**` so it needs no change). - -Validation run locally: all changed + new TUs syntax-check under the Android NDK toolchain; -the full `react-native-wgpu` native lib **compiles and links** for `arm64-v8a` (ninja); -`cpplint` clean (project filters); `clang-format` (pinned 15.0.0) applied; `yarn tsc` passes -(no TS changed). On-device runtime behaviour (frame pacing, zero idle CPU) is Phase 4. - -**Phase 2 — Auto-present + remove `present()`** -- Add `FrameDriver` (iOS `CADisplayLink`, Android `AChoreographer`); wire - `getCurrentTexture` → register; vsync → dispatch present to owning runtime. -- Remove `GPUCanvasContext::present` (`api/GPUCanvasContext.h:50,58`, `.cpp:56-65`) and - `SurfaceInfo::present` (`SurfaceRegistry.h:116-121`). -- JS: drop `present` from `RNCanvasContext` (`src/Canvas.tsx:22-24`, `src/types.ts`). -- Migrate all 16 example / `useWebGPU` call sites + `README.md` + `packages/webgpu/README.md`. - -**Phase 3 — First-class worklet runtimes** -- Worklet-runtime `RuntimeScheduler` impl (per Spike 1); verify auto-present dispatch on UI + - dedicated runtimes; update `apps/example/src/Reanimated/Reanimated.tsx` (drop `present()`, - keep its own rAF loop). - -**Phase 4 — Validation** -```bash -yarn tsc && yarn lint -yarn workspace react-native-wgpu test # offscreen readback + demo specs -yarn build:ios # or: yarn workspace example ios -yarn build:android # or: yarn workspace example android -``` -Verify: no idle-CPU polling (logging), correct frame pacing, no present-ordering glitches, -Reanimated UI/Dedicated examples render. - ---- - -## 16 `present()` call sites to migrate (Phase 2) - -``` -apps/example/src/StorageBufferVertices/StorageBufferVertices.tsx -apps/example/src/components/useWebGPU.ts -apps/example/src/components/Texture.tsx -apps/example/src/SharedTextureMemory/SharedTextureMemory.tsx -apps/example/src/ThreeJS/Helmet.tsx -apps/example/src/ComputeToys/engine/index.ts -apps/example/src/CanvasAPI/CanvasAPI.tsx -apps/example/src/ThreeJS/PostProcessing.tsx -apps/example/src/ThreeJS/Cube.tsx -apps/example/src/Triangle/HelloTriangle.tsx -apps/example/src/Triangle/HelloTriangleMSAA.tsx -apps/example/src/ThreeJS/InstancedMesh.tsx -apps/example/src/ThreeJS/Retargeting.tsx -apps/example/src/ThreeJS/components/FiberCanvas.tsx -apps/example/src/Reanimated/Reanimated.tsx -apps/example/src/ThreeJS/Backdrop.tsx -``` -Plus `README.md` and `packages/webgpu/README.md`. - ---- - -## Risks / open questions -- **Worklet-runtime scheduler** access in worklets 0.8.3 (Spike 1 — highest risk). -- **Concurrent `WaitAny`** semantics on one Dawn instance (Spike 2; single-worker fallback ready). -- **Present timing**: vsync-dispatched-to-owning-loop must land after submit (FIFO on that loop) - and before the next `getCurrentTexture`. -- **Breaking change**: `present()` removed — type, examples, README updated together. -- **Apple Metal wait** moves into the frame-boundary present task, off the synchronous call path. - ---- - -## How to resume locally - -```bash -git fetch origin claude/keen-darwin-xeywa -git checkout claude/keen-darwin-xeywa -git pull origin claude/keen-darwin-xeywa -# open this file and run Phase 0 spikes, then start Claude Code: -# claude -# suggested kickoff prompt: -# "Read docs/refactor-async-present-plan.md. Run the Phase 0 spikes and report -# findings before implementing. Develop on this branch." -```