Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
9d5572a
feat(⚡️): keep Dawn's compiled pipelines across launches
camchis Oct 24, 2026
8ef0f85
fix(⚡️): leave a cache write another process may still finish, and re…
camchis Oct 24, 2026
410baee
chore(🐙): unit test the Dawn blob cache on the host
camchis Oct 24, 2026
9540249
chore(🐙): use POSIX calls in the blob cache test, as cpplint asks
camchis Oct 24, 2026
e0321ca
chore(📚): say what the pipeline cache keeps on each backend, and when…
camchis Oct 24, 2026
19868ba
fix(⚡️): recreate the cache directory's parents, and keep the cache o…
camchis Oct 24, 2026
331710b
chore(🐙): test the cache's parent directories, and require the CMake …
camchis Oct 24, 2026
1685b9c
fix(⚡️): clear only the cache's own files, stay off without a usable …
camchis Oct 24, 2026
2c8a167
chore(🐙): test that the cache leaves other files alone and stays off …
camchis Oct 24, 2026
55997fe
fix(⚡️): trim the oldest cache entries to fit the limit, and check it…
camchis Oct 25, 2026
23e8bae
chore(🐙): test that the cache is trimmed rather than cleared, at setu…
camchis Oct 25, 2026
49b806f
Merge branch 'main' into feat/dawn-blob-cache
wcandillon Oct 28, 2026
a987838
fix(⚡️): keep one entry from taking the cache, and count replacements…
camchis Oct 29, 2026
3361634
fix(⚡️): persist Dawn's monolithic Vulkan pipeline cache
camchis Oct 29, 2026
8357d57
chore(📚): say when the pipeline cache is written and how its size is …
camchis Oct 29, 2026
8892675
Merge remote-tracking branch 'origin/main' into feat/dawn-blob-cache
wcandillon Nov 2, 2026
0d8e05b
refactor(⚡️): write blob cache entries on a thread of their own, and …
wcandillon Nov 2, 2026
23681dc
refactor(⚡️): flush the Vulkan pipeline cache on destroy and when the…
wcandillon Nov 2, 2026
ea8db8c
fix(⚡️): flush the Vulkan pipeline cache every ten seconds, from any …
wcandillon Nov 2, 2026
d19233a
fix(⚡️): keep the writer thread alive through a failing store, and me…
wcandillon Nov 2, 2026
92f55fa
chore(🐙): run the C++ unit tests in a job of their own, with the goog…
wcandillon Nov 2, 2026
560260e
fix(⚡️): serialize the pipeline cache off the frame thread, hold no d…
wcandillon Nov 2, 2026
61c02cf
Merge branch 'main' into feat/dawn-blob-cache
wcandillon Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,38 @@ jobs:
exit 1
}

# The C++ unit tests, built for the host (yarn test:cpp). A job of their
# own, so that a failing test is reported in a minute or two rather than
# after the Android job's setup, and so that a googletest download that
# fails does not fail the Android e2e run.
cpp-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup
uses: ./.github/actions/setup
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

# FetchContent downloads googletest into the build directory; with the
# download, its stamps and the extracted source in place, a configure
# skips the download. Those only: the objects built from them belong to
# the runner image's compiler, which the key does not name.
- name: Cache googletest
uses: actions/cache@v4
with:
path: |
packages/webgpu/node_modules/.cache/cpp-tests/_deps/googletest-src
packages/webgpu/node_modules/.cache/cpp-tests/_deps/googletest-subbuild
key: ${{ runner.os }}-cpp-tests-googletest-${{ hashFiles('packages/webgpu/tests/cpp/CMakeLists.txt') }}

- name: Run the C++ unit tests
working-directory: packages/webgpu
run: yarn test:cpp

# Android e2e on a GitHub-hosted emulator. The runner has no GPU, so the guest
# sees a SwiftShader (software) Vulkan 1.3 device through gfxstream, which Dawn
# exposes as a fallback adapter. Specs that need hardware-only paths (hardware
Expand Down
16 changes: 16 additions & 0 deletions apps/docs/content/docs/getting-started/native-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ A device created by another library and wrapped with [`importDevice()`](#importi

See [Vision Camera](/docs/integrations/vision-camera) for an end-to-end example applying WGSL effects to live camera frames, and [Native Extensions](/api/gpu-device-extensions) for the full API.

## Pipeline cache

Every device `requestDevice()` creates keeps what Dawn produces while it builds shaders and pipelines in a cache on disk, in the app's cache directory, so a relaunch can reuse it instead of redoing the work. On Vulkan (Android) that includes the driver's pipeline cache data, which saves the most, since building pipelines is where Vulkan drivers spend their time. That data is written when `device.destroy()` is called, when the app goes to the background and, while the app submits work to the queue, about every ten seconds: a `queue.submit()` ten seconds or more after the last write triggers the next. An app killed while in the foreground therefore loses at most the last ten seconds of pipeline building, or everything built since its last submit. On Metal (iOS) the cache holds the shaders Dawn translates to MSL; Metal still builds the pipelines, with the help of its own system cache.

The cache is kept to about 64 MB. An app that finds its cache past that size when it starts clears it, and builds everything once more; a session that would take the cache past that size stops adding to it until then. An entry larger than half of that is not stored, so the Vulkan pipeline cache data stops being updated if it ever grows past 32 MB. The system may also clear the cache, as it does any app cache.

To opt a device out, disable it with a Dawn toggle:

```tsx
const device = await adapter.requestDevice({
dawnToggles: { enabledToggles: ["disable_blob_cache"] },
});
```

A device wrapped with [`importDevice()`](#importing-an-external-device) is created by its own library and does not use this cache.

## Importing an external device

`importDevice()` wraps a GPU device created by another native library into a regular `GPUDevice`. The device handle is passed as a `BigInt`, and the original owner keeps its own reference.
Expand Down
2 changes: 1 addition & 1 deletion packages/webgpu/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Example app (from `apps/example/`): `yarn start` · `yarn ios` · `yarn android`

From the repo root: `yarn lint` · `yarn tsc` · `yarn build:docs`

Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests)
Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests) · `yarn test:cpp` (C++ unit tests of `cpp/rnwgpu/DawnBlobCache.cpp`, built for the host with CMake and a C++20 compiler against Dawn's headers; one test after a build: `ctest --test-dir node_modules/.cache/cpp-tests -R <name>`)

`yarn test` needs the example app open on its "Tests" screen and connected to Metro first: start Metro with `CI=true yarn start` (from `apps/example/`) — `initialRouteName` in `apps/example/src/App.tsx` is `"Tests"` when `process.env.CI === "true"`, inlined at bundle time via the `transform-inline-environment-variables` babel plugin — then launch the app (`yarn ios` / `yarn android`, or install+launch an existing build). The screen shows "Connecting to localhost..." once it's ready; `yarn test` then picks it up automatically. If a run fails mid-suite, reload the app (`curl -X POST http://localhost:8081/reload`) before the next run — a broken `GPUDevice` from the failed run makes unrelated later tests fail too. The app and the test server talk over port 4242 by default; set `E2E_PORT` for both Metro (it is inlined into the bundle, so start Metro with `--reset-cache` after changing it) and `yarn test` to use another one, as CI does to keep clear of the other projects sharing its runner.

Expand Down
1 change: 1 addition & 0 deletions packages/webgpu/android/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ add_library(${PACKAGE_NAME} SHARED
../cpp/rnwgpu/api/GPUComputePassEncoder.cpp
../cpp/rnwgpu/api/GPUComputePipeline.cpp
../cpp/rnwgpu/api/GPUCanvasContext.cpp
../cpp/rnwgpu/DawnBlobCache.cpp
../cpp/rnwgpu/RNWebGPUManager.cpp
../cpp/rnwgpu/WebGPURuntime.cpp
../cpp/jsi/Promise.cpp
Expand Down
24 changes: 23 additions & 1 deletion packages/webgpu/android/cpp/cpp-adapter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
#include <webgpu/webgpu_cpp.h>

#include "AndroidPlatformContext.h"
#include "DawnBlobCache.h"
#include "GPUCanvasContext.h"
#include "GPUDevice.h"
#include "RNWebGPUManager.h"
#include "WebGPURuntime.h"

Expand All @@ -31,8 +33,18 @@ std::shared_ptr<rnwgpu::RNWebGPUManager> manager;

extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative(
JNIEnv *env, jobject thiz, jlong jsRuntime, jobject jsCallInvokerHolder,
jobject blobModule) {
jobject blobModule, jstring cacheDirectory) {
auto runtime = reinterpret_cast<facebook::jsi::Runtime *>(jsRuntime);
if (cacheDirectory != nullptr) {
const char *path = env->GetStringUTFChars(cacheDirectory, nullptr);
if (path == nullptr) {
// The JVM could not copy the string, and an OutOfMemoryError is pending:
// no further JNI call is allowed, so let it reach the caller.
return;
}
rnwgpu::DawnBlobCache::setDirectory(path);
env->ReleaseStringUTFChars(cacheDirectory, path);
}
jobject globalBlobModule = env->NewGlobalRef(blobModule);
jobject globalModule = env->NewGlobalRef(thiz);
auto jsCallInvoker{
Expand All @@ -45,6 +57,16 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative(
platformContext);
}

// WebGPUModule.onHostPause (UI thread): the app is going to the background,
// the last moment to write what Dawn's pipeline caches hold before the process
// may be killed. Each device flushes here, or on the thread of its own runtime
// when it does not synchronize itself.
extern "C" JNIEXPORT void JNICALL
Java_com_webgpu_WebGPUModule_flushPipelineCaches(JNIEnv * /* env */,
jobject /* this */) {
rnwgpu::GPUDevice::flushPipelineCaches();
}

// Completion of WebGPUModule.snapshotView (UI thread). Reclaims the callbacks
// handed to Java and delivers either the bitmap's pixels or the error.
extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_onViewSnapshot(
Expand Down
37 changes: 34 additions & 3 deletions packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@

import androidx.annotation.OptIn;

import java.io.File;
import java.util.HashSet;
import java.util.Set;

import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.JavaScriptContextHolder;
import com.facebook.react.bridge.LifecycleEventListener;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.bridge.UiThreadUtil;
Expand All @@ -26,7 +28,7 @@
import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder;

@ReactModule(name = WebGPUModule.NAME)
public class WebGPUModule extends NativeWebGPUModuleSpec {
public class WebGPUModule extends NativeWebGPUModuleSpec implements LifecycleEventListener {
static {
System.loadLibrary("react-native-webgpu"); // Load the C++ library
}
Expand All @@ -35,8 +37,34 @@ public WebGPUModule(ReactApplicationContext reactContext) {
super(reactContext);
// Initialize the C++ module
initialize();
reactContext.addLifecycleEventListener(this);
}

@Override
public void invalidate() {
getReactApplicationContext().removeLifecycleEventListener(this);
super.invalidate();
}

@Override
public void onHostResume() {}

/**
* The app is going to the background: the last moment to write what Dawn's
* pipeline caches hold before the process may be killed (see
* GPUDevice::flushPipelineCaches).
*/
@Override
public void onHostPause() {
flushPipelineCaches();
}

@Override
public void onHostDestroy() {}

@DoNotStrip
private native void flushPipelineCaches();

@OptIn(markerClass = FrameworkAPI.class)
@ReactMethod(isBlockingSynchronousMethod = true)
public boolean install() {
Expand All @@ -47,13 +75,16 @@ public boolean install() {
if (blobModule == null) {
throw new RuntimeException("React Native's BlobModule was not found!");
}
initializeNative(jsContext.get(), (CallInvokerHolderImpl) callInvokerHolder, blobModule);
// Dawn keeps the pipelines it compiles here, so a relaunch does not
// compile them again (see DawnBlobCache).
String cacheDirectory = new File(context.getCacheDir(), "webgpu-dawn").getAbsolutePath();
initializeNative(jsContext.get(), (CallInvokerHolderImpl) callInvokerHolder, blobModule, cacheDirectory);
return true;
}

@OptIn(markerClass = FrameworkAPI.class)
@DoNotStrip
private native void initializeNative(long jsRuntime, CallInvokerHolderImpl jsInvoker, BlobModule blobModule);
private native void initializeNative(long jsRuntime, CallInvokerHolderImpl jsInvoker, BlobModule blobModule, String cacheDirectory);

/**
* Called from C++ (AndroidPlatformContext::snapshotView) on an arbitrary
Expand Down
15 changes: 15 additions & 0 deletions packages/webgpu/apple/WebGPUModule.mm
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#import "WebGPUModule.h"
#include "ApplePlatformContext.h"
#include "DawnBlobCache.h"
#import "GPUCanvasContext.h"
#import "RNWGUIKit.h"

Expand Down Expand Up @@ -109,6 +110,20 @@ - (void)invalidate {
std::shared_ptr<rnwgpu::PlatformContext> platformContext =
std::make_shared<rnwgpu::ApplePlatformContext>(
std::move(viewLookup), std::move(blobManagerLookup));
// Dawn keeps the pipelines it compiles here, so a relaunch does not compile
// them again (see DawnBlobCache). The caches directory is per app on iOS but
// shared on macOS, hence the bundle identifier.
NSString *caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
NSUserDomainMask, YES)
.firstObject;
NSString *bundle = NSBundle.mainBundle.bundleIdentifier;
if (caches != nil) {
if (bundle != nil) {
caches = [caches stringByAppendingPathComponent:bundle];
}
rnwgpu::DawnBlobCache::setDirectory(
[caches stringByAppendingPathComponent:@"webgpu-dawn"].UTF8String);
}
webgpuManager = std::make_shared<rnwgpu::RNWebGPUManager>(runtime, jsInvoker,
platformContext);
return @true;
Expand Down
Loading
Loading