From 9d5572a5d577050c037431b77f97fe5ab33274f8 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 12:34:55 +0100 Subject: [PATCH 01/20] =?UTF-8?q?feat(=E2=9A=A1=EF=B8=8F):=20keep=20Dawn's?= =?UTF-8?q?=20compiled=20pipelines=20across=20launches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/getting-started/native-api.mdx | 14 ++ packages/webgpu/android/CMakeLists.txt | 1 + packages/webgpu/android/cpp/cpp-adapter.cpp | 8 +- .../main/java/com/webgpu/WebGPUModule.java | 8 +- packages/webgpu/apple/WebGPUModule.mm | 19 ++ packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 185 ++++++++++++++++++ packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 32 +++ packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 7 + 8 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp create mode 100644 packages/webgpu/cpp/rnwgpu/DawnBlobCache.h diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index 52a1f63ea..fd50d02e2 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -91,6 +91,20 @@ 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 + +Dawn keeps the render and compute pipelines it compiles, and the shaders it translates for them, on disk in the app's cache directory, so a relaunch loads them instead of compiling them again. Compiling is slowest on Android, where each pipeline can take tens to hundreds of milliseconds on Vulkan drivers; iOS also keeps its own Metal cache. Every device `requestDevice()` creates uses the cache. The cache is cleared when it grows past 64 MB, and the system may clear it 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. diff --git a/packages/webgpu/android/CMakeLists.txt b/packages/webgpu/android/CMakeLists.txt index 0c68a15f9..2ed41308b 100644 --- a/packages/webgpu/android/CMakeLists.txt +++ b/packages/webgpu/android/CMakeLists.txt @@ -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 diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index b3768488c..e57774cd0 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -21,6 +21,7 @@ #include #include "AndroidPlatformContext.h" +#include "DawnBlobCache.h" #include "GPUCanvasContext.h" #include "RNWebGPUManager.h" #include "WebGPURuntime.h" @@ -31,8 +32,13 @@ std::shared_ptr 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(jsRuntime); + if (cacheDirectory != nullptr) { + const char *path = env->GetStringUTFChars(cacheDirectory, nullptr); + rnwgpu::DawnBlobCache::setDirectory(path); + env->ReleaseStringUTFChars(cacheDirectory, path); + } jobject globalBlobModule = env->NewGlobalRef(blobModule); jobject globalModule = env->NewGlobalRef(thiz); auto jsCallInvoker{ diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java index 32750e698..b36c5767b 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java @@ -8,6 +8,7 @@ import androidx.annotation.OptIn; +import java.io.File; import java.util.HashSet; import java.util.Set; @@ -47,13 +48,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 diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index 62f58dfb5..9bc340d76 100644 --- a/packages/webgpu/apple/WebGPUModule.mm +++ b/packages/webgpu/apple/WebGPUModule.mm @@ -1,5 +1,6 @@ #import "WebGPUModule.h" #include "ApplePlatformContext.h" +#include "DawnBlobCache.h" #import "GPUCanvasContext.h" #import "RNWGUIKit.h" @@ -95,6 +96,24 @@ - (void)invalidate { }; std::shared_ptr platformContext = std::make_shared(std::move(viewLookup)); + // 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]; + [NSFileManager.defaultManager createDirectoryAtPath:caches + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + rnwgpu::DawnBlobCache::setDirectory( + [caches stringByAppendingPathComponent:@"webgpu-dawn"].UTF8String); + } webgpuManager = std::make_shared(runtime, jsInvoker, platformContext); return @true; diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp new file mode 100644 index 000000000..9c27ee337 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -0,0 +1,185 @@ +#include "DawnBlobCache.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace rnwgpu { + +namespace { + +// Entries only accumulate as an app's shaders change from release to release, +// so past this size the cache is cleared and refills with what the app +// compiles next. +constexpr off_t kMaxCacheBytes = 64 * 1024 * 1024; + +// Keeps this library's entries apart from any other user of the same Dawn +// cache in the process. +constexpr const char *kIsolationKey = "react-native-webgpu"; + +std::mutex &directoryMutex() { + static std::mutex mutex; + return mutex; +} + +std::string &directorySlot() { + static std::string directory; + return directory; +} + +std::string directory() { + std::lock_guard lock(directoryMutex()); + return directorySlot(); +} + +// One file per entry, named by a 64-bit FNV-1a hash of its key. +std::string entryPath(const std::string &dir, size_t keySize, + const uint8_t *key) { + uint64_t hash = 14695981039346656037ull; + for (size_t i = 0; i < keySize; ++i) { + hash ^= key[i]; + hash *= 1099511628211ull; + } + char name[18]; + snprintf(name, sizeof(name), "/%016llx", + static_cast(hash)); + return dir + name; +} + +// An entry is the key's size, the value's size, the key and the value. The +// stored key is compared on load, so a hash collision reads as a miss, and so +// does an entry shorter than its recorded size. Dawn asks for the size first +// (no buffer), then for the value. +size_t loadEntry(size_t keySize, const uint8_t *key, size_t valueSize, + uint8_t *value, void * /* userdata1 */, + void * /* userdata2 */) { + const std::string dir = directory(); + if (dir.empty()) { + return 0; + } + FILE *file = fopen(entryPath(dir, keySize, key).c_str(), "rb"); + if (file == nullptr) { + return 0; + } + size_t result = 0; + uint64_t sizes[2] = {0, 0}; + if (fread(sizes, sizeof(sizes), 1, file) == 1 && sizes[0] == keySize) { + std::vector storedKey(keySize); + if (fread(storedKey.data(), 1, keySize, file) == keySize && + memcmp(storedKey.data(), key, keySize) == 0) { + const long start = ftell(file); + fseek(file, 0, SEEK_END); + const size_t stored = static_cast(ftell(file) - start); + if (stored == sizes[1]) { + if (value == nullptr || valueSize == 0) { + result = stored; + } else if (valueSize >= stored) { + fseek(file, start, SEEK_SET); + result = fread(value, 1, stored, file) == stored ? stored : 0; + } + } + } + } + fclose(file); + return result; +} + +// Written aside and renamed into place, so a concurrent load never sees half +// an entry. Dawn may store from several threads at once. +void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, + const uint8_t *value, void * /* userdata1 */, + void * /* userdata2 */) { + const std::string dir = directory(); + if (dir.empty()) { + return; + } + static std::atomic writes{0}; + const std::string path = entryPath(dir, keySize, key); + const std::string temporary = path + "." + std::to_string(getpid()) + "." + + std::to_string(writes.fetch_add(1)) + ".tmp"; + FILE *file = fopen(temporary.c_str(), "wb"); + if (file == nullptr) { + return; + } + const uint64_t sizes[2] = {keySize, valueSize}; + bool written = fwrite(sizes, sizeof(sizes), 1, file) == 1 && + fwrite(key, 1, keySize, file) == keySize && + fwrite(value, 1, valueSize, file) == valueSize; + written = fclose(file) == 0 && written; + if (!written || rename(temporary.c_str(), path.c_str()) != 0) { + remove(temporary.c_str()); + } +} + +bool endsWith(const std::string &name, const char *suffix) { + const size_t length = strlen(suffix); + return name.size() > length && + name.compare(name.size() - length, length, suffix) == 0; +} + +// Removes the temporary files of writes a killed process left unfinished, and +// clears the cache once it has outgrown kMaxCacheBytes. +void tidy(const std::string &dir) { + DIR *listing = opendir(dir.c_str()); + if (listing == nullptr) { + return; + } + std::vector entries; + off_t total = 0; + while (dirent *entry = readdir(listing)) { + const std::string name = entry->d_name; + if (name == "." || name == "..") { + continue; + } + const std::string path = dir + "/" + name; + if (endsWith(name, ".tmp")) { + remove(path.c_str()); + continue; + } + struct stat info{}; + if (stat(path.c_str(), &info) == 0) { + total += info.st_size; + } + entries.push_back(path); + } + closedir(listing); + if (total > kMaxCacheBytes) { + for (const auto &path : entries) { + remove(path.c_str()); + } + } +} + +} // namespace + +void DawnBlobCache::setDirectory(const std::string &dir) { + if (!dir.empty()) { + mkdir(dir.c_str(), 0700); + tidy(dir); + } + std::lock_guard lock(directoryMutex()); + directorySlot() = dir; +} + +void DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, + WGPUDawnCacheDeviceDescriptor &storage) { + if (directory().empty()) { + return; + } + storage.isolationKey = {kIsolationKey, WGPU_STRLEN}; + storage.dawnLoadCacheDataCallbackInfo.callback = loadEntry; + storage.dawnStoreCacheDataCallbackInfo.callback = storeEntry; + storage.chain.next = reinterpret_cast( + const_cast(descriptor.nextInChain)); + descriptor.nextInChain = reinterpret_cast(&storage); +} + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h new file mode 100644 index 000000000..73423775e --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "webgpu/webgpu_cpp.h" + +namespace rnwgpu { + +// Dawn's blob cache, kept on disk so the pipelines a device compiles (and the +// shaders Dawn translates for them) survive a relaunch. Dawn only caches when +// the embedder gives it somewhere to put the blobs (Chrome hands it its disk +// cache); without one, every launch compiles every pipeline again, which on +// Android's Vulkan drivers costs tens to hundreds of milliseconds each. +// +// Each platform's module points the cache at the app's cache directory when it +// installs, and requestDevice() chains it onto every device it creates. A +// device requested with the `disable_blob_cache` Dawn toggle does not use it. +class DawnBlobCache { +public: + // The directory to keep the blobs in, created if missing. Leftovers of + // interrupted writes are removed, and an outgrown cache is cleared. An empty + // path disables the cache. + static void setDirectory(const std::string &directory); + + // Chains the cache onto `descriptor`, in front of whatever it already + // chains. `storage` holds the chained struct and must outlive the + // RequestDevice call that reads it. Does nothing without a directory. + static void chain(wgpu::DeviceDescriptor &descriptor, + WGPUDawnCacheDeviceDescriptor &storage); +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index 1e514d3ef..4ccbe2ab1 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -17,6 +17,7 @@ #endif #include "Convertors.h" +#include "DawnBlobCache.h" #include "GPUFeatures.h" #include "JSIConverter.h" @@ -267,6 +268,12 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( toggles.disabledToggles = disabledToggles.data(); deviceDesc.nextInChain = &toggles; } + // Compiled pipelines are kept on disk across launches (see + // DawnBlobCache). Chained in front of the toggles, and alive for the + // synchronous RequestDevice call below, like them. + WGPUDawnCacheDeviceDescriptor blobCache = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + DawnBlobCache::chain(deviceDesc, blobCache); _instance.RequestDevice( &deviceDesc, wgpu::CallbackMode::AllowProcessEvents, [context, resolve, reject, label, creationRuntime, From 8ef0f856c41cea41163bae6e890c5c684e7a2f65 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 13:00:17 +0100 Subject: [PATCH 02/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20leave=20a=20ca?= =?UTF-8?q?che=20write=20another=20process=20may=20still=20finish,=20and?= =?UTF-8?q?=20recreate=20a=20cleared=20cache=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 29 ++++++++++++++++---- packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 5 ++-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index 9c27ee337..d52bff091 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,11 @@ constexpr off_t kMaxCacheBytes = 64 * 1024 * 1024; // cache in the process. constexpr const char *kIsolationKey = "react-native-webgpu"; +// A temporary file this old belongs to a write that will never finish (its +// process was killed); a younger one may still be being written by another +// process using the same cache, so tidy() leaves it. +constexpr time_t kAbandonedWriteSeconds = 60; + std::mutex &directoryMutex() { static std::mutex mutex; return mutex; @@ -106,6 +112,11 @@ void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, const std::string temporary = path + "." + std::to_string(getpid()) + "." + std::to_string(writes.fetch_add(1)) + ".tmp"; FILE *file = fopen(temporary.c_str(), "wb"); + if (file == nullptr) { + // The system or the app may have cleared the cache directory itself. + mkdir(dir.c_str(), 0700); + file = fopen(temporary.c_str(), "wb"); + } if (file == nullptr) { return; } @@ -126,7 +137,9 @@ bool endsWith(const std::string &name, const char *suffix) { } // Removes the temporary files of writes a killed process left unfinished, and -// clears the cache once it has outgrown kMaxCacheBytes. +// clears the cache if it has outgrown kMaxCacheBytes. Runs when the cache is +// set up, so the limit is checked once per launch rather than on every store: +// a session can take the cache past it, and the next launch clears it. void tidy(const std::string &dir) { DIR *listing = opendir(dir.c_str()); if (listing == nullptr) { @@ -134,20 +147,24 @@ void tidy(const std::string &dir) { } std::vector entries; off_t total = 0; + const time_t now = time(nullptr); while (dirent *entry = readdir(listing)) { const std::string name = entry->d_name; if (name == "." || name == "..") { continue; } const std::string path = dir + "/" + name; - if (endsWith(name, ".tmp")) { - remove(path.c_str()); + struct stat info{}; + if (stat(path.c_str(), &info) != 0) { continue; } - struct stat info{}; - if (stat(path.c_str(), &info) == 0) { - total += info.st_size; + if (endsWith(name, ".tmp")) { + if (now - info.st_mtime > kAbandonedWriteSeconds) { + remove(path.c_str()); + } + continue; } + total += info.st_size; entries.push_back(path); } closedir(listing); diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h index 73423775e..c3a92f3d6 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -18,8 +18,9 @@ namespace rnwgpu { class DawnBlobCache { public: // The directory to keep the blobs in, created if missing. Leftovers of - // interrupted writes are removed, and an outgrown cache is cleared. An empty - // path disables the cache. + // interrupted writes are removed, and a cache that has outgrown its limit + // is cleared (the limit is checked here, once per launch). An empty path + // disables the cache. static void setDirectory(const std::string &directory); // Chains the cache onto `descriptor`, in front of whatever it already From 410baee4851cff35fe84fefa42c07530cb2ef3d1 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 13:01:49 +0100 Subject: [PATCH 03/20] =?UTF-8?q?chore(=F0=9F=90=99):=20unit=20test=20the?= =?UTF-8?q?=20Dawn=20blob=20cache=20on=20the=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 + packages/webgpu/CONTRIBUTING.md | 2 +- packages/webgpu/package.json | 1 + packages/webgpu/tests/cpp/CMakeLists.txt | 45 +++ .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 299 ++++++++++++++++++ 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 packages/webgpu/tests/cpp/CMakeLists.txt create mode 100644 packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d986f278d..9b0e231c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,6 +348,10 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Run the C++ unit tests + working-directory: packages/webgpu + run: yarn test:cpp + - name: Install JDK uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: diff --git a/packages/webgpu/CONTRIBUTING.md b/packages/webgpu/CONTRIBUTING.md index 6d0ab49f7..e5d450034 100644 --- a/packages/webgpu/CONTRIBUTING.md +++ b/packages/webgpu/CONTRIBUTING.md @@ -12,7 +12,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 `) `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. diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json index 9d0e47cd3..9aa0255ef 100644 --- a/packages/webgpu/package.json +++ b/packages/webgpu/package.json @@ -31,6 +31,7 @@ "test:ref": "REFERENCE=true NODE_OPTIONS='--experimental-require-module' jest -i", "test:node": "NODE_WEBGPU=true NODE_OPTIONS='--experimental-require-module' jest -i", "test:plugin": "jest -c plugin/jest.config.js", + "test:cpp": "cmake -S tests/cpp -B node_modules/.cache/cpp-tests && cmake --build node_modules/.cache/cpp-tests --parallel && ctest --test-dir node_modules/.cache/cpp-tests --output-on-failure", "lint": "eslint . --ext .ts,.tsx --max-warnings 0 --cache --fix", "tsc": "tsc --noEmit", "build": "bob build && yarn build:plugin", diff --git a/packages/webgpu/tests/cpp/CMakeLists.txt b/packages/webgpu/tests/cpp/CMakeLists.txt new file mode 100644 index 000000000..dcd3ce391 --- /dev/null +++ b/packages/webgpu/tests/cpp/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.22) +project(react_native_webgpu_cpp_tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED True) + +# Dawn's headers come from the react-native-webgpu-dawn package, as in the +# Android and iOS builds. The code under test needs only its headers. +execute_process( + COMMAND node -e "process.stdout.write(require('path').dirname(require.resolve('react-native-webgpu-dawn/package.json')))" + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE DAWN_DIR + RESULT_VARIABLE DAWN_DIR_RESULT +) +if(NOT DAWN_DIR_RESULT EQUAL 0) + message(FATAL_ERROR "react-native-webgpu: the react-native-webgpu-dawn package was not found. Install dependencies (yarn install) first.") +endif() + +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/releases/download/v1.18.0/googletest-1.18.0.tar.gz + URL_HASH SHA256=6e3191c1455468b3fc35a417fb565c1c5071aee1b7e7f85e30cf48a98d37d8b5 + DOWNLOAD_EXTRACT_TIMESTAMP True +) +set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +add_executable( + cpp-tests + DawnBlobCacheTest.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/rnwgpu/DawnBlobCache.cpp +) +target_include_directories( + cpp-tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/rnwgpu + ${DAWN_DIR}/include +) +target_compile_options(cpp-tests PRIVATE -Wall -Wextra) +target_link_libraries(cpp-tests PRIVATE GTest::gtest_main) + +enable_testing() +include(GoogleTest) +gtest_discover_tests(cpp-tests) diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp new file mode 100644 index 000000000..a594a1761 --- /dev/null +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -0,0 +1,299 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DawnBlobCache.h" + +namespace { + +namespace fs = std::filesystem; +using rnwgpu::DawnBlobCache; +using Bytes = std::vector; + +// The cache as Dawn sees it: the callbacks chained onto a device descriptor. +struct Callbacks { + WGPUDawnLoadCacheDataCallback load; + WGPUDawnStoreCacheDataCallback store; + + // The stored value's size, or 0 for a miss. + size_t size(const Bytes &key) const { + return load(key.size(), key.data(), 0, nullptr, nullptr, nullptr); + } + + // The stored value, or nothing for a miss. + Bytes read(const Bytes &key) const { + Bytes value(size(key)); + if (value.empty()) { + return value; + } + const size_t read = load(key.size(), key.data(), value.size(), value.data(), + nullptr, nullptr); + value.resize(read); + return value; + } + + void write(const Bytes &key, const Bytes &value) const { + store(key.size(), key.data(), value.size(), value.data(), nullptr, nullptr); + } +}; + +// The cache directory is process-wide: every test gets its own and disables +// the cache when it ends. +class DawnBlobCacheTest : public ::testing::Test { +protected: + void SetUp() override { + std::string pattern = + (fs::temp_directory_path() / "dawn-blob-cache-XXXXXX").string(); + ASSERT_NE(mkdtemp(pattern.data()), nullptr); + _root = pattern; + _dir = _root / "cache"; + DawnBlobCache::setDirectory(_dir.string()); + } + + void TearDown() override { + DawnBlobCache::setDirectory(""); + fs::remove_all(_root); + } + + static Callbacks callbacks() { + wgpu::DeviceDescriptor descriptor; + WGPUDawnCacheDeviceDescriptor storage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + DawnBlobCache::chain(descriptor, storage); + return {storage.dawnLoadCacheDataCallbackInfo.callback, + storage.dawnStoreCacheDataCallbackInfo.callback}; + } + + // The cache's entries, leaving out temporary files. + std::vector entries() const { + std::vector paths; + for (const auto &entry : fs::directory_iterator(_dir)) { + if (entry.path().extension() != ".tmp") { + paths.push_back(entry.path()); + } + } + return paths; + } + + static void age(const fs::path &path, time_t seconds) { + const time_t then = time(nullptr) - seconds; + utimbuf times{then, then}; + ASSERT_EQ(utime(path.c_str(), ×), 0); + } + + fs::path _root; + fs::path _dir; +}; + +TEST_F(DawnBlobCacheTest, ChainsItselfInFrontOfTheDescriptorsChain) { + wgpu::DawnTogglesDescriptor toggles; + wgpu::DeviceDescriptor descriptor; + descriptor.nextInChain = &toggles; + WGPUDawnCacheDeviceDescriptor storage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + + DawnBlobCache::chain(descriptor, storage); + + EXPECT_EQ(descriptor.nextInChain, + reinterpret_cast(&storage)); + EXPECT_EQ(storage.chain.sType, WGPUSType_DawnCacheDeviceDescriptor); + EXPECT_EQ(storage.chain.next, + reinterpret_cast(&toggles)); + EXPECT_NE(storage.dawnLoadCacheDataCallbackInfo.callback, nullptr); + EXPECT_NE(storage.dawnStoreCacheDataCallbackInfo.callback, nullptr); +} + +TEST_F(DawnBlobCacheTest, ChainsNothingWithoutADirectory) { + DawnBlobCache::setDirectory(""); + wgpu::DeviceDescriptor descriptor; + WGPUDawnCacheDeviceDescriptor storage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + + DawnBlobCache::chain(descriptor, storage); + + EXPECT_EQ(descriptor.nextInChain, nullptr); +} + +TEST_F(DawnBlobCacheTest, CreatesItsDirectory) { + EXPECT_TRUE(fs::is_directory(_dir)); +} + +TEST_F(DawnBlobCacheTest, MissesAKeyNeverStored) { + EXPECT_EQ(callbacks().size({1, 2, 3}), 0u); +} + +TEST_F(DawnBlobCacheTest, ReturnsTheExactBytesStored) { + const auto cache = callbacks(); + const Bytes key{1, 2, 3}; + const Bytes value{0, 255, 7, 0, 42}; + + cache.write(key, value); + + EXPECT_EQ(cache.size(key), value.size()); + EXPECT_EQ(cache.read(key), value); +} + +TEST_F(DawnBlobCacheTest, KeepsEntriesAcrossASecondSetup) { + callbacks().write({1}, {9, 8, 7}); + + DawnBlobCache::setDirectory(_dir.string()); + + EXPECT_EQ(callbacks().read({1}), (Bytes{9, 8, 7})); +} + +TEST_F(DawnBlobCacheTest, ReplacesAValueStoredAgain) { + const auto cache = callbacks(); + cache.write({1}, {1, 1, 1, 1}); + + cache.write({1}, {2, 2}); + + EXPECT_EQ(cache.read({1}), (Bytes{2, 2})); +} + +TEST_F(DawnBlobCacheTest, LeavesABufferTooSmallForTheValueUntouched) { + const auto cache = callbacks(); + const Bytes key{1}; + cache.write(key, {4, 5, 6, 7}); + Bytes buffer{99, 99}; + + const size_t read = cache.load(key.size(), key.data(), buffer.size(), + buffer.data(), nullptr, nullptr); + + EXPECT_EQ(read, 0u); + EXPECT_EQ(buffer, (Bytes{99, 99})); +} + +TEST_F(DawnBlobCacheTest, MissesATruncatedEntry) { + const auto cache = callbacks(); + cache.write({1}, {4, 5, 6, 7}); + const auto entry = entries().at(0); + + fs::resize_file(entry, fs::file_size(entry) - 1); + + EXPECT_EQ(cache.size({1}), 0u); +} + +// Two keys whose hashes collide share a file; the key stored in the file +// decides which of them it holds. +TEST_F(DawnBlobCacheTest, MissesAnEntryWhoseStoredKeyDiffers) { + const auto cache = callbacks(); + cache.write({1, 2}, {4, 5, 6, 7}); + const auto entry = entries().at(0); + + { + // The key starts after the two 64-bit sizes. + std::fstream file(entry, std::ios::in | std::ios::out | std::ios::binary); + file.seekp(2 * sizeof(uint64_t)); + file.put(static_cast(99)); + } + + EXPECT_EQ(cache.size({1, 2}), 0u); +} + +TEST_F(DawnBlobCacheTest, NeverTearsAValueWrittenFromManyThreads) { + const auto cache = callbacks(); + const Bytes sharedKey{88}; + std::vector threads; + std::vector failures(8, 0); + + for (int thread = 0; thread < 8; thread++) { + threads.emplace_back([&, thread] { + const Bytes ownKey{static_cast(thread), 42}; + const Bytes value(4096, static_cast(thread)); + for (int round = 0; round < 64; round++) { + cache.write(ownKey, value); + cache.write(sharedKey, value); + if (cache.read(ownKey) != value) { + failures[thread]++; + } + // Another thread may have replaced it, but never with a mix of two. + const Bytes shared = cache.read(sharedKey); + if (shared.size() != value.size() || + shared != Bytes(shared.size(), shared[0])) { + failures[thread]++; + } + } + }); + } + for (auto &thread : threads) { + thread.join(); + } + + EXPECT_EQ(failures, std::vector(8, 0)); +} + +TEST_F(DawnBlobCacheTest, RecreatesItsDirectoryIfItIsCleared) { + const auto cache = callbacks(); + fs::remove_all(_dir); + + cache.write({1}, {2, 3}); + + EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); +} + +TEST_F(DawnBlobCacheTest, StaysQuietWhenItsDirectoryCannotBeMade) { + // A directory can't be made under a regular file. + const auto blocker = _root / "file"; + std::ofstream(blocker).put('x'); + DawnBlobCache::setDirectory((blocker / "cache").string()); + const auto cache = callbacks(); + + cache.write({1}, {2, 3}); + + EXPECT_EQ(cache.size({1}), 0u); +} + +TEST_F(DawnBlobCacheTest, RemovesAWriteAbandonedByAKilledProcess) { + const auto abandoned = _dir / "0123456789abcdef.4242.0.tmp"; + std::ofstream(abandoned).put('x'); + age(abandoned, 120); + + DawnBlobCache::setDirectory(_dir.string()); + + EXPECT_FALSE(fs::exists(abandoned)); +} + +TEST_F(DawnBlobCacheTest, LeavesAWriteAnotherProcessMayStillFinish) { + const auto writing = _dir / "0123456789abcdef.4242.0.tmp"; + std::ofstream(writing).put('x'); + + DawnBlobCache::setDirectory(_dir.string()); + + EXPECT_TRUE(fs::exists(writing)); +} + +TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { + callbacks().write({1}, {2, 3}); + + DawnBlobCache::setDirectory(_dir.string()); + + EXPECT_EQ(entries().size(), 1u); +} + +// The limit is checked when the cache is set up, not on every store. +TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { + const auto cache = callbacks(); + cache.write({1}, {2, 3}); + const auto large = _dir / "large"; + std::ofstream(large).put('x'); + fs::resize_file(large, 65 * 1024 * 1024); + + cache.write({2}, {4, 5}); + EXPECT_EQ(entries().size(), 3u); + + DawnBlobCache::setDirectory(_dir.string()); + EXPECT_TRUE(entries().empty()); + EXPECT_EQ(cache.size({1}), 0u); +} + +} // namespace From 9540249744834ff9837f6d70ac2d5ff18c4f4ab3 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 13:02:40 +0100 Subject: [PATCH 04/20] =?UTF-8?q?chore(=F0=9F=90=99):=20use=20POSIX=20call?= =?UTF-8?q?s=20in=20the=20blob=20cache=20test,=20as=20cpplint=20asks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 114 ++++++++++++------ 1 file changed, 80 insertions(+), 34 deletions(-) diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index a594a1761..f6a907cbb 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -1,12 +1,13 @@ #include +#include #include +#include #include #include #include #include -#include #include #include #include @@ -16,10 +17,54 @@ namespace { -namespace fs = std::filesystem; using rnwgpu::DawnBlobCache; using Bytes = std::vector; +bool exists(const std::string &path) { + struct stat info{}; + return stat(path.c_str(), &info) == 0; +} + +bool isDirectory(const std::string &path) { + struct stat info{}; + return stat(path.c_str(), &info) == 0 && S_ISDIR(info.st_mode); +} + +off_t fileSize(const std::string &path) { + struct stat info{}; + return stat(path.c_str(), &info) == 0 ? info.st_size : -1; +} + +void writeFile(const std::string &path, const std::string &contents) { + std::ofstream(path, std::ios::binary) << contents; +} + +// The names in a directory, leaving out "." and "..". +std::vector list(const std::string &dir) { + std::vector names; + if (DIR *listing = opendir(dir.c_str())) { + while (dirent *entry = readdir(listing)) { + const std::string name = entry->d_name; + if (name != "." && name != "..") { + names.push_back(name); + } + } + closedir(listing); + } + return names; +} + +void removeTree(const std::string &path) { + if (isDirectory(path)) { + for (const auto &name : list(path)) { + removeTree(path + "/" + name); + } + rmdir(path.c_str()); + } else { + unlink(path.c_str()); + } +} + // The cache as Dawn sees it: the callbacks chained onto a device descriptor. struct Callbacks { WGPUDawnLoadCacheDataCallback load; @@ -52,17 +97,18 @@ struct Callbacks { class DawnBlobCacheTest : public ::testing::Test { protected: void SetUp() override { + const char *tmp = getenv("TMPDIR"); std::string pattern = - (fs::temp_directory_path() / "dawn-blob-cache-XXXXXX").string(); + std::string(tmp != nullptr ? tmp : "/tmp") + "/dawn-blob-cache-XXXXXX"; ASSERT_NE(mkdtemp(pattern.data()), nullptr); _root = pattern; - _dir = _root / "cache"; - DawnBlobCache::setDirectory(_dir.string()); + _dir = _root + "/cache"; + DawnBlobCache::setDirectory(_dir); } void TearDown() override { DawnBlobCache::setDirectory(""); - fs::remove_all(_root); + removeTree(_root); } static Callbacks callbacks() { @@ -75,24 +121,24 @@ class DawnBlobCacheTest : public ::testing::Test { } // The cache's entries, leaving out temporary files. - std::vector entries() const { - std::vector paths; - for (const auto &entry : fs::directory_iterator(_dir)) { - if (entry.path().extension() != ".tmp") { - paths.push_back(entry.path()); + std::vector entries() const { + std::vector paths; + for (const auto &name : list(_dir)) { + if (name.size() < 4 || name.compare(name.size() - 4, 4, ".tmp") != 0) { + paths.push_back(_dir + "/" + name); } } return paths; } - static void age(const fs::path &path, time_t seconds) { + static void age(const std::string &path, time_t seconds) { const time_t then = time(nullptr) - seconds; utimbuf times{then, then}; ASSERT_EQ(utime(path.c_str(), ×), 0); } - fs::path _root; - fs::path _dir; + std::string _root; + std::string _dir; }; TEST_F(DawnBlobCacheTest, ChainsItselfInFrontOfTheDescriptorsChain) { @@ -125,7 +171,7 @@ TEST_F(DawnBlobCacheTest, ChainsNothingWithoutADirectory) { } TEST_F(DawnBlobCacheTest, CreatesItsDirectory) { - EXPECT_TRUE(fs::is_directory(_dir)); + EXPECT_TRUE(isDirectory(_dir)); } TEST_F(DawnBlobCacheTest, MissesAKeyNeverStored) { @@ -146,7 +192,7 @@ TEST_F(DawnBlobCacheTest, ReturnsTheExactBytesStored) { TEST_F(DawnBlobCacheTest, KeepsEntriesAcrossASecondSetup) { callbacks().write({1}, {9, 8, 7}); - DawnBlobCache::setDirectory(_dir.string()); + DawnBlobCache::setDirectory(_dir); EXPECT_EQ(callbacks().read({1}), (Bytes{9, 8, 7})); } @@ -178,7 +224,7 @@ TEST_F(DawnBlobCacheTest, MissesATruncatedEntry) { cache.write({1}, {4, 5, 6, 7}); const auto entry = entries().at(0); - fs::resize_file(entry, fs::file_size(entry) - 1); + ASSERT_EQ(truncate(entry.c_str(), fileSize(entry) - 1), 0); EXPECT_EQ(cache.size({1}), 0u); } @@ -234,7 +280,7 @@ TEST_F(DawnBlobCacheTest, NeverTearsAValueWrittenFromManyThreads) { TEST_F(DawnBlobCacheTest, RecreatesItsDirectoryIfItIsCleared) { const auto cache = callbacks(); - fs::remove_all(_dir); + removeTree(_dir); cache.write({1}, {2, 3}); @@ -243,9 +289,9 @@ TEST_F(DawnBlobCacheTest, RecreatesItsDirectoryIfItIsCleared) { TEST_F(DawnBlobCacheTest, StaysQuietWhenItsDirectoryCannotBeMade) { // A directory can't be made under a regular file. - const auto blocker = _root / "file"; - std::ofstream(blocker).put('x'); - DawnBlobCache::setDirectory((blocker / "cache").string()); + const auto blocker = _root + "/file"; + writeFile(blocker, "x"); + DawnBlobCache::setDirectory(blocker + "/cache"); const auto cache = callbacks(); cache.write({1}, {2, 3}); @@ -254,28 +300,28 @@ TEST_F(DawnBlobCacheTest, StaysQuietWhenItsDirectoryCannotBeMade) { } TEST_F(DawnBlobCacheTest, RemovesAWriteAbandonedByAKilledProcess) { - const auto abandoned = _dir / "0123456789abcdef.4242.0.tmp"; - std::ofstream(abandoned).put('x'); + const auto abandoned = _dir + "/0123456789abcdef.4242.0.tmp"; + writeFile(abandoned, "x"); age(abandoned, 120); - DawnBlobCache::setDirectory(_dir.string()); + DawnBlobCache::setDirectory(_dir); - EXPECT_FALSE(fs::exists(abandoned)); + EXPECT_FALSE(exists(abandoned)); } TEST_F(DawnBlobCacheTest, LeavesAWriteAnotherProcessMayStillFinish) { - const auto writing = _dir / "0123456789abcdef.4242.0.tmp"; - std::ofstream(writing).put('x'); + const auto writing = _dir + "/0123456789abcdef.4242.0.tmp"; + writeFile(writing, "x"); - DawnBlobCache::setDirectory(_dir.string()); + DawnBlobCache::setDirectory(_dir); - EXPECT_TRUE(fs::exists(writing)); + EXPECT_TRUE(exists(writing)); } TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { callbacks().write({1}, {2, 3}); - DawnBlobCache::setDirectory(_dir.string()); + DawnBlobCache::setDirectory(_dir); EXPECT_EQ(entries().size(), 1u); } @@ -284,14 +330,14 @@ TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { const auto cache = callbacks(); cache.write({1}, {2, 3}); - const auto large = _dir / "large"; - std::ofstream(large).put('x'); - fs::resize_file(large, 65 * 1024 * 1024); + const auto large = _dir + "/large"; + writeFile(large, "x"); + ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); cache.write({2}, {4, 5}); EXPECT_EQ(entries().size(), 3u); - DawnBlobCache::setDirectory(_dir.string()); + DawnBlobCache::setDirectory(_dir); EXPECT_TRUE(entries().empty()); EXPECT_EQ(cache.size({1}), 0u); } From e0321ca0ff58f93e710e836d43c9f6df637d118e Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 13:02:47 +0100 Subject: [PATCH 05/20] =?UTF-8?q?chore(=F0=9F=93=9A):=20say=20what=20the?= =?UTF-8?q?=20pipeline=20cache=20keeps=20on=20each=20backend,=20and=20when?= =?UTF-8?q?=20its=20size=20is=20checked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/content/docs/getting-started/native-api.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index fd50d02e2..4c67ba449 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -93,7 +93,9 @@ See [Vision Camera](/docs/integrations/vision-camera) for an end-to-end example ## Pipeline cache -Dawn keeps the render and compute pipelines it compiles, and the shaders it translates for them, on disk in the app's cache directory, so a relaunch loads them instead of compiling them again. Compiling is slowest on Android, where each pipeline can take tens to hundreds of milliseconds on Vulkan drivers; iOS also keeps its own Metal cache. Every device `requestDevice()` creates uses the cache. The cache is cleared when it grows past 64 MB, and the system may clear it as it does any app 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: building a pipeline there can take tens to hundreds of milliseconds. On Metal (iOS) it is the shaders Dawn translates to MSL; Metal still builds the pipelines, with the help of its own system cache. + +The cache's size is checked when the app starts: past 64 MB, it is cleared and refills as the app builds pipelines again. The system may also clear it, as it does any app cache. To opt a device out, disable it with a Dawn toggle: From 19868baa20223fca4d83790f7f3379ad567753bd Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 13:39:58 +0100 Subject: [PATCH 06/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20recreate=20the?= =?UTF-8?q?=20cache=20directory's=20parents,=20and=20keep=20the=20cache=20?= =?UTF-8?q?off=20when=20the=20JVM=20can't=20copy=20its=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgpu/android/cpp/cpp-adapter.cpp | 9 ++++-- packages/webgpu/apple/WebGPUModule.mm | 4 --- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 29 ++++++++++++++------ packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 8 +++--- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index e57774cd0..3220690c9 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -35,9 +35,12 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative( jobject blobModule, jstring cacheDirectory) { auto runtime = reinterpret_cast(jsRuntime); if (cacheDirectory != nullptr) { - const char *path = env->GetStringUTFChars(cacheDirectory, nullptr); - rnwgpu::DawnBlobCache::setDirectory(path); - env->ReleaseStringUTFChars(cacheDirectory, path); + // Null only when the JVM could not allocate the copy; the cache then + // stays off. + if (const char *path = env->GetStringUTFChars(cacheDirectory, nullptr)) { + rnwgpu::DawnBlobCache::setDirectory(path); + env->ReleaseStringUTFChars(cacheDirectory, path); + } } jobject globalBlobModule = env->NewGlobalRef(blobModule); jobject globalModule = env->NewGlobalRef(thiz); diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index 9bc340d76..059ff9eee 100644 --- a/packages/webgpu/apple/WebGPUModule.mm +++ b/packages/webgpu/apple/WebGPUModule.mm @@ -106,10 +106,6 @@ - (void)invalidate { if (caches != nil) { if (bundle != nil) { caches = [caches stringByAppendingPathComponent:bundle]; - [NSFileManager.defaultManager createDirectoryAtPath:caches - withIntermediateDirectories:YES - attributes:nil - error:nil]; } rnwgpu::DawnBlobCache::setDirectory( [caches stringByAppendingPathComponent:@"webgpu-dawn"].UTF8String); diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index d52bff091..21c6bb217 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -26,9 +26,11 @@ constexpr off_t kMaxCacheBytes = 64 * 1024 * 1024; // cache in the process. constexpr const char *kIsolationKey = "react-native-webgpu"; -// A temporary file this old belongs to a write that will never finish (its -// process was killed); a younger one may still be being written by another -// process using the same cache, so tidy() leaves it. +// tidy() takes a temporary file this old for a write that will never finish +// (its process was killed). A younger one may still be being written by +// another process using the same cache, so it is left. The age is a best +// guess: a writer suspended for longer than this loses its entry when tidy() +// removes its file (its rename then fails), which only costs a cache miss. constexpr time_t kAbandonedWriteSeconds = 60; std::mutex &directoryMutex() { @@ -46,6 +48,15 @@ std::string directory() { return directorySlot(); } +// Creates `dir` and any of its parents that are missing. +void makeDirectories(const std::string &dir) { + for (size_t slash = dir.find('/', 1); slash != std::string::npos; + slash = dir.find('/', slash + 1)) { + mkdir(dir.substr(0, slash).c_str(), 0700); + } + mkdir(dir.c_str(), 0700); +} + // One file per entry, named by a 64-bit FNV-1a hash of its key. std::string entryPath(const std::string &dir, size_t keySize, const uint8_t *key) { @@ -113,8 +124,9 @@ void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, std::to_string(writes.fetch_add(1)) + ".tmp"; FILE *file = fopen(temporary.c_str(), "wb"); if (file == nullptr) { - // The system or the app may have cleared the cache directory itself. - mkdir(dir.c_str(), 0700); + // The system or the app may have cleared the cache directory, or its + // parent, during the session. + makeDirectories(dir); file = fopen(temporary.c_str(), "wb"); } if (file == nullptr) { @@ -138,8 +150,9 @@ bool endsWith(const std::string &name, const char *suffix) { // Removes the temporary files of writes a killed process left unfinished, and // clears the cache if it has outgrown kMaxCacheBytes. Runs when the cache is -// set up, so the limit is checked once per launch rather than on every store: -// a session can take the cache past it, and the next launch clears it. +// set up (the module installing), so the limit is checked then rather than on +// every store: a session can take the cache past it, and the next setup +// clears it. void tidy(const std::string &dir) { DIR *listing = opendir(dir.c_str()); if (listing == nullptr) { @@ -179,7 +192,7 @@ void tidy(const std::string &dir) { void DawnBlobCache::setDirectory(const std::string &dir) { if (!dir.empty()) { - mkdir(dir.c_str(), 0700); + makeDirectories(dir); tidy(dir); } std::lock_guard lock(directoryMutex()); diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h index c3a92f3d6..6a91e47ab 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -17,10 +17,10 @@ namespace rnwgpu { // device requested with the `disable_blob_cache` Dawn toggle does not use it. class DawnBlobCache { public: - // The directory to keep the blobs in, created if missing. Leftovers of - // interrupted writes are removed, and a cache that has outgrown its limit - // is cleared (the limit is checked here, once per launch). An empty path - // disables the cache. + // The directory to keep the blobs in, created (with its parents) if + // missing. Each platform's module calls this when it installs, which is + // also when the cache is tidied: abandoned writes are removed, and a cache + // that has outgrown its limit is cleared. An empty path disables the cache. static void setDirectory(const std::string &directory); // Chains the cache onto `descriptor`, in front of whatever it already From 331710be49e97e724baf02e6a5497c1e32e765bf Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 13:39:58 +0100 Subject: [PATCH 07/20] =?UTF-8?q?chore(=F0=9F=90=99):=20test=20the=20cache?= =?UTF-8?q?'s=20parent=20directories,=20and=20require=20the=20CMake=20its?= =?UTF-8?q?=20options=20need?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgpu/tests/cpp/CMakeLists.txt | 2 +- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/webgpu/tests/cpp/CMakeLists.txt b/packages/webgpu/tests/cpp/CMakeLists.txt index dcd3ce391..55b423784 100644 --- a/packages/webgpu/tests/cpp/CMakeLists.txt +++ b/packages/webgpu/tests/cpp/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.22) +cmake_minimum_required(VERSION 3.24) project(react_native_webgpu_cpp_tests LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index f6a907cbb..6ec1a0a2d 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -287,6 +287,27 @@ TEST_F(DawnBlobCacheTest, RecreatesItsDirectoryIfItIsCleared) { EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); } +// On Apple platforms the cache sits inside a folder named after the app, which +// a cache clear can remove too. +TEST_F(DawnBlobCacheTest, RecreatesItsParentsIfTheyAreCleared) { + const auto parent = _root + "/app"; + DawnBlobCache::setDirectory(parent + "/cache"); + const auto cache = callbacks(); + removeTree(parent); + + cache.write({1}, {2, 3}); + + EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); +} + +TEST_F(DawnBlobCacheTest, CreatesMissingParentsWhenSetUp) { + const auto nested = _root + "/a/b/cache"; + + DawnBlobCache::setDirectory(nested); + + EXPECT_TRUE(isDirectory(nested)); +} + TEST_F(DawnBlobCacheTest, StaysQuietWhenItsDirectoryCannotBeMade) { // A directory can't be made under a regular file. const auto blocker = _root + "/file"; From 1685b9c5e3ed0725be22b3cf3bdd4bb3aeb68d2e Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 14:01:11 +0100 Subject: [PATCH 08/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20clear=20only?= =?UTF-8?q?=20the=20cache's=20own=20files,=20stay=20off=20without=20a=20us?= =?UTF-8?q?able=20directory,=20and=20let=20a=20failed=20JNI=20copy=20throw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgpu/android/cpp/cpp-adapter.cpp | 12 +++--- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 39 +++++++++++++++----- packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 18 ++++++--- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 3220690c9..9aba4f426 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -35,12 +35,14 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative( jobject blobModule, jstring cacheDirectory) { auto runtime = reinterpret_cast(jsRuntime); if (cacheDirectory != nullptr) { - // Null only when the JVM could not allocate the copy; the cache then - // stays off. - if (const char *path = env->GetStringUTFChars(cacheDirectory, nullptr)) { - rnwgpu::DawnBlobCache::setDirectory(path); - env->ReleaseStringUTFChars(cacheDirectory, path); + 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); diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index 21c6bb217..97c5aa682 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -22,10 +22,6 @@ namespace { // compiles next. constexpr off_t kMaxCacheBytes = 64 * 1024 * 1024; -// Keeps this library's entries apart from any other user of the same Dawn -// cache in the process. -constexpr const char *kIsolationKey = "react-native-webgpu"; - // tidy() takes a temporary file this old for a write that will never finish // (its process was killed). A younger one may still be being written by // another process using the same cache, so it is left. The age is a best @@ -148,6 +144,21 @@ bool endsWith(const std::string &name, const char *suffix) { name.compare(name.size() - length, length, suffix) == 0; } +// Whether a file name starts like one of the cache's own: an entry's 16 +// lowercase hex digits, alone or followed by a dot (a temporary file). +bool isOwnFile(const std::string &name) { + if (name.size() < 16 || (name.size() > 16 && name[16] != '.')) { + return false; + } + for (size_t i = 0; i < 16; i++) { + const char c = name[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + return false; + } + } + return true; +} + // Removes the temporary files of writes a killed process left unfinished, and // clears the cache if it has outgrown kMaxCacheBytes. Runs when the cache is // set up (the module installing), so the limit is checked then rather than on @@ -163,7 +174,8 @@ void tidy(const std::string &dir) { const time_t now = time(nullptr); while (dirent *entry = readdir(listing)) { const std::string name = entry->d_name; - if (name == "." || name == "..") { + // Anything else in the directory is not the cache's to delete. + if (!isOwnFile(name)) { continue; } const std::string path = dir + "/" + name; @@ -191,12 +203,20 @@ void tidy(const std::string &dir) { } // namespace void DawnBlobCache::setDirectory(const std::string &dir) { - if (!dir.empty()) { - makeDirectories(dir); - tidy(dir); + std::string usable = dir; + if (!usable.empty()) { + makeDirectories(usable); + struct stat info{}; + if (stat(usable.c_str(), &info) == 0 && S_ISDIR(info.st_mode)) { + tidy(usable); + } else { + // Nowhere to keep blobs: leave the cache off rather than fail every + // load and store. + usable.clear(); + } } std::lock_guard lock(directoryMutex()); - directorySlot() = dir; + directorySlot() = usable; } void DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, @@ -204,7 +224,6 @@ void DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, if (directory().empty()) { return; } - storage.isolationKey = {kIsolationKey, WGPU_STRLEN}; storage.dawnLoadCacheDataCallbackInfo.callback = loadEntry; storage.dawnStoreCacheDataCallbackInfo.callback = storeEntry; storage.chain.next = reinterpret_cast( diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h index 6a91e47ab..98216c314 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -6,11 +6,15 @@ namespace rnwgpu { -// Dawn's blob cache, kept on disk so the pipelines a device compiles (and the -// shaders Dawn translates for them) survive a relaunch. Dawn only caches when -// the embedder gives it somewhere to put the blobs (Chrome hands it its disk -// cache); without one, every launch compiles every pipeline again, which on -// Android's Vulkan drivers costs tens to hundreds of milliseconds each. +// Dawn's blob cache, kept on disk so what a device produces while building +// shaders and pipelines (Vulkan pipeline data, translated MSL) survives a +// relaunch. Dawn only reuses those blobs across launches when the embedder +// gives it storage: load and store callbacks on the device descriptor, or a +// caching interface on the platform the instance was created with (how Chrome +// does it). The instance can be React Native Skia's, so the callbacks go on +// each device. Without them every launch builds every pipeline from scratch, +// which on Android's Vulkan drivers costs tens to hundreds of milliseconds +// each. // // Each platform's module points the cache at the app's cache directory when it // installs, and requestDevice() chains it onto every device it creates. A @@ -20,7 +24,9 @@ class DawnBlobCache { // The directory to keep the blobs in, created (with its parents) if // missing. Each platform's module calls this when it installs, which is // also when the cache is tidied: abandoned writes are removed, and a cache - // that has outgrown its limit is cleared. An empty path disables the cache. + // that has outgrown its limit is cleared. Only the cache's own files are + // removed. An empty path, or one where no directory can be made, leaves the + // cache off. static void setDirectory(const std::string &directory); // Chains the cache onto `descriptor`, in front of whatever it already From 2c8a16788469ffd756da2002e6f6487069a1f9d5 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sat, 24 Oct 2026 14:01:11 +0100 Subject: [PATCH 09/20] =?UTF-8?q?chore(=F0=9F=90=99):=20test=20that=20the?= =?UTF-8?q?=20cache=20leaves=20other=20files=20alone=20and=20stays=20off?= =?UTF-8?q?=20without=20a=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index 6ec1a0a2d..648eea737 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -229,8 +229,8 @@ TEST_F(DawnBlobCacheTest, MissesATruncatedEntry) { EXPECT_EQ(cache.size({1}), 0u); } -// Two keys whose hashes collide share a file; the key stored in the file -// decides which of them it holds. +// Two keys whose hashes collide would share a file: the key stored in it +// decides which of them it holds, so a stored key that differs reads as a miss. TEST_F(DawnBlobCacheTest, MissesAnEntryWhoseStoredKeyDiffers) { const auto cache = callbacks(); cache.write({1, 2}, {4, 5, 6, 7}); @@ -308,16 +308,18 @@ TEST_F(DawnBlobCacheTest, CreatesMissingParentsWhenSetUp) { EXPECT_TRUE(isDirectory(nested)); } -TEST_F(DawnBlobCacheTest, StaysQuietWhenItsDirectoryCannotBeMade) { +TEST_F(DawnBlobCacheTest, StaysOffWhereNoDirectoryCanBeMade) { // A directory can't be made under a regular file. const auto blocker = _root + "/file"; writeFile(blocker, "x"); - DawnBlobCache::setDirectory(blocker + "/cache"); - const auto cache = callbacks(); - cache.write({1}, {2, 3}); + DawnBlobCache::setDirectory(blocker + "/cache"); - EXPECT_EQ(cache.size({1}), 0u); + wgpu::DeviceDescriptor descriptor; + WGPUDawnCacheDeviceDescriptor storage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + DawnBlobCache::chain(descriptor, storage); + EXPECT_EQ(descriptor.nextInChain, nullptr); } TEST_F(DawnBlobCacheTest, RemovesAWriteAbandonedByAKilledProcess) { @@ -351,7 +353,8 @@ TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { const auto cache = callbacks(); cache.write({1}, {2, 3}); - const auto large = _dir + "/large"; + // An entry name, so it counts towards the limit. + const auto large = _dir + "/00000000000000ff"; writeFile(large, "x"); ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); @@ -363,4 +366,23 @@ TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { EXPECT_EQ(cache.size({1}), 0u); } +// A wrong path must never turn clearing the cache into deleting someone's +// files. +TEST_F(DawnBlobCacheTest, LeavesFilesThatAreNotTheCachesAlone) { + const auto other = _dir + "/notes.txt"; + const auto similar = _dir + "/0123456789abcdefg"; + const auto large = _dir + "/00000000000000ff"; + writeFile(other, "x"); + writeFile(similar, "x"); + writeFile(large, "x"); + ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); + ASSERT_EQ(truncate(other.c_str(), 65 * 1024 * 1024), 0); + + DawnBlobCache::setDirectory(_dir); + + EXPECT_FALSE(exists(large)); + EXPECT_TRUE(exists(other)); + EXPECT_TRUE(exists(similar)); +} + } // namespace From 55997fe81f67b28d46fcafe2d11b9bcf60d043a8 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sun, 25 Oct 2026 22:35:33 +0000 Subject: [PATCH 10/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20trim=20the=20o?= =?UTF-8?q?ldest=20cache=20entries=20to=20fit=20the=20limit,=20and=20check?= =?UTF-8?q?=20it=20as=20a=20session=20stores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 54 ++++++++++++++++---- packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 8 +-- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index 97c5aa682..33e24d234 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -18,10 +19,13 @@ namespace rnwgpu { namespace { // Entries only accumulate as an app's shaders change from release to release, -// so past this size the cache is cleared and refills with what the app -// compiles next. +// so past this size the oldest are removed until the cache fits again. constexpr off_t kMaxCacheBytes = 64 * 1024 * 1024; +// A session checks the limit again each time it has stored this much, so a +// long-running process cannot take the cache far past it. +constexpr uint64_t kTidyAfterBytes = kMaxCacheBytes / 8; + // tidy() takes a temporary file this old for a write that will never finish // (its process was killed). A younger one may still be being written by // another process using the same cache, so it is left. The age is a best @@ -44,6 +48,8 @@ std::string directory() { return directorySlot(); } +void tidy(const std::string &dir); + // Creates `dir` and any of its parents that are missing. void makeDirectories(const std::string &dir) { for (size_t slash = dir.find('/', 1); slash != std::string::npos; @@ -135,6 +141,13 @@ void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, written = fclose(file) == 0 && written; if (!written || rename(temporary.c_str(), path.c_str()) != 0) { remove(temporary.c_str()); + return; + } + static std::atomic stored{0}; + if (stored.fetch_add(keySize + valueSize) + keySize + valueSize >= + kTidyAfterBytes) { + stored = 0; + tidy(dir); } } @@ -160,16 +173,26 @@ bool isOwnFile(const std::string &name) { } // Removes the temporary files of writes a killed process left unfinished, and -// clears the cache if it has outgrown kMaxCacheBytes. Runs when the cache is -// set up (the module installing), so the limit is checked then rather than on -// every store: a session can take the cache past it, and the next setup -// clears it. +// the oldest entries while the cache is over kMaxCacheBytes. Runs when the +// cache is set up and after each kTidyAfterBytes stored. "Oldest" is by write +// time: a load does not refresh an entry. void tidy(const std::string &dir) { + // Another thread tidying the same directory is enough. + static std::mutex tidying; + std::unique_lock lock(tidying, std::try_to_lock); + if (!lock.owns_lock()) { + return; + } DIR *listing = opendir(dir.c_str()); if (listing == nullptr) { return; } - std::vector entries; + struct Entry { + std::string path; + time_t modified; + off_t size; + }; + std::vector entries; off_t total = 0; const time_t now = time(nullptr); while (dirent *entry = readdir(listing)) { @@ -190,12 +213,21 @@ void tidy(const std::string &dir) { continue; } total += info.st_size; - entries.push_back(path); + entries.push_back({path, info.st_mtime, info.st_size}); } closedir(listing); - if (total > kMaxCacheBytes) { - for (const auto &path : entries) { - remove(path.c_str()); + if (total <= kMaxCacheBytes) { + return; + } + std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { + return a.modified != b.modified ? a.modified < b.modified : a.path < b.path; + }); + for (const auto &entry : entries) { + if (total <= kMaxCacheBytes) { + break; + } + if (remove(entry.path.c_str()) == 0) { + total -= entry.size; } } } diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h index 98216c314..ab419714a 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -23,10 +23,10 @@ class DawnBlobCache { public: // The directory to keep the blobs in, created (with its parents) if // missing. Each platform's module calls this when it installs, which is - // also when the cache is tidied: abandoned writes are removed, and a cache - // that has outgrown its limit is cleared. Only the cache's own files are - // removed. An empty path, or one where no directory can be made, leaves the - // cache off. + // also when the cache is tidied: abandoned writes are removed, and the + // oldest entries with them while the cache is over its limit (it is checked + // again as a session stores). Only the cache's own files are removed. An + // empty path, or one where no directory can be made, leaves the cache off. static void setDirectory(const std::string &directory); // Chains the cache onto `descriptor`, in front of whatever it already From 23e8baef8db3f417818c3e7f098ebab40070e2ff Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Sun, 25 Oct 2026 22:35:33 +0000 Subject: [PATCH 11/20] =?UTF-8?q?chore(=F0=9F=90=99):=20test=20that=20the?= =?UTF-8?q?=20cache=20is=20trimmed=20rather=20than=20cleared,=20at=20setup?= =?UTF-8?q?=20and=20while=20storing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index 648eea737..de9de2131 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -349,21 +349,34 @@ TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { EXPECT_EQ(entries().size(), 1u); } -// The limit is checked when the cache is set up, not on every store. -TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { +// Only as much as the limit needs: the oldest entries go, the rest stay. +TEST_F(DawnBlobCacheTest, TrimsACacheThatOutgrewItsLimitAtTheNextSetup) { const auto cache = callbacks(); cache.write({1}, {2, 3}); // An entry name, so it counts towards the limit. const auto large = _dir + "/00000000000000ff"; writeFile(large, "x"); ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); - - cache.write({2}, {4, 5}); - EXPECT_EQ(entries().size(), 3u); + age(large, 3600); DawnBlobCache::setDirectory(_dir); - EXPECT_TRUE(entries().empty()); - EXPECT_EQ(cache.size({1}), 0u); + + EXPECT_FALSE(exists(large)); + EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); +} + +// A long-running process is held to the limit too. +TEST_F(DawnBlobCacheTest, TrimsTheOldestEntriesAsASessionStoresMore) { + const auto cache = callbacks(); + const auto large = _dir + "/00000000000000ff"; + writeFile(large, "x"); + ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); + age(large, 3600); + + cache.write({1}, Bytes(9 * 1024 * 1024, 7)); + + EXPECT_FALSE(exists(large)); + EXPECT_EQ(cache.size({1}), 9u * 1024 * 1024); } // A wrong path must never turn clearing the cache into deleting someone's From a9878382adaee420a984701bd2cbd6ad2b4ada54 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Thu, 29 Oct 2026 19:30:41 +0000 Subject: [PATCH 12/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20keep=20one=20e?= =?UTF-8?q?ntry=20from=20taking=20the=20cache,=20and=20count=20replacement?= =?UTF-8?q?s=20as=20no=20growth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry larger than half the limit is not stored, so one blob cannot take more than half the cache from the others. A replacement counts only the bytes it adds towards the next tidy, and the counter is reset with a compare-exchange so growth that lands during a reset is kept. Tidy checks the inode as well as the time and size before removing a file, so it does not remove one a store has just replaced. The statics a flush on another thread may still use at exit are never destroyed, and chain() says whether it chained the cache. --- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 79 +++++++++++++++---- packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 26 ++++-- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 44 +++++++++++ 3 files changed, 124 insertions(+), 25 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index 33e24d234..610e8b4bf 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -19,11 +19,23 @@ namespace rnwgpu { namespace { // Entries only accumulate as an app's shaders change from release to release, -// so past this size the oldest are removed until the cache fits again. +// so past this size the oldest are removed until the cache fits again. The +// limit is a target, not a guarantee: stores that overlap tidying, or other +// processes using the directory, can leave it a little over until the next +// pass. constexpr off_t kMaxCacheBytes = 64 * 1024 * 1024; -// A session checks the limit again each time it has stored this much, so a -// long-running process cannot take the cache far past it. +// An entry this large is not stored, so that one entry cannot take more than +// half the cache from all the others. The entry already on disk under its key, +// if any, is left as it is, and a blob that has outgrown this stops being +// updated. +constexpr uint64_t kMaxEntryBytes = kMaxCacheBytes / 2; + +// A session checks the limit again each time the cache has grown by about this +// much through its stores (replacing an entry counts only what it adds), so a +// long-running process cannot take the cache far past it. The count is +// approximate: stores that overlap are measured against the file as it was +// before either replaced it. constexpr uint64_t kTidyAfterBytes = kMaxCacheBytes / 8; // tidy() takes a temporary file this old for a write that will never finish @@ -33,14 +45,16 @@ constexpr uint64_t kTidyAfterBytes = kMaxCacheBytes / 8; // removes its file (its rename then fails), which only costs a cache miss. constexpr time_t kAbandonedWriteSeconds = 60; +// Never destroyed: a pipeline cache flush on another thread can still be +// storing when the process starts to exit. std::mutex &directoryMutex() { - static std::mutex mutex; - return mutex; + static std::mutex *const mutex = new std::mutex; + return *mutex; } std::string &directorySlot() { - static std::string directory; - return directory; + static std::string *const directory = new std::string; + return *directory; } std::string directory() { @@ -120,6 +134,10 @@ void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, if (dir.empty()) { return; } + const uint64_t entryBytes = 2 * sizeof(uint64_t) + keySize + valueSize; + if (entryBytes > kMaxEntryBytes) { + return; + } static std::atomic writes{0}; const std::string path = entryPath(dir, keySize, key); const std::string temporary = path + "." + std::to_string(getpid()) + "." + @@ -139,15 +157,30 @@ void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, fwrite(key, 1, keySize, file) == keySize && fwrite(value, 1, valueSize, file) == valueSize; written = fclose(file) == 0 && written; + struct stat replaced{}; + const uint64_t previousBytes = stat(path.c_str(), &replaced) == 0 + ? static_cast(replaced.st_size) + : 0; if (!written || rename(temporary.c_str(), path.c_str()) != 0) { remove(temporary.c_str()); return; } - static std::atomic stored{0}; - if (stored.fetch_add(keySize + valueSize) + keySize + valueSize >= - kTidyAfterBytes) { - stored = 0; - tidy(dir); + if (entryBytes <= previousBytes) { + return; + } + static std::atomic grown{0}; + const uint64_t growth = entryBytes - previousBytes; + // One thread takes the count when it is over the limit, and resets it only + // while it still is: growth that lands after another thread has taken the + // count stays in it for the next pass. + if (grown.fetch_add(growth) + growth >= kTidyAfterBytes) { + uint64_t seen = grown.load(); + while (seen >= kTidyAfterBytes) { + if (grown.compare_exchange_weak(seen, 0)) { + tidy(dir); + break; + } + } } } @@ -178,8 +211,8 @@ bool isOwnFile(const std::string &name) { // time: a load does not refresh an entry. void tidy(const std::string &dir) { // Another thread tidying the same directory is enough. - static std::mutex tidying; - std::unique_lock lock(tidying, std::try_to_lock); + static std::mutex *const tidying = new std::mutex; + std::unique_lock lock(*tidying, std::try_to_lock); if (!lock.owns_lock()) { return; } @@ -191,6 +224,7 @@ void tidy(const std::string &dir) { std::string path; time_t modified; off_t size; + ino_t inode; }; std::vector entries; off_t total = 0; @@ -213,7 +247,7 @@ void tidy(const std::string &dir) { continue; } total += info.st_size; - entries.push_back({path, info.st_mtime, info.st_size}); + entries.push_back({path, info.st_mtime, info.st_size, info.st_ino}); } closedir(listing); if (total <= kMaxCacheBytes) { @@ -226,6 +260,16 @@ void tidy(const std::string &dir) { if (total <= kMaxCacheBytes) { break; } + // Another store may have replaced the entry since it was listed; the new + // one is not the old one this pass chose to remove. A replacement is a new + // file, so its inode tells it apart. Best effort: one landing between this + // check and the removal still goes, which only costs a cache miss. + struct stat current{}; + if (stat(entry.path.c_str(), ¤t) != 0 || + current.st_ino != entry.inode || current.st_mtime != entry.modified || + current.st_size != entry.size) { + continue; + } if (remove(entry.path.c_str()) == 0) { total -= entry.size; } @@ -251,16 +295,17 @@ void DawnBlobCache::setDirectory(const std::string &dir) { directorySlot() = usable; } -void DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, +bool DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, WGPUDawnCacheDeviceDescriptor &storage) { if (directory().empty()) { - return; + return false; } storage.dawnLoadCacheDataCallbackInfo.callback = loadEntry; storage.dawnStoreCacheDataCallbackInfo.callback = storeEntry; storage.chain.next = reinterpret_cast( const_cast(descriptor.nextInChain)); descriptor.nextInChain = reinterpret_cast(&storage); + return true; } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h index ab419714a..7604e83e2 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -13,26 +13,36 @@ namespace rnwgpu { // caching interface on the platform the instance was created with (how Chrome // does it). The instance can be React Native Skia's, so the callbacks go on // each device. Without them every launch builds every pipeline from scratch, -// which on Android's Vulkan drivers costs tens to hundreds of milliseconds -// each. +// and on Vulkan drivers that can take a noticeable time per pipeline. // // Each platform's module points the cache at the app's cache directory when it // installs, and requestDevice() chains it onto every device it creates. A // device requested with the `disable_blob_cache` Dawn toggle does not use it. +// +// The cache is best effort. By default Dawn checks a hash of every blob it +// loads, and an entry that is torn, truncated or for another key reads as a +// miss. Entries are +// replaced whole, so the writer that finishes last wins: devices or processes +// that share a key (Dawn's monolithic Vulkan pipeline cache has one per +// device) can replace a fuller blob with a smaller one, which only costs the +// pipelines in it being built again. class DawnBlobCache { public: // The directory to keep the blobs in, created (with its parents) if // missing. Each platform's module calls this when it installs, which is // also when the cache is tidied: abandoned writes are removed, and the - // oldest entries with them while the cache is over its limit (it is checked - // again as a session stores). Only the cache's own files are removed. An - // empty path, or one where no directory can be made, leaves the cache off. + // oldest entries with them while the cache is over its limit (about 64 MB, + // checked again as a session stores). "Oldest" is by when an entry was + // written, not read. An entry larger than half the limit is not stored. Only + // the cache's own files are removed. An empty path, or one where no directory + // can be made, leaves the cache off. static void setDirectory(const std::string &directory); // Chains the cache onto `descriptor`, in front of whatever it already - // chains. `storage` holds the chained struct and must outlive the - // RequestDevice call that reads it. Does nothing without a directory. - static void chain(wgpu::DeviceDescriptor &descriptor, + // chains, and returns true. `storage` holds the chained struct and must + // outlive the RequestDevice call that reads it. Does nothing, and returns + // false, without a directory. + static bool chain(wgpu::DeviceDescriptor &descriptor, WGPUDawnCacheDeviceDescriptor &storage); }; diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index de9de2131..fc845a1b5 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -379,6 +379,50 @@ TEST_F(DawnBlobCacheTest, TrimsTheOldestEntriesAsASessionStoresMore) { EXPECT_EQ(cache.size({1}), 9u * 1024 * 1024); } +TEST_F(DawnBlobCacheTest, SaysWhetherItChained) { + wgpu::DeviceDescriptor descriptor; + WGPUDawnCacheDeviceDescriptor storage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + EXPECT_TRUE(DawnBlobCache::chain(descriptor, storage)); + + DawnBlobCache::setDirectory(""); + wgpu::DeviceDescriptor other; + WGPUDawnCacheDeviceDescriptor otherStorage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + EXPECT_FALSE(DawnBlobCache::chain(other, otherStorage)); +} + +// Tidying would remove every older entry, and then the new one. +TEST_F(DawnBlobCacheTest, DoesNotStoreAnEntryTooLargeForTheCache) { + const auto cache = callbacks(); + cache.write({1}, {2, 3}); + cache.write({2}, {4}); + + cache.write({1}, Bytes(33 * 1024 * 1024, 7)); + + EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); + EXPECT_EQ(cache.read({2}), (Bytes{4})); + EXPECT_EQ(entries().size(), 2u); +} + +// Dawn's monolithic pipeline cache is one entry stored over and over: that is +// not the cache growing. +TEST_F(DawnBlobCacheTest, DoesNotCountAReplacementAsTheCacheGrowing) { + const auto cache = callbacks(); + // The first store takes the cache past the point where it checks its limit. + cache.write({1}, Bytes(9 * 1024 * 1024, 7)); + const auto large = _dir + "/00000000000000ff"; + writeFile(large, "x"); + ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); + age(large, 3600); + + cache.write({1}, Bytes(9 * 1024 * 1024, 8)); + cache.write({1}, Bytes(9 * 1024 * 1024, 9)); + + EXPECT_TRUE(exists(large)); + EXPECT_EQ(cache.size({1}), 9u * 1024 * 1024); +} + // A wrong path must never turn clearing the cache into deleting someone's // files. TEST_F(DawnBlobCacheTest, LeavesFilesThatAreNotTheCachesAlone) { From 33616343fff9a42b5b3670f823b7674f927b67f4 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Thu, 29 Oct 2026 19:30:41 +0000 Subject: [PATCH 13/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20persist=20Dawn?= =?UTF-8?q?'s=20monolithic=20Vulkan=20pipeline=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With vulkan_monolithic_pipeline_cache Dawn writes its one VkPipelineCache to the blob cache only inside PerformIdleTasks(), which nothing called, so on Android the Vulkan pipeline data never reached the disk. An IdleFlusher now calls it about a second after the last pipeline was created (about ten seconds into a long run of creations) and once on device.destroy(), for devices that have the blob cache, run on Vulkan, and have the toggle applied. A device without implicit synchronization is flushed only on destroy(). Cancelled on device loss, and never waited for when the device is released, since that can happen inside a Dawn callback. Messages Dawn logs during a flush stay native. --- packages/webgpu/android/CMakeLists.txt | 1 + packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp | 212 ++++++++++ packages/webgpu/cpp/rnwgpu/IdleFlusher.h | 104 +++++ packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 60 ++- packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 53 ++- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 25 ++ .../webgpu/cpp/rnwgpu/api/VideoFrameBlit.h | 12 + packages/webgpu/tests/cpp/CMakeLists.txt | 2 + packages/webgpu/tests/cpp/IdleFlusherTest.cpp | 376 ++++++++++++++++++ 9 files changed, 832 insertions(+), 13 deletions(-) create mode 100644 packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp create mode 100644 packages/webgpu/cpp/rnwgpu/IdleFlusher.h create mode 100644 packages/webgpu/tests/cpp/IdleFlusherTest.cpp diff --git a/packages/webgpu/android/CMakeLists.txt b/packages/webgpu/android/CMakeLists.txt index 2ed41308b..2db2744b5 100644 --- a/packages/webgpu/android/CMakeLists.txt +++ b/packages/webgpu/android/CMakeLists.txt @@ -56,6 +56,7 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/rnwgpu/api/GPUComputePipeline.cpp ../cpp/rnwgpu/api/GPUCanvasContext.cpp ../cpp/rnwgpu/DawnBlobCache.cpp + ../cpp/rnwgpu/IdleFlusher.cpp ../cpp/rnwgpu/RNWebGPUManager.cpp ../cpp/rnwgpu/WebGPURuntime.cpp ../cpp/jsi/Promise.cpp diff --git a/packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp b/packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp new file mode 100644 index 000000000..5486cb93a --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp @@ -0,0 +1,212 @@ +#include "IdleFlusher.h" + +#include +#include +#include + +namespace rnwgpu { + +namespace { + +thread_local bool tInFlush = false; + +// The flush locks this thread holds, innermost first. cancel() is allowed +// from inside a flush, and from what releasing the flush function sets off, so +// it has to tell that a lock it would try is one this thread already owns. +struct Holding { + const void *state; + Holding *previous; +}; +thread_local Holding *tHolding = nullptr; + +class ScopedHolding { +public: + explicit ScopedHolding(const void *state) : _node{state, tHolding} { + tHolding = &_node; + } + ~ScopedHolding() { tHolding = _node.previous; } + + ScopedHolding(const ScopedHolding &) = delete; + ScopedHolding &operator=(const ScopedHolding &) = delete; + +private: + Holding _node; +}; + +bool holding(const void *state) { + for (const Holding *node = tHolding; node != nullptr; node = node->previous) { + if (node->state == state) { + return true; + } + } + return false; +} + +// Runs `flush` on this thread, marked as a flush. Exceptions stop here: a +// flush has no one to report to. +void runMarked(const std::function &flush) { + const bool outer = tInFlush; + tInFlush = true; + try { + flush(); + } catch (...) { + } + tInFlush = outer; +} + +} // namespace + +IdleFlusher::IdleFlusher(std::function flush, + std::chrono::milliseconds quiet, + std::chrono::milliseconds maxDelay, bool background) + : _state(std::make_shared(std::move(flush), quiet, maxDelay, + background)) {} + +IdleFlusher::~IdleFlusher() { cancel(); } + +bool IdleFlusher::flushing() { return tInFlush; } + +IdleFlusher::Clock::time_point IdleFlusher::State::due() const { + return std::min(lastTouch + quiet, firstTouch + maxDelay); +} + +void IdleFlusher::touch() { + auto &state = *_state; + std::lock_guard lock(state.mutex); + if (state.cancelled) { + return; + } + const auto now = Clock::now(); + if (!state.dirty) { + state.dirty = true; + state.firstTouch = now; + } + state.lastTouch = now; + if (state.background) { + if (!state.threadStarted) { + state.threadStarted = true; + // The thread keeps the state alive, so it outlives this object if it has + // to. + std::thread([shared = _state] { shared->run(); }).detach(); + } + state.wake.notify_one(); + } +} + +void IdleFlusher::flushNow() { + auto &state = *_state; + std::lock_guard flushLock(state.flushMutex); + ScopedHolding scoped(&state); + bool due = false; + { + std::lock_guard lock(state.mutex); + if (state.dirty && !state.cancelled) { + state.dirty = false; + due = true; + } + } + if (due && state.flush) { + runMarked(state.flush); + } + state.releaseIfCancelled(); +} + +void IdleFlusher::cancel() { + auto &state = *_state; + { + std::lock_guard lock(state.mutex); + state.cancelled = true; + } + state.wake.notify_all(); + if (holding(&state)) { + // Called from inside a flush, or from what releasing its function set off: + // whoever holds the lock drops the function when it is done. + return; + } + // Release what the flush holds now if that is possible; a flush in progress + // does it when it ends. + std::unique_lock flushLock(state.flushMutex, std::try_to_lock); + if (flushLock.owns_lock()) { + ScopedHolding scoped(&state); + state.releaseIfCancelled(); + } +} + +void IdleFlusher::finish() { + auto &state = *_state; + std::lock_guard flushLock(state.flushMutex); + ScopedHolding scoped(&state); + bool pending; + { + std::lock_guard lock(state.mutex); + pending = state.dirty && !state.cancelled; + state.dirty = false; + state.cancelled = true; + } + state.wake.notify_all(); + if (pending && state.flush) { + runMarked(state.flush); + } + state.releaseIfCancelled(); +} + +void IdleFlusher::stop() { + cancel(); + auto &state = *_state; + std::lock_guard flushLock(state.flushMutex); + ScopedHolding scoped(&state); + state.releaseIfCancelled(); +} + +void IdleFlusher::State::run() { + std::unique_lock lock(mutex); + while (!cancelled) { + if (!dirty) { + wake.wait(lock); + continue; + } + if (Clock::now() < due()) { + wake.wait_until(lock, due()); + continue; + } + lock.unlock(); + runFlush(); + lock.lock(); + } +} + +// Decides again under the flush lock whether the flush is still due, so a +// finish() or flushNow() that ran in between has already taken it, and one +// that is still to come waits for this one. +void IdleFlusher::State::runFlush() { + std::lock_guard flushLock(flushMutex); + ScopedHolding scoped(this); + bool shouldRun = false; + { + std::lock_guard lock(mutex); + if (!cancelled && dirty && Clock::now() >= due()) { + dirty = false; + shouldRun = true; + } + } + if (shouldRun && flush) { + runMarked(flush); + } + releaseIfCancelled(); +} + +// What the function captured is released outside `mutex`: letting go of a +// device can call back into code that cancels. +void IdleFlusher::State::releaseIfCancelled() { + bool isCancelled; + { + std::lock_guard lock(mutex); + isCancelled = cancelled; + } + if (isCancelled) { + std::function released = std::move(flush); + flush = nullptr; + } +} + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/IdleFlusher.h b/packages/webgpu/cpp/rnwgpu/IdleFlusher.h new file mode 100644 index 000000000..99046b9d9 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/IdleFlusher.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace rnwgpu { + +// Runs `flush` once things have been quiet for a while, for work that is worth +// persisting but not worth doing on every change. Dawn's monolithic Vulkan +// pipeline cache is one: it only reaches the blob cache when the embedder calls +// PerformIdleTasks() on the device, and each store rewrites the whole cache. +// +// touch() records that there is something to flush. In the background mode a +// thread, started by the first touch(), flushes `quiet` after the last touch(), +// or `maxDelay` after the first one since the last flush if touches keep +// coming. Otherwise nothing runs by itself, and flushNow() and finish() are the +// only ways to flush. +// +// A flush never overlaps another one. The thread owns the state it works on, +// so destroying the IdleFlusher never waits for it: that makes the destructor +// safe to run wherever the last reference to its owner is dropped, a Dawn +// callback holding a device lock included. Call finish() or stop() first where +// the flush must be over, or what it uses released. A flush still running when +// the process exits is not waited for either, so what it touches must outlive +// the process's static destructors. +class IdleFlusher { +public: + using Clock = std::chrono::steady_clock; + + IdleFlusher(std::function flush, std::chrono::milliseconds quiet, + std::chrono::milliseconds maxDelay, bool background); + // Cancels, without waiting. + ~IdleFlusher(); + + IdleFlusher(const IdleFlusher &) = delete; + IdleFlusher &operator=(const IdleFlusher &) = delete; + + // Thread-safe and cheap. Does nothing once cancelled. + void touch(); + + // Flushes on the calling thread if anything was touched since the last flush. + void flushNow(); + + // No flush starts after this, and `flush` is released as soon as none is + // running (by whichever thread is last to use it). In a narrow race with a + // flush that is ending it can be kept until stop() or finish(), or until + // the IdleFlusher is destroyed. Does not wait, so it can be called from a + // callback that may run inside a flush. + void cancel(); + + // Flushes what was touched and not flushed yet, on the calling thread, then + // cancels, as one step: nothing touched before the call is dropped, and no + // flush starts or is running when it returns. Safe to call from several + // threads. Must not be called from `flush` itself. + void finish(); + + // cancel(), then waits for a flush in progress. Same rules as finish(). + void stop(); + + // Whether the calling thread is inside a flush. A callback that must not + // reach JavaScript from a thread of Dawn's (a logging callback) can use this + // to tell a flush's messages from the ones it normally gets. + static bool flushing(); + +private: + struct State { + State(std::function flushFunction, std::chrono::milliseconds quiet, + std::chrono::milliseconds maxDelay, bool background) + : quiet(quiet), maxDelay(maxDelay), background(background), + flush(std::move(flushFunction)) {} + + Clock::time_point due() const; + void run(); + void runFlush(); + // Drops `flush` if cancelled; the caller holds flushMutex. + void releaseIfCancelled(); + + const std::chrono::milliseconds quiet; + const std::chrono::milliseconds maxDelay; + const bool background; + + // Guards everything below it up to flushMutex. Never held while waiting + // for flushMutex. + std::mutex mutex; + std::condition_variable wake; + bool dirty = false; + bool cancelled = false; + bool threadStarted = false; + Clock::time_point firstTouch; + Clock::time_point lastTouch; + + // Serializes flushes and guards `flush`. Taken before `mutex`, never after. + std::mutex flushMutex; + std::function flush; + }; + + const std::shared_ptr _state; +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index b3a0f8c7b..9050f8b7f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -16,8 +16,11 @@ #include #endif +#include "dawn/native/DawnNative.h" + #include "Convertors.h" #include "DawnBlobCache.h" +#include "IdleFlusher.h" #include "GPUFeatures.h" #include "JSIConverter.h" @@ -255,8 +258,9 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( // (MediaTek mt6855) segfaults while Skia Graphite compiles its // pipelines (wcandillon/react-native-skia#4340). With one cache the // only vkDestroyPipelineCache runs at device teardown, and pipelines - // that share binaries compile faster. PerformIdleTasks is not needed: - // nothing persists the cache. + // that share binaries compile faster. Dawn writes that cache to the + // blob cache only when told the device is idle, which GPUDevice does + // (see enablePipelineCacheFlush). static const char *const kAndroidEnabledToggles[] = { "ignore_imported_ahardwarebuffer_vulkan_image_size", "vulkan_monolithic_pipeline_cache"}; @@ -285,12 +289,17 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( // synchronous RequestDevice call below, like them. WGPUDawnCacheDeviceDescriptor blobCache = WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; - DawnBlobCache::chain(deviceDesc, blobCache); + const bool blobCached = DawnBlobCache::chain(deviceDesc, blobCache); + wgpu::AdapterInfo adapterInfo{}; + _instance.GetInfo(&adapterInfo); + const bool vulkan = + adapterInfo.backendType == wgpu::BackendType::Vulkan; _instance.RequestDevice( &deviceDesc, wgpu::CallbackMode::AllowProcessEvents, [context, resolve, reject, label, creationRuntime, - deviceLostBinding](wgpu::RequestDeviceStatus status, - wgpu::Device device, wgpu::StringView message) { + deviceLostBinding, blobCached, + vulkan](wgpu::RequestDeviceStatus status, wgpu::Device device, + wgpu::StringView message) { if (message.length) { fprintf(stderr, "%s", message.data); } @@ -309,6 +318,19 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( if (creationRuntime == nullptr) { return; } + // Dawn logs on the thread that serializes its pipeline + // cache, which must not enter the JS runtime. + if (IdleFlusher::flushing()) { +#ifdef __ANDROID__ + __android_log_print( + ANDROID_LOG_WARN, "WebGPU", "Dawn: %.*s", + static_cast(msg.length), msg.data); +#else + fprintf(stderr, "Dawn: %.*s\n", + static_cast(msg.length), msg.data); +#endif + return; + } const char *logLevel = ""; switch (type) { case wgpu::LoggingType::Warning: @@ -338,6 +360,34 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( auto deviceHost = std::make_shared(std::move(device), context, label); + + // Set up before the device is bound for the device lost callback + // below, so the flusher exists by the time that can cancel it. + if (blobCached && vulkan) { + // The monolithic Vulkan pipeline cache reaches the blob cache + // only when the device is told it is idle. Go by the toggles + // Dawn applied, not the ones asked for: the lists can disagree. + bool monolithic = false; + bool blobCacheOff = false; + for (const char *name : + dawn::native::GetTogglesUsed(deviceHost->get().Get())) { + monolithic = + monolithic || + std::strcmp(name, "vulkan_monolithic_pipeline_cache") == + 0; + blobCacheOff = blobCacheOff || + std::strcmp(name, "disable_blob_cache") == 0; + } + if (monolithic && !blobCacheOff) { + // A thread of its own may flush only when Dawn guards the + // device against it; otherwise the device is used from one + // thread, and flushing waits for destroy(). + deviceHost->enablePipelineCacheFlush( + deviceHost->get().HasFeature( + wgpu::FeatureName::ImplicitDeviceSynchronization)); + } + } + *deviceLostBinding = deviceHost; // Register the device in the static registry so the uncaptured diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index 37e7adcae..a13b3103c 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -1,5 +1,6 @@ #include "GPUDevice.h" +#include #include #include #include @@ -9,6 +10,8 @@ #include +#include "dawn/native/DawnNative.h" + #include "Convertors.h" #include "JSIConverter.h" @@ -47,8 +50,29 @@ void defineHiddenProperty(jsi::Runtime &runtime, const jsi::Object &target, } // namespace +void GPUDevice::enablePipelineCacheFlush(bool background) { + // Holds its own reference to the device, released once the flusher is + // cancelled and no flush is running. + wgpu::Device device = _instance; + _pipelineCacheFlusher = std::make_shared( + [device] { + // Destroying or losing the device ends the flush: it has nothing left + // to serialize, and the backend may already be torn down. + if (!dawn::native::IsDeviceLost(device.Get())) { + dawn::native::PerformIdleTasks(device); + } + }, + std::chrono::seconds(1), std::chrono::seconds(10), background); + _videoFrameBlit->setPipelineCreatedCallback( + [flusher = _pipelineCacheFlusher] { flusher->touch(); }); +} + void GPUDevice::notifyDeviceLost(wgpu::DeviceLostReason reason, std::string message) { + if (_pipelineCacheFlusher) { + // Does not wait: this can run on a thread that is inside a flush. + _pipelineCacheFlusher->cancel(); + } std::vector toResolve; std::shared_ptr info; { @@ -160,6 +184,11 @@ std::shared_ptr GPUDevice::createCommandEncoder( } void GPUDevice::destroy() { + if (_pipelineCacheFlusher) { + // The last chance to persist what the pipelines added: flush what was not + // yet, and make sure no flush starts or is running once the device is gone. + _pipelineCacheFlusher->finish(); + } // DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE // Any canvas surface holding a swapchain for this device must let go of it // while the device is alive (see SurfaceInfo::releaseSurfaceForDevice). @@ -208,6 +237,7 @@ std::shared_ptr GPUDevice::createRenderPipeline( } // assert(desc.fragment != nullptr && "Fragment state must not be null"); auto renderPipeline = _instance.CreateRenderPipeline(&desc); + notePipelineCreated(); return std::make_shared(renderPipeline, descriptor->label.value_or("")); } @@ -250,6 +280,7 @@ std::shared_ptr GPUDevice::createComputePipeline( "GPUComputePipelineDescriptor"); } auto computePipeline = _instance.CreateComputePipeline(&desc); + notePipelineCreated(); return std::make_shared(computePipeline, descriptor->label.value_or("")); } @@ -528,7 +559,7 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance()); return context->postTask([device = _instance, desc, descriptor, - pipelineHolder]( + pipelineHolder, flusher = _pipelineCacheFlusher]( const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction @@ -536,10 +567,13 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( (void)descriptor; device.CreateComputePipelineAsync( &desc, wgpu::CallbackMode::AllowProcessEvents, - [pipelineHolder, resolve, - reject](wgpu::CreatePipelineAsyncStatus status, - wgpu::ComputePipeline pipeline, wgpu::StringView msg) { + [pipelineHolder, resolve, reject, + flusher](wgpu::CreatePipelineAsyncStatus status, + wgpu::ComputePipeline pipeline, wgpu::StringView msg) { if (status == wgpu::CreatePipelineAsyncStatus::Success && pipeline) { + if (flusher) { + flusher->touch(); + } pipelineHolder->_instance = pipeline; resolve([pipelineHolder](jsi::Runtime &runtime) mutable { return JSIConverter>::toJSI( @@ -575,7 +609,7 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance()); return context->postTask([device = _instance, desc, descriptor, - pipelineHolder]( + pipelineHolder, flusher = _pipelineCacheFlusher]( const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction @@ -583,10 +617,13 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( (void)descriptor; device.CreateRenderPipelineAsync( &desc, wgpu::CallbackMode::AllowProcessEvents, - [pipelineHolder, resolve, - reject](wgpu::CreatePipelineAsyncStatus status, - wgpu::RenderPipeline pipeline, wgpu::StringView msg) { + [pipelineHolder, resolve, reject, + flusher](wgpu::CreatePipelineAsyncStatus status, + wgpu::RenderPipeline pipeline, wgpu::StringView msg) { if (status == wgpu::CreatePipelineAsyncStatus::Success && pipeline) { + if (flusher) { + flusher->touch(); + } pipelineHolder->_instance = pipeline; resolve([pipelineHolder](jsi::Runtime &runtime) mutable { return JSIConverter>::toJSI( diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 4507c6459..824768346 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -15,6 +15,7 @@ #include "NativeObject.h" +#include "IdleFlusher.h" #include "rnwgpu/async/AsyncTaskHandle.h" #include "rnwgpu/async/RuntimeContext.h" @@ -77,6 +78,11 @@ class GPUDevice : public NativeObject { ~GPUDevice() override { // Unregister from the static registry unregisterDevice(_instance.Get()); + if (_pipelineCacheFlusher) { + // Not stop(): the last reference to a device can be dropped inside a + // Dawn callback, and a flush may be waiting for what that holds. + _pipelineCacheFlusher->cancel(); + } } // Static registry for looking up GPUDevice from wgpu::Device in callbacks @@ -298,9 +304,26 @@ class GPUDevice : public NativeObject { inline const wgpu::Device get() { return _instance; } + // Has this device persist its monolithic Vulkan pipeline cache. Dawn keeps + // that cache in memory and writes it to the blob cache only when the + // embedder calls PerformIdleTasks() (the vulkan_monolithic_pipeline_cache + // toggle says so), so without this a relaunch starts with an empty one. The + // flush runs a while after the last pipeline was created, and when the + // device is destroyed. `background` lets it run on a thread of its own, which + // needs the device to guard itself (ImplicitDeviceSynchronization); without + // it only the flush at destroy() happens. Call before the device is shared. + void enablePipelineCacheFlush(bool background); + private: friend class GPUAdapter; + // Tells the flush that a pipeline was created; safe from any thread. + void notePipelineCreated() { + if (_pipelineCacheFlusher) { + _pipelineCacheFlusher->touch(); + } + } + // Runs the uncapturederror listeners on the creation runtime's JS thread. // Invoked from notifyUncapturedError via the main CallInvoker. void deliverUncapturedError(wgpu::ErrorType type, std::string message); @@ -313,6 +336,8 @@ class GPUDevice : public NativeObject { // this device so the pipelines are created once. std::shared_ptr _videoFrameBlit = std::make_shared(); + // Null unless enablePipelineCacheFlush() was called. + std::shared_ptr _pipelineCacheFlusher; // Guards the device-lost state below. getLost() runs on a JS thread, but // Dawn's AllowSpontaneous device-lost callback (and device destruction) can // fire notifyDeviceLost() from other threads, so the mutex keeps these diff --git a/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h b/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h index 23d11ec22..9ece160f6 100644 --- a/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h +++ b/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,13 @@ namespace rnwgpu { // and one pipeline per destination format. class VideoFrameBlit { public: + // Called after each pipeline this creates, so the device can tell its + // pipeline cache has something new to persist. Set once, before the first + // copy. + void setPipelineCreatedCallback(std::function callback) { + _onPipelineCreated = std::move(callback); + } + // What becomes of the frame's alpha channel. enum class Alpha { // The destination is opaque (a video frame): alpha is written as 1. @@ -354,12 +362,16 @@ fn srgbToLinear(c: vec3f) -> vec3f { resources.bindGroupLayout = _bindGroupLayout; resources.sampler = _sampler; resources.pipeline = device.CreateRenderPipeline(&pipelineDesc); + if (_onPipelineCreated) { + _onPipelineCreated(); + } auto inserted = _resources.emplace(static_cast(format), std::move(resources)); return inserted.first->second; } std::mutex _mutex; + std::function _onPipelineCreated; wgpu::ShaderModule _module; wgpu::BindGroupLayout _bindGroupLayout; wgpu::PipelineLayout _pipelineLayout; diff --git a/packages/webgpu/tests/cpp/CMakeLists.txt b/packages/webgpu/tests/cpp/CMakeLists.txt index 55b423784..cc766d814 100644 --- a/packages/webgpu/tests/cpp/CMakeLists.txt +++ b/packages/webgpu/tests/cpp/CMakeLists.txt @@ -29,7 +29,9 @@ FetchContent_MakeAvailable(googletest) add_executable( cpp-tests DawnBlobCacheTest.cpp + IdleFlusherTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/rnwgpu/DawnBlobCache.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/rnwgpu/IdleFlusher.cpp ) target_include_directories( cpp-tests diff --git a/packages/webgpu/tests/cpp/IdleFlusherTest.cpp b/packages/webgpu/tests/cpp/IdleFlusherTest.cpp new file mode 100644 index 000000000..3080e9b55 --- /dev/null +++ b/packages/webgpu/tests/cpp/IdleFlusherTest.cpp @@ -0,0 +1,376 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "IdleFlusher.h" + +namespace { + +using rnwgpu::IdleFlusher; +using std::chrono::milliseconds; + +void sleepFor(int ms) { std::this_thread::sleep_for(milliseconds(ms)); } + +// Waits for `condition`, for as long as `ms`. +template bool waitFor(Condition condition, int ms) { + const auto deadline = std::chrono::steady_clock::now() + milliseconds(ms); + while (!condition()) { + if (std::chrono::steady_clock::now() > deadline) { + return false; + } + sleepFor(1); + } + return true; +} + +TEST(IdleFlusher, FlushesOnceAfterThingsGoQuiet) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(30), milliseconds(1000), + true); + + flusher.touch(); + flusher.touch(); + flusher.touch(); + + ASSERT_TRUE(waitFor([&] { return flushes == 1; }, 2000)); + sleepFor(150); + EXPECT_EQ(flushes, 1); + flusher.stop(); +} + +TEST(IdleFlusher, FlushesAgainForTouchesAfterAFlush) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(20), milliseconds(1000), + true); + + flusher.touch(); + ASSERT_TRUE(waitFor([&] { return flushes == 1; }, 2000)); + flusher.touch(); + + EXPECT_TRUE(waitFor([&] { return flushes == 2; }, 2000)); + flusher.stop(); +} + +TEST(IdleFlusher, NeverFlushesWithoutATouch) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(10), milliseconds(50), + true); + + sleepFor(150); + flusher.flushNow(); + + EXPECT_EQ(flushes, 0); + flusher.stop(); +} + +// Touches that never stop coming must not hold the flush back for ever. +TEST(IdleFlusher, FlushesWhileTouchesKeepComingOnceTheDelayIsUp) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(400), milliseconds(1000), + true); + + const auto start = std::chrono::steady_clock::now(); + auto lastTouch = start; + std::chrono::milliseconds longestGap{0}; + int flushesBeforeTheDelay = -1; + // Touches until two flushes have happened, with a generous limit for a + // machine that cannot keep up. + while (flushes < 2 && + std::chrono::steady_clock::now() - start < milliseconds(15000)) { + const auto now = std::chrono::steady_clock::now(); + longestGap = std::max( + longestGap, std::chrono::duration_cast(now - lastTouch)); + lastTouch = now; + flusher.touch(); + sleepFor(10); + const auto elapsed = std::chrono::steady_clock::now() - start; + if (flushesBeforeTheDelay < 0 && elapsed > milliseconds(500) && + elapsed < milliseconds(900)) { + flushesBeforeTheDelay = flushes; + } + } + + // Only a run that kept touching without a pause as long as the quiet time + // says anything about the delay: a machine that stalled that long may have + // flushed for being quiet. + if (longestGap < milliseconds(300) && flushesBeforeTheDelay >= 0) { + EXPECT_EQ(flushesBeforeTheDelay, 0); + } + EXPECT_GE(flushes, 2); + flusher.stop(); +} + +TEST(IdleFlusher, DoesNotFlushByItselfWithoutABackground) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(10), milliseconds(50), + false); + + flusher.touch(); + sleepFor(150); + + EXPECT_EQ(flushes, 0); +} + +TEST(IdleFlusher, FlushesNowOnTheCallingThreadOnlyWhatWasTouched) { + std::thread::id flushedOn; + int flushes = 0; + IdleFlusher flusher( + [&] { + flushes++; + flushedOn = std::this_thread::get_id(); + }, + milliseconds(10), milliseconds(50), false); + + flusher.flushNow(); + EXPECT_EQ(flushes, 0); + + flusher.touch(); + flusher.flushNow(); + EXPECT_EQ(flushes, 1); + EXPECT_EQ(flushedOn, std::this_thread::get_id()); + + flusher.flushNow(); + EXPECT_EQ(flushes, 1); +} + +TEST(IdleFlusher, FlushesNothingOnceCancelled) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(20), milliseconds(100), + true); + + flusher.touch(); + flusher.cancel(); + flusher.touch(); + flusher.flushNow(); + sleepFor(150); + + EXPECT_EQ(flushes, 0); + flusher.stop(); +} + +// What the flush uses (a device) may go as soon as stop() returns. +TEST(IdleFlusher, StopWaitsForAFlushInProgress) { + std::promise started; + std::promise release; + auto released = release.get_future().share(); + IdleFlusher flusher( + [&started, released] { + started.set_value(); + released.wait(); + }, + milliseconds(10), milliseconds(50), true); + flusher.touch(); + started.get_future().wait(); + + std::atomic stopped{false}; + std::thread stopping([&] { + flusher.stop(); + stopped = true; + }); + sleepFor(100); + EXPECT_FALSE(stopped); + + release.set_value(); + stopping.join(); + EXPECT_TRUE(stopped); +} + +TEST(IdleFlusher, StopReleasesWhatTheFlushHolds) { + auto held = std::make_shared(0); + IdleFlusher flusher([held] {}, milliseconds(10), milliseconds(50), true); + EXPECT_EQ(held.use_count(), 2); + + flusher.stop(); + + EXPECT_EQ(held.use_count(), 1); +} + +// Two runtimes can destroy a device at once. +TEST(IdleFlusher, MayBeStoppedFromSeveralThreadsAtOnce) { + for (int i = 0; i < 50; i++) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(1), milliseconds(5), + true); + flusher.touch(); + std::thread first([&] { flusher.stop(); }); + std::thread second([&] { flusher.finish(); }); + flusher.stop(); + first.join(); + second.join(); + } +} + +// The flush a device promises at destroy() must not be lost to the thread that +// was just about to run it. +TEST(IdleFlusher, FinishFlushesWhatWasTouchedExactlyOnce) { + for (int i = 0; i < 200; i++) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(1), milliseconds(5), + true); + flusher.touch(); + std::this_thread::sleep_for(std::chrono::microseconds(i % 40 * 50)); + + flusher.finish(); + + ASSERT_EQ(flushes, 1) << "round " << i; + sleepFor(0); + } +} + +TEST(IdleFlusher, FinishFlushesNothingThatWasNotTouched) { + std::atomic flushes{0}; + IdleFlusher flusher([&] { flushes++; }, milliseconds(1), milliseconds(5), + true); + + flusher.finish(); + flusher.touch(); + flusher.finish(); + + EXPECT_EQ(flushes, 0); +} + +// A device lost callback can drop the last reference to the device while Dawn +// holds a lock the flush waits for, so destroying must never wait for one. +TEST(IdleFlusher, DestroyingDoesNotWaitForAFlushInProgress) { + std::promise started; + std::promise release; + auto released = release.get_future().share(); + std::atomic flushed{false}; + { + IdleFlusher flusher( + [&started, released, &flushed] { + started.set_value(); + released.wait(); + flushed = true; + }, + milliseconds(1), milliseconds(5), true); + flusher.touch(); + started.get_future().wait(); + } + + EXPECT_FALSE(flushed); + release.set_value(); + EXPECT_TRUE(waitFor([&] { return flushed.load(); }, 2000)); +} + +TEST(IdleFlusher, KnowsWhenItIsInAFlush) { + bool insideNow = false; + std::atomic insideBackground{false}; + std::atomic backgroundFlushes{0}; + bool background = false; + IdleFlusher flusher( + [&] { + if (background) { + insideBackground = IdleFlusher::flushing(); + backgroundFlushes++; + } else { + insideNow = IdleFlusher::flushing(); + } + }, + milliseconds(300), milliseconds(1000), true); + + EXPECT_FALSE(IdleFlusher::flushing()); + flusher.touch(); + flusher.flushNow(); + EXPECT_TRUE(insideNow); + EXPECT_FALSE(IdleFlusher::flushing()); + + background = true; + flusher.touch(); + ASSERT_TRUE(waitFor([&] { return backgroundFlushes == 1; }, 2000)); + EXPECT_TRUE(insideBackground); + flusher.stop(); +} + +// A device lost callback can run on the thread that is flushing. +TEST(IdleFlusher, MayBeCancelledFromInsideAFlush) { + auto held = std::make_shared(0); + std::atomic self{nullptr}; + std::atomic flushes{0}; + IdleFlusher flusher( + [held, &self, &flushes] { + flushes++; + self.load()->cancel(); + }, + milliseconds(1), milliseconds(5), true); + self = &flusher; + EXPECT_EQ(held.use_count(), 2); + + flusher.touch(); + ASSERT_TRUE(waitFor([&] { return flushes == 1; }, 2000)); + + // The flush function goes once the flush that cancelled has ended. + EXPECT_TRUE(waitFor([&] { return held.use_count() == 1; }, 2000)); + flusher.touch(); + sleepFor(50); + EXPECT_EQ(flushes, 1); +} + +TEST(IdleFlusher, MayBeCancelledFromWhatReleasingTheFlushSetsOff) { + struct CancelsOnRelease { + explicit CancelsOnRelease(std::atomic *flusher) + : flusher(flusher) {} + CancelsOnRelease(const CancelsOnRelease &) = default; + ~CancelsOnRelease() { + if (IdleFlusher *target = flusher->load()) { + target->cancel(); + } + } + std::atomic *flusher; + }; + std::atomic self{nullptr}; + { + auto releases = std::make_shared(&self); + IdleFlusher flusher([releases] {}, milliseconds(1), milliseconds(5), false); + self = &flusher; + releases.reset(); + + flusher.touch(); + flusher.stop(); + self = nullptr; + } +} + +// What the flush holds goes when the flush that was running at cancel() ends, +// not only at stop(). +TEST(IdleFlusher, ReleasesTheFlushAfterAFlushNowThatWasCancelled) { + std::promise started; + std::promise release; + auto released = release.get_future().share(); + auto held = std::make_shared(0); + IdleFlusher flusher( + [held, &started, released] { + started.set_value(); + released.wait(); + }, + milliseconds(1), milliseconds(5), false); + flusher.touch(); + std::thread flushing([&] { flusher.flushNow(); }); + started.get_future().wait(); + + flusher.cancel(); + EXPECT_EQ(held.use_count(), 2); + release.set_value(); + flushing.join(); + + EXPECT_EQ(held.use_count(), 1); +} + +TEST(IdleFlusher, StopsFromADestructorWithAFlushPending) { + std::atomic flushes{0}; + { + IdleFlusher flusher([&] { flushes++; }, milliseconds(500), + milliseconds(1000), true); + flusher.touch(); + } + + sleepFor(50); + EXPECT_EQ(flushes, 0); +} + +} // namespace From 8357d57c07f30f37ace3ee3f076594ba12afc208 Mon Sep 17 00:00:00 2001 From: Cameron Chisholm Date: Thu, 29 Oct 2026 19:30:41 +0000 Subject: [PATCH 14/20] =?UTF-8?q?chore(=F0=9F=93=9A):=20say=20when=20the?= =?UTF-8?q?=20pipeline=20cache=20is=20written=20and=20how=20its=20size=20i?= =?UTF-8?q?s=20kept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/content/docs/getting-started/native-api.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index 4c67ba449..eb75d53ad 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -93,9 +93,9 @@ See [Vision Camera](/docs/integrations/vision-camera) for an end-to-end example ## 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: building a pipeline there can take tens to hundreds of milliseconds. On Metal (iOS) it is the shaders Dawn translates to MSL; Metal still builds the pipelines, with the help of its own system 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 to the cache about a second after the app last created a pipeline (or about ten seconds into a long run of pipeline creation), and when `device.destroy()` is called. A device that opts out of implicit synchronization (`implicitDeviceSynchronization: false`) is written only on `device.destroy()`. An app that is killed before then keeps what was written until that point. 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's size is checked when the app starts: past 64 MB, it is cleared and refills as the app builds pipelines again. The system may also clear it, as it does any app cache. +The cache is kept to about 64 MB: when the app starts, and again as it stores more, the entries written longest ago are removed until it fits. 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: From 0d8e05b81f8b3be0c33b67c81547045028b38212 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 10:05:08 +0100 Subject: [PATCH 15/20] =?UTF-8?q?refactor(=E2=9A=A1=EF=B8=8F):=20write=20b?= =?UTF-8?q?lob=20cache=20entries=20on=20a=20thread=20of=20their=20own,=20a?= =?UTF-8?q?nd=20clear=20a=20cache=20past=20its=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dawn stores from the thread that compiles, a JS thread included, and holds the device's lock while it stores its pipeline cache. A store now copies the bytes and returns; one writer thread does the disk work, in order. Tidying runs on that thread too, and is simpler: a cache found past 64 MB at setup is cleared, since which of its entries the app still uses cannot be told, and a session that would take the cache past the limit stops adding to it (a replacement no larger than the entry it replaces is never dropped). Only names shaped exactly like the cache's own are counted or removed. --- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 306 +++++++++++------- packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 35 +- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 232 ++++++++----- 3 files changed, 353 insertions(+), 220 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index 610e8b4bf..e8ae3e3d0 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -4,49 +4,43 @@ #include #include -#include -#include +#include #include #include #include #include +#include +#include #include #include +#include +#include +#include #include namespace rnwgpu { namespace { -// Entries only accumulate as an app's shaders change from release to release, -// so past this size the oldest are removed until the cache fits again. The -// limit is a target, not a guarantee: stores that overlap tidying, or other -// processes using the directory, can leave it a little over until the next -// pass. +// Entries only accumulate as an app's shaders change from release to release. +// A cache found past this size when it is set up is cleared, and a session +// whose stores would take it past this size drops them until the next setup. constexpr off_t kMaxCacheBytes = 64 * 1024 * 1024; // An entry this large is not stored, so that one entry cannot take more than // half the cache from all the others. The entry already on disk under its key, // if any, is left as it is, and a blob that has outgrown this stops being // updated. -constexpr uint64_t kMaxEntryBytes = kMaxCacheBytes / 2; +constexpr off_t kMaxEntryBytes = kMaxCacheBytes / 2; -// A session checks the limit again each time the cache has grown by about this -// much through its stores (replacing an entry counts only what it adds), so a -// long-running process cannot take the cache far past it. The count is -// approximate: stores that overlap are measured against the file as it was -// before either replaced it. -constexpr uint64_t kTidyAfterBytes = kMaxCacheBytes / 8; - -// tidy() takes a temporary file this old for a write that will never finish -// (its process was killed). A younger one may still be being written by -// another process using the same cache, so it is left. The age is a best -// guess: a writer suspended for longer than this loses its entry when tidy() -// removes its file (its rename then fails), which only costs a cache miss. +// A temporary file this old is taken for a write that will never finish (its +// process was killed). A younger one may still be being written by another +// process using the same cache, so it is left. A writer suspended for longer +// than this loses its entry (its rename fails), which only costs a cache miss. constexpr time_t kAbandonedWriteSeconds = 60; -// Never destroyed: a pipeline cache flush on another thread can still be -// storing when the process starts to exit. +// Never destroyed: a store can still be queued when the process starts to +// exit. std::mutex &directoryMutex() { static std::mutex *const mutex = new std::mutex; return *mutex; @@ -62,7 +56,77 @@ std::string directory() { return directorySlot(); } -void tidy(const std::string &dir); +// Everything that changes the directory runs here, one job after another: the +// tidying of setDirectory() and the stores. Dawn stores from the thread that +// compiles, a JS thread included, and holds the device's lock while it stores +// its pipeline cache, so none of them should wait for the disk. Never +// destroyed, so a job queued as the process exits never runs into a destroyed +// queue; a write cut short by the exit leaves a temporary file the next setup +// removes. +class Writer { +public: + static Writer &shared() { + static Writer *const writer = new Writer; + return *writer; + } + + void post(std::function job) { + std::unique_lock lock(_mutex); + _queue.push_back(std::move(job)); + if (!_started) { + try { + std::thread([this] { run(); }).detach(); + _started = true; + } catch (const std::system_error &) { + // No thread to be had (a process at its limit): the work runs here, + // and the next post tries for a thread again. + std::deque> queued = std::move(_queue); + _queue.clear(); + lock.unlock(); + for (auto &queuedJob : queued) { + queuedJob(); + } + return; + } + } + _wake.notify_one(); + } + + // Blocks until every job posted so far has run. + void wait() { + std::unique_lock lock(_mutex); + _idle.wait(lock, [this] { return _queue.empty() && !_busy; }); + } + + // Bytes of entries in the directory: what tidying found, plus what this + // session stored since. Read and written by jobs only. + off_t total = 0; + +private: + void run() { + std::unique_lock lock(_mutex); + for (;;) { + _wake.wait(lock, [this] { return !_queue.empty(); }); + std::function job = std::move(_queue.front()); + _queue.pop_front(); + _busy = true; + lock.unlock(); + job(); + lock.lock(); + _busy = false; + if (_queue.empty()) { + _idle.notify_all(); + } + } + } + + std::mutex _mutex; + std::condition_variable _wake; + std::condition_variable _idle; + std::deque> _queue; + bool _started = false; + bool _busy = false; +}; // Creates `dir` and any of its parents that are missing. void makeDirectories(const std::string &dir) { @@ -87,6 +151,10 @@ std::string entryPath(const std::string &dir, size_t keySize, return dir + name; } +off_t entryBytes(size_t keySize, size_t valueSize) { + return static_cast(2 * sizeof(uint64_t) + keySize + valueSize); +} + // An entry is the key's size, the value's size, the key and the value. The // stored key is compared on load, so a hash collision reads as a miss, and so // does an entry shorter than its recorded size. Dawn asks for the size first @@ -125,114 +193,122 @@ size_t loadEntry(size_t keySize, const uint8_t *key, size_t valueSize, return result; } -// Written aside and renamed into place, so a concurrent load never sees half -// an entry. Dawn may store from several threads at once. -void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, - const uint8_t *value, void * /* userdata1 */, - void * /* userdata2 */) { - const std::string dir = directory(); - if (dir.empty()) { - return; +// Written aside and renamed into place, so a load on another thread never +// sees half an entry. Runs on the writer. +void writeEntry(const std::string &dir, const std::vector &key, + const std::vector &value) { + Writer &writer = Writer::shared(); + const std::string path = entryPath(dir, key.size(), key.data()); + const off_t bytes = entryBytes(key.size(), value.size()); + struct stat replaced{}; + const off_t previousBytes = + stat(path.c_str(), &replaced) == 0 ? replaced.st_size : 0; + if (previousBytes == 0) { + // A new entry, unless the system or the app cleared the cache directory, + // or its parent, during the session: then the entries counted so far are + // gone with it, and the directory has to be made again. + struct stat info{}; + if (stat(dir.c_str(), &info) != 0) { + writer.total = 0; + makeDirectories(dir); + } } - const uint64_t entryBytes = 2 * sizeof(uint64_t) + keySize + valueSize; - if (entryBytes > kMaxEntryBytes) { + // Past the limit the cache stops growing until the next setup clears it. + // Replacing an entry with one no larger is not growth: Dawn's monolithic + // pipeline cache is one entry stored over and over. + if (bytes > previousBytes && + writer.total + (bytes - previousBytes) > kMaxCacheBytes) { return; } - static std::atomic writes{0}; - const std::string path = entryPath(dir, keySize, key); - const std::string temporary = path + "." + std::to_string(getpid()) + "." + - std::to_string(writes.fetch_add(1)) + ".tmp"; + const std::string temporary = path + "." + std::to_string(getpid()) + ".tmp"; FILE *file = fopen(temporary.c_str(), "wb"); - if (file == nullptr) { - // The system or the app may have cleared the cache directory, or its - // parent, during the session. - makeDirectories(dir); - file = fopen(temporary.c_str(), "wb"); - } if (file == nullptr) { return; } - const uint64_t sizes[2] = {keySize, valueSize}; + const uint64_t sizes[2] = {key.size(), value.size()}; bool written = fwrite(sizes, sizeof(sizes), 1, file) == 1 && - fwrite(key, 1, keySize, file) == keySize && - fwrite(value, 1, valueSize, file) == valueSize; + fwrite(key.data(), 1, key.size(), file) == key.size() && + fwrite(value.data(), 1, value.size(), file) == value.size(); written = fclose(file) == 0 && written; - struct stat replaced{}; - const uint64_t previousBytes = stat(path.c_str(), &replaced) == 0 - ? static_cast(replaced.st_size) - : 0; if (!written || rename(temporary.c_str(), path.c_str()) != 0) { remove(temporary.c_str()); return; } - if (entryBytes <= previousBytes) { + writer.total += bytes - previousBytes; +} + +// Dawn's buffers last only for this call: the bytes are copied, and written +// later on the writer. +void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, + const uint8_t *value, void * /* userdata1 */, + void * /* userdata2 */) { + const std::string dir = directory(); + if (dir.empty() || entryBytes(keySize, valueSize) > kMaxEntryBytes) { return; } - static std::atomic grown{0}; - const uint64_t growth = entryBytes - previousBytes; - // One thread takes the count when it is over the limit, and resets it only - // while it still is: growth that lands after another thread has taken the - // count stays in it for the next pass. - if (grown.fetch_add(growth) + growth >= kTidyAfterBytes) { - uint64_t seen = grown.load(); - while (seen >= kTidyAfterBytes) { - if (grown.compare_exchange_weak(seen, 0)) { - tidy(dir); - break; - } - } - } + Writer::shared().post( + [dir, key = std::vector(key, key + keySize), + value = std::vector(value, value + valueSize)] { + writeEntry(dir, key, value); + }); } -bool endsWith(const std::string &name, const char *suffix) { - const size_t length = strlen(suffix); - return name.size() > length && - name.compare(name.size() - length, length, suffix) == 0; -} +enum class Kind { Entry, Temporary, Other }; -// Whether a file name starts like one of the cache's own: an entry's 16 -// lowercase hex digits, alone or followed by a dot (a temporary file). -bool isOwnFile(const std::string &name) { - if (name.size() < 16 || (name.size() > 16 && name[16] != '.')) { - return false; +// An entry's name is 16 lowercase hex digits. A temporary file's is one +// followed by dot-separated numbers and ".tmp": the writing process's id, and +// in earlier versions a sequence number too. Anything else in the directory +// is not the cache's to remove. +Kind kindOf(const std::string &name) { + if (name.size() < 16) { + return Kind::Other; } for (size_t i = 0; i < 16; i++) { const char c = name[i]; if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { - return false; + return Kind::Other; } } - return true; + if (name.size() == 16) { + return Kind::Entry; + } + size_t i = 16; + bool numbers = false; + while (i < name.size() && name[i] == '.') { + size_t j = i + 1; + while (j < name.size() && name[j] >= '0' && name[j] <= '9') { + j++; + } + if (j == i + 1) { + break; + } + numbers = true; + i = j; + } + return numbers && name.compare(i, std::string::npos, ".tmp") == 0 + ? Kind::Temporary + : Kind::Other; } // Removes the temporary files of writes a killed process left unfinished, and -// the oldest entries while the cache is over kMaxCacheBytes. Runs when the -// cache is set up and after each kTidyAfterBytes stored. "Oldest" is by write -// time: a load does not refresh an entry. +// every entry when together they are past kMaxCacheBytes: a cache that has +// outgrown the limit holds mostly what the app no longer uses, and one launch +// that builds everything is the price of starting over. Leaves the writer's +// total at what the directory holds. Runs on the writer. void tidy(const std::string &dir) { - // Another thread tidying the same directory is enough. - static std::mutex *const tidying = new std::mutex; - std::unique_lock lock(*tidying, std::try_to_lock); - if (!lock.owns_lock()) { - return; - } + Writer &writer = Writer::shared(); + writer.total = 0; DIR *listing = opendir(dir.c_str()); if (listing == nullptr) { return; } - struct Entry { - std::string path; - time_t modified; - off_t size; - ino_t inode; - }; - std::vector entries; + std::vector entries; off_t total = 0; const time_t now = time(nullptr); while (dirent *entry = readdir(listing)) { const std::string name = entry->d_name; - // Anything else in the directory is not the cache's to delete. - if (!isOwnFile(name)) { + const Kind kind = kindOf(name); + if (kind == Kind::Other) { continue; } const std::string path = dir + "/" + name; @@ -240,40 +316,23 @@ void tidy(const std::string &dir) { if (stat(path.c_str(), &info) != 0) { continue; } - if (endsWith(name, ".tmp")) { + if (kind == Kind::Temporary) { if (now - info.st_mtime > kAbandonedWriteSeconds) { remove(path.c_str()); } continue; } total += info.st_size; - entries.push_back({path, info.st_mtime, info.st_size, info.st_ino}); + entries.push_back(path); } closedir(listing); - if (total <= kMaxCacheBytes) { - return; - } - std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) { - return a.modified != b.modified ? a.modified < b.modified : a.path < b.path; - }); - for (const auto &entry : entries) { - if (total <= kMaxCacheBytes) { - break; - } - // Another store may have replaced the entry since it was listed; the new - // one is not the old one this pass chose to remove. A replacement is a new - // file, so its inode tells it apart. Best effort: one landing between this - // check and the removal still goes, which only costs a cache miss. - struct stat current{}; - if (stat(entry.path.c_str(), ¤t) != 0 || - current.st_ino != entry.inode || current.st_mtime != entry.modified || - current.st_size != entry.size) { - continue; - } - if (remove(entry.path.c_str()) == 0) { - total -= entry.size; + if (total > kMaxCacheBytes) { + for (const auto &path : entries) { + remove(path.c_str()); } + total = 0; } + writer.total = total; } } // namespace @@ -283,14 +342,15 @@ void DawnBlobCache::setDirectory(const std::string &dir) { if (!usable.empty()) { makeDirectories(usable); struct stat info{}; - if (stat(usable.c_str(), &info) == 0 && S_ISDIR(info.st_mode)) { - tidy(usable); - } else { + if (stat(usable.c_str(), &info) != 0 || !S_ISDIR(info.st_mode)) { // Nowhere to keep blobs: leave the cache off rather than fail every // load and store. usable.clear(); } } + if (!usable.empty()) { + Writer::shared().post([usable] { tidy(usable); }); + } std::lock_guard lock(directoryMutex()); directorySlot() = usable; } @@ -308,4 +368,6 @@ bool DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, return true; } +void DawnBlobCache::waitForWrites() { Writer::shared().wait(); } + } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h index 7604e83e2..686e9bf37 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -19,23 +19,29 @@ namespace rnwgpu { // installs, and requestDevice() chains it onto every device it creates. A // device requested with the `disable_blob_cache` Dawn toggle does not use it. // +// Loads read the file on the calling thread, since Dawn needs the bytes +// before it goes on. Stores copy the bytes and return: a thread of the cache's +// own writes them, so neither the thread Dawn compiles on (a JS thread +// included) nor a device lock Dawn holds while it stores waits for the disk. +// // The cache is best effort. By default Dawn checks a hash of every blob it // loads, and an entry that is torn, truncated or for another key reads as a -// miss. Entries are -// replaced whole, so the writer that finishes last wins: devices or processes -// that share a key (Dawn's monolithic Vulkan pipeline cache has one per -// device) can replace a fuller blob with a smaller one, which only costs the -// pipelines in it being built again. +// miss. Entries are replaced whole, so the writer that finishes last wins: +// devices or processes that share a key (Dawn's monolithic Vulkan pipeline +// cache has one per device) can replace a fuller blob with a smaller one, +// which only costs the pipelines in it being built again. class DawnBlobCache { public: // The directory to keep the blobs in, created (with its parents) if - // missing. Each platform's module calls this when it installs, which is - // also when the cache is tidied: abandoned writes are removed, and the - // oldest entries with them while the cache is over its limit (about 64 MB, - // checked again as a session stores). "Oldest" is by when an entry was - // written, not read. An entry larger than half the limit is not stored. Only - // the cache's own files are removed. An empty path, or one where no directory - // can be made, leaves the cache off. + // missing. Each platform's module calls this when it installs. The cache is + // then tidied on its own thread: abandoned writes are removed, and when the + // entries together are past the limit (about 64 MB) all of them are, so that + // a cache full of what the app no longer uses starts over, at the price of + // one launch that builds everything. A session whose stores would take the + // cache past the limit drops them (replacing an entry with one no larger is + // never dropped), and an entry larger than half the limit is not stored. + // Only the cache's own files are ever removed. An empty path, or one where + // no directory can be made, leaves the cache off. static void setDirectory(const std::string &directory); // Chains the cache onto `descriptor`, in front of whatever it already @@ -44,6 +50,11 @@ class DawnBlobCache { // false, without a directory. static bool chain(wgpu::DeviceDescriptor &descriptor, WGPUDawnCacheDeviceDescriptor &storage); + + // Blocks until the cache's thread has done everything asked of it so far: + // the tidying of setDirectory() and the stores. Nothing in an app needs to + // wait for a write; tests do. + static void waitForWrites(); }; } // namespace rnwgpu diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index fc845a1b5..745a24e58 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -20,6 +20,8 @@ namespace { using rnwgpu::DawnBlobCache; using Bytes = std::vector; +constexpr off_t kMegabyte = 1024 * 1024; + bool exists(const std::string &path) { struct stat info{}; return stat(path.c_str(), &info) == 0; @@ -39,6 +41,13 @@ void writeFile(const std::string &path, const std::string &contents) { std::ofstream(path, std::ios::binary) << contents; } +// A file of `bytes` that takes no disk: a hole, which counts towards the +// cache's limit all the same. +void writeSparseFile(const std::string &path, off_t bytes) { + writeFile(path, "x"); + ASSERT_EQ(truncate(path.c_str(), bytes), 0); +} + // The names in a directory, leaving out "." and "..". std::vector list(const std::string &dir) { std::vector names; @@ -87,9 +96,16 @@ struct Callbacks { return value; } - void write(const Bytes &key, const Bytes &value) const { + // Stores, as Dawn does, without waiting for the write. + void storeOnly(const Bytes &key, const Bytes &value) const { store(key.size(), key.data(), value.size(), value.data(), nullptr, nullptr); } + + // Stores, and waits for the write. + void write(const Bytes &key, const Bytes &value) const { + storeOnly(key, value); + DawnBlobCache::waitForWrites(); + } }; // The cache directory is process-wide: every test gets its own and disables @@ -103,14 +119,21 @@ class DawnBlobCacheTest : public ::testing::Test { ASSERT_NE(mkdtemp(pattern.data()), nullptr); _root = pattern; _dir = _root + "/cache"; - DawnBlobCache::setDirectory(_dir); + setUpDirectory(_dir); } void TearDown() override { DawnBlobCache::setDirectory(""); + DawnBlobCache::waitForWrites(); removeTree(_root); } + // Sets the directory and waits for its tidying to be done. + static void setUpDirectory(const std::string &dir) { + DawnBlobCache::setDirectory(dir); + DawnBlobCache::waitForWrites(); + } + static Callbacks callbacks() { wgpu::DeviceDescriptor descriptor; WGPUDawnCacheDeviceDescriptor storage = @@ -170,10 +193,45 @@ TEST_F(DawnBlobCacheTest, ChainsNothingWithoutADirectory) { EXPECT_EQ(descriptor.nextInChain, nullptr); } +TEST_F(DawnBlobCacheTest, SaysWhetherItChained) { + wgpu::DeviceDescriptor descriptor; + WGPUDawnCacheDeviceDescriptor storage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + EXPECT_TRUE(DawnBlobCache::chain(descriptor, storage)); + + DawnBlobCache::setDirectory(""); + wgpu::DeviceDescriptor other; + WGPUDawnCacheDeviceDescriptor otherStorage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + EXPECT_FALSE(DawnBlobCache::chain(other, otherStorage)); +} + TEST_F(DawnBlobCacheTest, CreatesItsDirectory) { EXPECT_TRUE(isDirectory(_dir)); } +TEST_F(DawnBlobCacheTest, CreatesMissingParentsWhenSetUp) { + const auto nested = _root + "/a/b/cache"; + + setUpDirectory(nested); + + EXPECT_TRUE(isDirectory(nested)); +} + +TEST_F(DawnBlobCacheTest, StaysOffWhereNoDirectoryCanBeMade) { + // A directory can't be made under a regular file. + const auto blocker = _root + "/file"; + writeFile(blocker, "x"); + + setUpDirectory(blocker + "/cache"); + + wgpu::DeviceDescriptor descriptor; + WGPUDawnCacheDeviceDescriptor storage = + WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; + DawnBlobCache::chain(descriptor, storage); + EXPECT_EQ(descriptor.nextInChain, nullptr); +} + TEST_F(DawnBlobCacheTest, MissesAKeyNeverStored) { EXPECT_EQ(callbacks().size({1, 2, 3}), 0u); } @@ -189,10 +247,26 @@ TEST_F(DawnBlobCacheTest, ReturnsTheExactBytesStored) { EXPECT_EQ(cache.read(key), value); } +// Dawn stores from the thread that compiles, and holds a device lock while it +// stores its pipeline cache: the write must not keep it waiting. +TEST_F(DawnBlobCacheTest, WritesWhatWasStoredOnceWaitedFor) { + const auto cache = callbacks(); + for (uint8_t i = 0; i < 64; i++) { + cache.storeOnly({i}, Bytes(1024, i)); + } + + DawnBlobCache::waitForWrites(); + + EXPECT_EQ(entries().size(), 64u); + for (uint8_t i = 0; i < 64; i++) { + EXPECT_EQ(cache.read({i}), Bytes(1024, i)); + } +} + TEST_F(DawnBlobCacheTest, KeepsEntriesAcrossASecondSetup) { callbacks().write({1}, {9, 8, 7}); - DawnBlobCache::setDirectory(_dir); + setUpDirectory(_dir); EXPECT_EQ(callbacks().read({1}), (Bytes{9, 8, 7})); } @@ -257,7 +331,7 @@ TEST_F(DawnBlobCacheTest, NeverTearsAValueWrittenFromManyThreads) { const Bytes ownKey{static_cast(thread), 42}; const Bytes value(4096, static_cast(thread)); for (int round = 0; round < 64; round++) { - cache.write(ownKey, value); + cache.storeOnly(ownKey, value); cache.write(sharedKey, value); if (cache.read(ownKey) != value) { failures[thread]++; @@ -291,7 +365,7 @@ TEST_F(DawnBlobCacheTest, RecreatesItsDirectoryIfItIsCleared) { // a cache clear can remove too. TEST_F(DawnBlobCacheTest, RecreatesItsParentsIfTheyAreCleared) { const auto parent = _root + "/app"; - DawnBlobCache::setDirectory(parent + "/cache"); + setUpDirectory(parent + "/cache"); const auto cache = callbacks(); removeTree(parent); @@ -300,43 +374,26 @@ TEST_F(DawnBlobCacheTest, RecreatesItsParentsIfTheyAreCleared) { EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); } -TEST_F(DawnBlobCacheTest, CreatesMissingParentsWhenSetUp) { - const auto nested = _root + "/a/b/cache"; - - DawnBlobCache::setDirectory(nested); - - EXPECT_TRUE(isDirectory(nested)); -} - -TEST_F(DawnBlobCacheTest, StaysOffWhereNoDirectoryCanBeMade) { - // A directory can't be made under a regular file. - const auto blocker = _root + "/file"; - writeFile(blocker, "x"); - - DawnBlobCache::setDirectory(blocker + "/cache"); - - wgpu::DeviceDescriptor descriptor; - WGPUDawnCacheDeviceDescriptor storage = - WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; - DawnBlobCache::chain(descriptor, storage); - EXPECT_EQ(descriptor.nextInChain, nullptr); -} - -TEST_F(DawnBlobCacheTest, RemovesAWriteAbandonedByAKilledProcess) { - const auto abandoned = _dir + "/0123456789abcdef.4242.0.tmp"; +TEST_F(DawnBlobCacheTest, RemovesWritesAbandonedByAKilledProcess) { + const auto abandoned = _dir + "/0123456789abcdef.4242.tmp"; + // The shape of an earlier version's temporary files. + const auto older = _dir + "/0123456789abcdef.4242.0.tmp"; writeFile(abandoned, "x"); + writeFile(older, "x"); age(abandoned, 120); + age(older, 120); - DawnBlobCache::setDirectory(_dir); + setUpDirectory(_dir); EXPECT_FALSE(exists(abandoned)); + EXPECT_FALSE(exists(older)); } TEST_F(DawnBlobCacheTest, LeavesAWriteAnotherProcessMayStillFinish) { - const auto writing = _dir + "/0123456789abcdef.4242.0.tmp"; + const auto writing = _dir + "/0123456789abcdef.4242.tmp"; writeFile(writing, "x"); - DawnBlobCache::setDirectory(_dir); + setUpDirectory(_dir); EXPECT_TRUE(exists(writing)); } @@ -344,102 +401,105 @@ TEST_F(DawnBlobCacheTest, LeavesAWriteAnotherProcessMayStillFinish) { TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { callbacks().write({1}, {2, 3}); - DawnBlobCache::setDirectory(_dir); + setUpDirectory(_dir); EXPECT_EQ(entries().size(), 1u); } -// Only as much as the limit needs: the oldest entries go, the rest stay. -TEST_F(DawnBlobCacheTest, TrimsACacheThatOutgrewItsLimitAtTheNextSetup) { +// A cache past its limit holds mostly what the app no longer uses, and which +// entries those are cannot be told: it starts over. +TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { const auto cache = callbacks(); cache.write({1}, {2, 3}); - // An entry name, so it counts towards the limit. - const auto large = _dir + "/00000000000000ff"; - writeFile(large, "x"); - ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); - age(large, 3600); + writeSparseFile(_dir + "/00000000000000ff", 65 * kMegabyte); - DawnBlobCache::setDirectory(_dir); + setUpDirectory(_dir); - EXPECT_FALSE(exists(large)); - EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); + EXPECT_TRUE(entries().empty()); + EXPECT_EQ(cache.size({1}), 0u); } -// A long-running process is held to the limit too. -TEST_F(DawnBlobCacheTest, TrimsTheOldestEntriesAsASessionStoresMore) { +// A long-running session is held to the limit too, until the next setup +// clears the cache. +TEST_F(DawnBlobCacheTest, DropsStoresPastTheLimitUntilTheNextSetup) { const auto cache = callbacks(); const auto large = _dir + "/00000000000000ff"; - writeFile(large, "x"); - ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); - age(large, 3600); + writeSparseFile(large, 60 * kMegabyte); + setUpDirectory(_dir); - cache.write({1}, Bytes(9 * 1024 * 1024, 7)); + cache.write({1}, Bytes(9 * kMegabyte, 7)); + + EXPECT_EQ(cache.size({1}), 0u); + EXPECT_TRUE(exists(large)); + + writeSparseFile(_dir + "/00000000000000fe", 5 * kMegabyte); + setUpDirectory(_dir); + cache.write({1}, Bytes(9 * kMegabyte, 7)); EXPECT_FALSE(exists(large)); - EXPECT_EQ(cache.size({1}), 9u * 1024 * 1024); + EXPECT_EQ(cache.size({1}), 9u * kMegabyte); } -TEST_F(DawnBlobCacheTest, SaysWhetherItChained) { - wgpu::DeviceDescriptor descriptor; - WGPUDawnCacheDeviceDescriptor storage = - WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; - EXPECT_TRUE(DawnBlobCache::chain(descriptor, storage)); +// Dawn's monolithic pipeline cache is one entry stored over and over: that is +// not the cache growing. +TEST_F(DawnBlobCacheTest, DoesNotCountAReplacementAsTheCacheGrowing) { + const auto cache = callbacks(); + writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte); + setUpDirectory(_dir); + cache.write({1}, Bytes(3 * kMegabyte, 7)); - DawnBlobCache::setDirectory(""); - wgpu::DeviceDescriptor other; - WGPUDawnCacheDeviceDescriptor otherStorage = - WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; - EXPECT_FALSE(DawnBlobCache::chain(other, otherStorage)); + cache.write({1}, Bytes(3 * kMegabyte, 8)); + cache.write({2}, Bytes(3 * kMegabyte, 9)); + + EXPECT_EQ(cache.read({1}), Bytes(3 * kMegabyte, 8)); + EXPECT_EQ(cache.size({2}), 0u); } -// Tidying would remove every older entry, and then the new one. +// What the system cleared mid-session is no longer in the cache, so it no +// longer counts against the limit. +TEST_F(DawnBlobCacheTest, StartsCountingAgainWhenItsDirectoryWasCleared) { + const auto cache = callbacks(); + writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte); + setUpDirectory(_dir); + removeTree(_dir); + + cache.write({1}, Bytes(9 * kMegabyte, 7)); + + EXPECT_EQ(cache.size({1}), 9u * kMegabyte); +} + +// Storing it would take most of the cache from all the other entries. TEST_F(DawnBlobCacheTest, DoesNotStoreAnEntryTooLargeForTheCache) { const auto cache = callbacks(); cache.write({1}, {2, 3}); cache.write({2}, {4}); - cache.write({1}, Bytes(33 * 1024 * 1024, 7)); + cache.write({1}, Bytes(33 * kMegabyte, 7)); EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); EXPECT_EQ(cache.read({2}), (Bytes{4})); EXPECT_EQ(entries().size(), 2u); } -// Dawn's monolithic pipeline cache is one entry stored over and over: that is -// not the cache growing. -TEST_F(DawnBlobCacheTest, DoesNotCountAReplacementAsTheCacheGrowing) { - const auto cache = callbacks(); - // The first store takes the cache past the point where it checks its limit. - cache.write({1}, Bytes(9 * 1024 * 1024, 7)); - const auto large = _dir + "/00000000000000ff"; - writeFile(large, "x"); - ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); - age(large, 3600); - - cache.write({1}, Bytes(9 * 1024 * 1024, 8)); - cache.write({1}, Bytes(9 * 1024 * 1024, 9)); - - EXPECT_TRUE(exists(large)); - EXPECT_EQ(cache.size({1}), 9u * 1024 * 1024); -} - // A wrong path must never turn clearing the cache into deleting someone's -// files. +// files: only names shaped like the cache's own are counted or removed. TEST_F(DawnBlobCacheTest, LeavesFilesThatAreNotTheCachesAlone) { const auto other = _dir + "/notes.txt"; const auto similar = _dir + "/0123456789abcdefg"; + const auto lookalike = _dir + "/0123456789abcdef.bak"; const auto large = _dir + "/00000000000000ff"; - writeFile(other, "x"); + writeSparseFile(other, 65 * kMegabyte); writeFile(similar, "x"); - writeFile(large, "x"); - ASSERT_EQ(truncate(large.c_str(), 65 * 1024 * 1024), 0); - ASSERT_EQ(truncate(other.c_str(), 65 * 1024 * 1024), 0); + writeFile(lookalike, "x"); + age(lookalike, 3600); + writeSparseFile(large, 65 * kMegabyte); - DawnBlobCache::setDirectory(_dir); + setUpDirectory(_dir); EXPECT_FALSE(exists(large)); EXPECT_TRUE(exists(other)); EXPECT_TRUE(exists(similar)); + EXPECT_TRUE(exists(lookalike)); } } // namespace From 23681dc55b52c9b8214732aca4675680757da5a1 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 10:05:08 +0100 Subject: [PATCH 16/20] =?UTF-8?q?refactor(=E2=9A=A1=EF=B8=8F):=20flush=20t?= =?UTF-8?q?he=20Vulkan=20pipeline=20cache=20on=20destroy=20and=20when=20th?= =?UTF-8?q?e=20app=20pauses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dawn writes its monolithic Vulkan pipeline cache only when the embedder says the device is idle. Rather than guess at idleness with a thread of our own (IdleFlusher), use the moments the platform gives: device.destroy(), and the app going to the background, which Android reports through onHostPause. Each device flushes on the thread of the runtime it was created on, so Dawn's callbacks keep running where they always did. There is nothing to flush on Metal, where Dawn stores its blobs as it compiles. --- .../docs/getting-started/native-api.mdx | 4 +- packages/webgpu/android/CMakeLists.txt | 1 - packages/webgpu/android/cpp/cpp-adapter.cpp | 10 + .../main/java/com/webgpu/WebGPUModule.java | 29 +- packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp | 212 ---------- packages/webgpu/cpp/rnwgpu/IdleFlusher.h | 104 ----- packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 57 +-- packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 83 ++-- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 39 +- .../webgpu/cpp/rnwgpu/api/VideoFrameBlit.h | 12 - packages/webgpu/tests/cpp/CMakeLists.txt | 2 - packages/webgpu/tests/cpp/IdleFlusherTest.cpp | 376 ------------------ 12 files changed, 101 insertions(+), 828 deletions(-) delete mode 100644 packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp delete mode 100644 packages/webgpu/cpp/rnwgpu/IdleFlusher.h delete mode 100644 packages/webgpu/tests/cpp/IdleFlusherTest.cpp diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index eb75d53ad..567a76020 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -93,9 +93,9 @@ See [Vision Camera](/docs/integrations/vision-camera) for an end-to-end example ## 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 to the cache about a second after the app last created a pipeline (or about ten seconds into a long run of pipeline creation), and when `device.destroy()` is called. A device that opts out of implicit synchronization (`implicitDeviceSynchronization: false`) is written only on `device.destroy()`. An app that is killed before then keeps what was written until that point. 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. +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 the app goes to the background and when `device.destroy()` is called; an app killed while in the foreground keeps what was written before. 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: when the app starts, and again as it stores more, the entries written longest ago are removed until it fits. 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. +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: diff --git a/packages/webgpu/android/CMakeLists.txt b/packages/webgpu/android/CMakeLists.txt index 2db2744b5..2ed41308b 100644 --- a/packages/webgpu/android/CMakeLists.txt +++ b/packages/webgpu/android/CMakeLists.txt @@ -56,7 +56,6 @@ add_library(${PACKAGE_NAME} SHARED ../cpp/rnwgpu/api/GPUComputePipeline.cpp ../cpp/rnwgpu/api/GPUCanvasContext.cpp ../cpp/rnwgpu/DawnBlobCache.cpp - ../cpp/rnwgpu/IdleFlusher.cpp ../cpp/rnwgpu/RNWebGPUManager.cpp ../cpp/rnwgpu/WebGPURuntime.cpp ../cpp/jsi/Promise.cpp diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 9aba4f426..2c07efd90 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -23,6 +23,7 @@ #include "AndroidPlatformContext.h" #include "DawnBlobCache.h" #include "GPUCanvasContext.h" +#include "GPUDevice.h" #include "RNWebGPUManager.h" #include "WebGPURuntime.h" @@ -56,6 +57,15 @@ 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 on the thread of its own runtime. +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( diff --git a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java index b36c5767b..d670caa86 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java @@ -15,6 +15,7 @@ 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; @@ -27,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 } @@ -36,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() { diff --git a/packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp b/packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp deleted file mode 100644 index 5486cb93a..000000000 --- a/packages/webgpu/cpp/rnwgpu/IdleFlusher.cpp +++ /dev/null @@ -1,212 +0,0 @@ -#include "IdleFlusher.h" - -#include -#include -#include - -namespace rnwgpu { - -namespace { - -thread_local bool tInFlush = false; - -// The flush locks this thread holds, innermost first. cancel() is allowed -// from inside a flush, and from what releasing the flush function sets off, so -// it has to tell that a lock it would try is one this thread already owns. -struct Holding { - const void *state; - Holding *previous; -}; -thread_local Holding *tHolding = nullptr; - -class ScopedHolding { -public: - explicit ScopedHolding(const void *state) : _node{state, tHolding} { - tHolding = &_node; - } - ~ScopedHolding() { tHolding = _node.previous; } - - ScopedHolding(const ScopedHolding &) = delete; - ScopedHolding &operator=(const ScopedHolding &) = delete; - -private: - Holding _node; -}; - -bool holding(const void *state) { - for (const Holding *node = tHolding; node != nullptr; node = node->previous) { - if (node->state == state) { - return true; - } - } - return false; -} - -// Runs `flush` on this thread, marked as a flush. Exceptions stop here: a -// flush has no one to report to. -void runMarked(const std::function &flush) { - const bool outer = tInFlush; - tInFlush = true; - try { - flush(); - } catch (...) { - } - tInFlush = outer; -} - -} // namespace - -IdleFlusher::IdleFlusher(std::function flush, - std::chrono::milliseconds quiet, - std::chrono::milliseconds maxDelay, bool background) - : _state(std::make_shared(std::move(flush), quiet, maxDelay, - background)) {} - -IdleFlusher::~IdleFlusher() { cancel(); } - -bool IdleFlusher::flushing() { return tInFlush; } - -IdleFlusher::Clock::time_point IdleFlusher::State::due() const { - return std::min(lastTouch + quiet, firstTouch + maxDelay); -} - -void IdleFlusher::touch() { - auto &state = *_state; - std::lock_guard lock(state.mutex); - if (state.cancelled) { - return; - } - const auto now = Clock::now(); - if (!state.dirty) { - state.dirty = true; - state.firstTouch = now; - } - state.lastTouch = now; - if (state.background) { - if (!state.threadStarted) { - state.threadStarted = true; - // The thread keeps the state alive, so it outlives this object if it has - // to. - std::thread([shared = _state] { shared->run(); }).detach(); - } - state.wake.notify_one(); - } -} - -void IdleFlusher::flushNow() { - auto &state = *_state; - std::lock_guard flushLock(state.flushMutex); - ScopedHolding scoped(&state); - bool due = false; - { - std::lock_guard lock(state.mutex); - if (state.dirty && !state.cancelled) { - state.dirty = false; - due = true; - } - } - if (due && state.flush) { - runMarked(state.flush); - } - state.releaseIfCancelled(); -} - -void IdleFlusher::cancel() { - auto &state = *_state; - { - std::lock_guard lock(state.mutex); - state.cancelled = true; - } - state.wake.notify_all(); - if (holding(&state)) { - // Called from inside a flush, or from what releasing its function set off: - // whoever holds the lock drops the function when it is done. - return; - } - // Release what the flush holds now if that is possible; a flush in progress - // does it when it ends. - std::unique_lock flushLock(state.flushMutex, std::try_to_lock); - if (flushLock.owns_lock()) { - ScopedHolding scoped(&state); - state.releaseIfCancelled(); - } -} - -void IdleFlusher::finish() { - auto &state = *_state; - std::lock_guard flushLock(state.flushMutex); - ScopedHolding scoped(&state); - bool pending; - { - std::lock_guard lock(state.mutex); - pending = state.dirty && !state.cancelled; - state.dirty = false; - state.cancelled = true; - } - state.wake.notify_all(); - if (pending && state.flush) { - runMarked(state.flush); - } - state.releaseIfCancelled(); -} - -void IdleFlusher::stop() { - cancel(); - auto &state = *_state; - std::lock_guard flushLock(state.flushMutex); - ScopedHolding scoped(&state); - state.releaseIfCancelled(); -} - -void IdleFlusher::State::run() { - std::unique_lock lock(mutex); - while (!cancelled) { - if (!dirty) { - wake.wait(lock); - continue; - } - if (Clock::now() < due()) { - wake.wait_until(lock, due()); - continue; - } - lock.unlock(); - runFlush(); - lock.lock(); - } -} - -// Decides again under the flush lock whether the flush is still due, so a -// finish() or flushNow() that ran in between has already taken it, and one -// that is still to come waits for this one. -void IdleFlusher::State::runFlush() { - std::lock_guard flushLock(flushMutex); - ScopedHolding scoped(this); - bool shouldRun = false; - { - std::lock_guard lock(mutex); - if (!cancelled && dirty && Clock::now() >= due()) { - dirty = false; - shouldRun = true; - } - } - if (shouldRun && flush) { - runMarked(flush); - } - releaseIfCancelled(); -} - -// What the function captured is released outside `mutex`: letting go of a -// device can call back into code that cancels. -void IdleFlusher::State::releaseIfCancelled() { - bool isCancelled; - { - std::lock_guard lock(mutex); - isCancelled = cancelled; - } - if (isCancelled) { - std::function released = std::move(flush); - flush = nullptr; - } -} - -} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/IdleFlusher.h b/packages/webgpu/cpp/rnwgpu/IdleFlusher.h deleted file mode 100644 index 99046b9d9..000000000 --- a/packages/webgpu/cpp/rnwgpu/IdleFlusher.h +++ /dev/null @@ -1,104 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace rnwgpu { - -// Runs `flush` once things have been quiet for a while, for work that is worth -// persisting but not worth doing on every change. Dawn's monolithic Vulkan -// pipeline cache is one: it only reaches the blob cache when the embedder calls -// PerformIdleTasks() on the device, and each store rewrites the whole cache. -// -// touch() records that there is something to flush. In the background mode a -// thread, started by the first touch(), flushes `quiet` after the last touch(), -// or `maxDelay` after the first one since the last flush if touches keep -// coming. Otherwise nothing runs by itself, and flushNow() and finish() are the -// only ways to flush. -// -// A flush never overlaps another one. The thread owns the state it works on, -// so destroying the IdleFlusher never waits for it: that makes the destructor -// safe to run wherever the last reference to its owner is dropped, a Dawn -// callback holding a device lock included. Call finish() or stop() first where -// the flush must be over, or what it uses released. A flush still running when -// the process exits is not waited for either, so what it touches must outlive -// the process's static destructors. -class IdleFlusher { -public: - using Clock = std::chrono::steady_clock; - - IdleFlusher(std::function flush, std::chrono::milliseconds quiet, - std::chrono::milliseconds maxDelay, bool background); - // Cancels, without waiting. - ~IdleFlusher(); - - IdleFlusher(const IdleFlusher &) = delete; - IdleFlusher &operator=(const IdleFlusher &) = delete; - - // Thread-safe and cheap. Does nothing once cancelled. - void touch(); - - // Flushes on the calling thread if anything was touched since the last flush. - void flushNow(); - - // No flush starts after this, and `flush` is released as soon as none is - // running (by whichever thread is last to use it). In a narrow race with a - // flush that is ending it can be kept until stop() or finish(), or until - // the IdleFlusher is destroyed. Does not wait, so it can be called from a - // callback that may run inside a flush. - void cancel(); - - // Flushes what was touched and not flushed yet, on the calling thread, then - // cancels, as one step: nothing touched before the call is dropped, and no - // flush starts or is running when it returns. Safe to call from several - // threads. Must not be called from `flush` itself. - void finish(); - - // cancel(), then waits for a flush in progress. Same rules as finish(). - void stop(); - - // Whether the calling thread is inside a flush. A callback that must not - // reach JavaScript from a thread of Dawn's (a logging callback) can use this - // to tell a flush's messages from the ones it normally gets. - static bool flushing(); - -private: - struct State { - State(std::function flushFunction, std::chrono::milliseconds quiet, - std::chrono::milliseconds maxDelay, bool background) - : quiet(quiet), maxDelay(maxDelay), background(background), - flush(std::move(flushFunction)) {} - - Clock::time_point due() const; - void run(); - void runFlush(); - // Drops `flush` if cancelled; the caller holds flushMutex. - void releaseIfCancelled(); - - const std::chrono::milliseconds quiet; - const std::chrono::milliseconds maxDelay; - const bool background; - - // Guards everything below it up to flushMutex. Never held while waiting - // for flushMutex. - std::mutex mutex; - std::condition_variable wake; - bool dirty = false; - bool cancelled = false; - bool threadStarted = false; - Clock::time_point firstTouch; - Clock::time_point lastTouch; - - // Serializes flushes and guards `flush`. Taken before `mutex`, never after. - std::mutex flushMutex; - std::function flush; - }; - - const std::shared_ptr _state; -}; - -} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index 438408fac..a44414957 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -16,11 +16,8 @@ #include #endif -#include "dawn/native/DawnNative.h" - #include "Convertors.h" #include "DawnBlobCache.h" -#include "IdleFlusher.h" #include "GPUFeatures.h" #include "JSIConverter.h" @@ -260,7 +257,7 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( // only vkDestroyPipelineCache runs at device teardown, and pipelines // that share binaries compile faster. Dawn writes that cache to the // blob cache only when told the device is idle, which GPUDevice does - // (see enablePipelineCacheFlush). + // (see GPUDevice::flushPipelineCache). static const char *const kAndroidEnabledToggles[] = { "ignore_imported_ahardwarebuffer_vulkan_image_size", "vulkan_monolithic_pipeline_cache"}; @@ -289,17 +286,12 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( // synchronous RequestDevice call below, like them. WGPUDawnCacheDeviceDescriptor blobCache = WGPU_DAWN_CACHE_DEVICE_DESCRIPTOR_INIT; - const bool blobCached = DawnBlobCache::chain(deviceDesc, blobCache); - wgpu::AdapterInfo adapterInfo{}; - _instance.GetInfo(&adapterInfo); - const bool vulkan = - adapterInfo.backendType == wgpu::BackendType::Vulkan; + DawnBlobCache::chain(deviceDesc, blobCache); return _instance.RequestDevice( &deviceDesc, wgpu::CallbackMode::WaitAnyOnly, [context, resolve, reject, label, creationRuntime, - deviceLostBinding, blobCached, - vulkan](wgpu::RequestDeviceStatus status, wgpu::Device device, - wgpu::StringView message) { + deviceLostBinding](wgpu::RequestDeviceStatus status, + wgpu::Device device, wgpu::StringView message) { if (message.length) { fprintf(stderr, "%s", message.data); } @@ -318,19 +310,6 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( if (creationRuntime == nullptr) { return; } - // Dawn logs on the thread that serializes its pipeline - // cache, which must not enter the JS runtime. - if (IdleFlusher::flushing()) { -#ifdef __ANDROID__ - __android_log_print( - ANDROID_LOG_WARN, "WebGPU", "Dawn: %.*s", - static_cast(msg.length), msg.data); -#else - fprintf(stderr, "Dawn: %.*s\n", - static_cast(msg.length), msg.data); -#endif - return; - } const char *logLevel = ""; switch (type) { case wgpu::LoggingType::Warning: @@ -360,34 +339,6 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( auto deviceHost = std::make_shared(std::move(device), context, label); - - // Set up before the device is bound for the device lost callback - // below, so the flusher exists by the time that can cancel it. - if (blobCached && vulkan) { - // The monolithic Vulkan pipeline cache reaches the blob cache - // only when the device is told it is idle. Go by the toggles - // Dawn applied, not the ones asked for: the lists can disagree. - bool monolithic = false; - bool blobCacheOff = false; - for (const char *name : - dawn::native::GetTogglesUsed(deviceHost->get().Get())) { - monolithic = - monolithic || - std::strcmp(name, "vulkan_monolithic_pipeline_cache") == - 0; - blobCacheOff = blobCacheOff || - std::strcmp(name, "disable_blob_cache") == 0; - } - if (monolithic && !blobCacheOff) { - // A thread of its own may flush only when Dawn guards the - // device against it; otherwise the device is used from one - // thread, and flushing waits for destroy(). - deviceHost->enablePipelineCacheFlush( - deviceHost->get().HasFeature( - wgpu::FeatureName::ImplicitDeviceSynchronization)); - } - } - *deviceLostBinding = deviceHost; // Register the device in the static registry so the uncaptured diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index 240f6d8ff..b6c6159d3 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -1,6 +1,5 @@ #include "GPUDevice.h" -#include #include #include #include @@ -50,29 +49,41 @@ void defineHiddenProperty(jsi::Runtime &runtime, const jsi::Object &target, } // namespace -void GPUDevice::enablePipelineCacheFlush(bool background) { - // Holds its own reference to the device, released once the flusher is - // cancelled and no flush is running. - wgpu::Device device = _instance; - _pipelineCacheFlusher = std::make_shared( - [device] { - // Destroying or losing the device ends the flush: it has nothing left - // to serialize, and the backend may already be torn down. - if (!dawn::native::IsDeviceLost(device.Get())) { - dawn::native::PerformIdleTasks(device); - } - }, - std::chrono::seconds(1), std::chrono::seconds(10), background); - _videoFrameBlit->setPipelineCreatedCallback( - [flusher = _pipelineCacheFlusher] { flusher->touch(); }); +void GPUDevice::flushPipelineCache() { + // Nothing is left to write once the device is lost or destroyed, and the + // backend may already be torn down. + if (!dawn::native::IsDeviceLost(_instance.Get())) { + dawn::native::PerformIdleTasks(_instance); + } +} + +void GPUDevice::flushPipelineCaches() { + std::vector> devices; + { + std::lock_guard lock(getRegistryMutex()); + for (const auto &entry : getRegistry()) { + if (auto device = entry.second.lock()) { + devices.push_back(std::move(device)); + } + } + } + for (const auto &device : devices) { + if (!device->_async) { + continue; + } + // Held weakly: a device collected before its runtime gets to the job has + // nothing left to flush, and the job must not keep it alive. + device->_async->post( + [weak = std::weak_ptr(device)](jsi::Runtime &) { + if (auto device = weak.lock()) { + device->flushPipelineCache(); + } + }); + } } void GPUDevice::notifyDeviceLost(wgpu::DeviceLostReason reason, std::string message) { - if (_pipelineCacheFlusher) { - // Does not wait: this can run on a thread that is inside a flush. - _pipelineCacheFlusher->cancel(); - } std::vector toResolve; std::shared_ptr info; { @@ -178,11 +189,9 @@ std::shared_ptr GPUDevice::createCommandEncoder( } void GPUDevice::destroy() { - if (_pipelineCacheFlusher) { - // The last chance to persist what the pipelines added: flush what was not - // yet, and make sure no flush starts or is running once the device is gone. - _pipelineCacheFlusher->finish(); - } + // The last chance to write what the pipelines added to Dawn's pipeline + // cache: once destroyed, the device has nothing left to serialize. + flushPipelineCache(); // DAWN_WORKAROUND_DEVICE_DESTROY_BEFORE_SURFACE_RELEASE // Any canvas surface holding a swapchain for this device must let go of it // while the device is alive (see SurfaceInfo::releaseSurfaceForDevice). @@ -231,7 +240,6 @@ std::shared_ptr GPUDevice::createRenderPipeline( } // assert(desc.fragment != nullptr && "Fragment state must not be null"); auto renderPipeline = _instance.CreateRenderPipeline(&desc); - notePipelineCreated(); return std::make_shared(renderPipeline, descriptor->label.value_or("")); } @@ -274,7 +282,6 @@ std::shared_ptr GPUDevice::createComputePipeline( "GPUComputePipelineDescriptor"); } auto computePipeline = _instance.CreateComputePipeline(&desc); - notePipelineCreated(); return std::make_shared(computePipeline, descriptor->label.value_or("")); } @@ -553,7 +560,7 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance()); return context->postTask([device = _instance, desc, descriptor, - pipelineHolder, flusher = _pipelineCacheFlusher]( + pipelineHolder]( const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction @@ -561,13 +568,10 @@ async::AsyncTaskHandle GPUDevice::createComputePipelineAsync( (void)descriptor; return device.CreateComputePipelineAsync( &desc, wgpu::CallbackMode::WaitAnyOnly, - [pipelineHolder, resolve, reject, - flusher](wgpu::CreatePipelineAsyncStatus status, - wgpu::ComputePipeline pipeline, wgpu::StringView msg) { + [pipelineHolder, resolve, + reject](wgpu::CreatePipelineAsyncStatus status, + wgpu::ComputePipeline pipeline, wgpu::StringView msg) { if (status == wgpu::CreatePipelineAsyncStatus::Success && pipeline) { - if (flusher) { - flusher->touch(); - } pipelineHolder->_instance = pipeline; resolve([pipelineHolder](jsi::Runtime &runtime) mutable { return JSIConverter>::toJSI( @@ -603,7 +607,7 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( auto context = async::RuntimeContext::getOrCreate(runtime, _async->instance()); return context->postTask([device = _instance, desc, descriptor, - pipelineHolder, flusher = _pipelineCacheFlusher]( + pipelineHolder]( const async::AsyncTaskHandle::ResolveFunction &resolve, const async::AsyncTaskHandle::RejectFunction @@ -611,13 +615,10 @@ async::AsyncTaskHandle GPUDevice::createRenderPipelineAsync( (void)descriptor; return device.CreateRenderPipelineAsync( &desc, wgpu::CallbackMode::WaitAnyOnly, - [pipelineHolder, resolve, reject, - flusher](wgpu::CreatePipelineAsyncStatus status, - wgpu::RenderPipeline pipeline, wgpu::StringView msg) { + [pipelineHolder, resolve, + reject](wgpu::CreatePipelineAsyncStatus status, + wgpu::RenderPipeline pipeline, wgpu::StringView msg) { if (status == wgpu::CreatePipelineAsyncStatus::Success && pipeline) { - if (flusher) { - flusher->touch(); - } pipelineHolder->_instance = pipeline; resolve([pipelineHolder](jsi::Runtime &runtime) mutable { return JSIConverter>::toJSI( diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index b813d2652..ba31bc493 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -15,7 +15,6 @@ #include "NativeObject.h" -#include "IdleFlusher.h" #include "rnwgpu/async/AsyncTaskHandle.h" #include "rnwgpu/async/RuntimeContext.h" @@ -78,11 +77,6 @@ class GPUDevice : public NativeObject { ~GPUDevice() override { // Unregister from the static registry unregisterDevice(_instance.Get()); - if (_pipelineCacheFlusher) { - // Not stop(): the last reference to a device can be dropped inside a - // Dawn callback, and a flush may be waiting for what that holds. - _pipelineCacheFlusher->cancel(); - } } // Static registry for looking up GPUDevice from wgpu::Device in callbacks @@ -304,26 +298,25 @@ class GPUDevice : public NativeObject { inline const wgpu::Device get() { return _instance; } - // Has this device persist its monolithic Vulkan pipeline cache. Dawn keeps - // that cache in memory and writes it to the blob cache only when the - // embedder calls PerformIdleTasks() (the vulkan_monolithic_pipeline_cache - // toggle says so), so without this a relaunch starts with an empty one. The - // flush runs a while after the last pipeline was created, and when the - // device is destroyed. `background` lets it run on a thread of its own, which - // needs the device to guard itself (ImplicitDeviceSynchronization); without - // it only the flush at destroy() happens. Call before the device is shared. - void enablePipelineCacheFlush(bool background); + // Has Dawn write what its monolithic Vulkan pipeline cache holds to the blob + // cache (see DawnBlobCache). Dawn keeps that cache in memory and writes it + // only when the embedder says the device is idle (the + // vulkan_monolithic_pipeline_cache toggle says so), so without this a + // relaunch would start with an empty one. Nothing to do on other backends. + // Runs on the calling thread, which must be one that may use the device: + // the thread of its runtime, or any thread when the device synchronizes + // itself (ImplicitDeviceSynchronization). destroy() calls it; the app going + // to the background does too, through flushPipelineCaches(). + void flushPipelineCache(); + + // Flushes every live device, each on the thread of the runtime it was + // created on. Any thread: the platform modules call it when the app goes to + // the background, the last moment to write before the process may be killed. + static void flushPipelineCaches(); private: friend class GPUAdapter; - // Tells the flush that a pipeline was created; safe from any thread. - void notePipelineCreated() { - if (_pipelineCacheFlusher) { - _pipelineCacheFlusher->touch(); - } - } - // Runs the uncapturederror listeners on the creation runtime's JS thread. // Invoked from notifyUncapturedError through the runtime's context. void deliverUncapturedError(wgpu::ErrorType type, std::string message); @@ -336,8 +329,6 @@ class GPUDevice : public NativeObject { // this device so the pipelines are created once. std::shared_ptr _videoFrameBlit = std::make_shared(); - // Null unless enablePipelineCacheFlush() was called. - std::shared_ptr _pipelineCacheFlusher; // Guards the device-lost state below. getLost() runs on a JS thread, but // Dawn's AllowSpontaneous device-lost callback (and device destruction) can // fire notifyDeviceLost() from other threads, so the mutex keeps these diff --git a/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h b/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h index 9ece160f6..23d11ec22 100644 --- a/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h +++ b/packages/webgpu/cpp/rnwgpu/api/VideoFrameBlit.h @@ -3,7 +3,6 @@ #include #include #include -#include #include #include #include @@ -33,13 +32,6 @@ namespace rnwgpu { // and one pipeline per destination format. class VideoFrameBlit { public: - // Called after each pipeline this creates, so the device can tell its - // pipeline cache has something new to persist. Set once, before the first - // copy. - void setPipelineCreatedCallback(std::function callback) { - _onPipelineCreated = std::move(callback); - } - // What becomes of the frame's alpha channel. enum class Alpha { // The destination is opaque (a video frame): alpha is written as 1. @@ -362,16 +354,12 @@ fn srgbToLinear(c: vec3f) -> vec3f { resources.bindGroupLayout = _bindGroupLayout; resources.sampler = _sampler; resources.pipeline = device.CreateRenderPipeline(&pipelineDesc); - if (_onPipelineCreated) { - _onPipelineCreated(); - } auto inserted = _resources.emplace(static_cast(format), std::move(resources)); return inserted.first->second; } std::mutex _mutex; - std::function _onPipelineCreated; wgpu::ShaderModule _module; wgpu::BindGroupLayout _bindGroupLayout; wgpu::PipelineLayout _pipelineLayout; diff --git a/packages/webgpu/tests/cpp/CMakeLists.txt b/packages/webgpu/tests/cpp/CMakeLists.txt index cc766d814..55b423784 100644 --- a/packages/webgpu/tests/cpp/CMakeLists.txt +++ b/packages/webgpu/tests/cpp/CMakeLists.txt @@ -29,9 +29,7 @@ FetchContent_MakeAvailable(googletest) add_executable( cpp-tests DawnBlobCacheTest.cpp - IdleFlusherTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/rnwgpu/DawnBlobCache.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/rnwgpu/IdleFlusher.cpp ) target_include_directories( cpp-tests diff --git a/packages/webgpu/tests/cpp/IdleFlusherTest.cpp b/packages/webgpu/tests/cpp/IdleFlusherTest.cpp deleted file mode 100644 index 3080e9b55..000000000 --- a/packages/webgpu/tests/cpp/IdleFlusherTest.cpp +++ /dev/null @@ -1,376 +0,0 @@ -#include - -#include -#include -#include -#include -#include -#include - -#include "IdleFlusher.h" - -namespace { - -using rnwgpu::IdleFlusher; -using std::chrono::milliseconds; - -void sleepFor(int ms) { std::this_thread::sleep_for(milliseconds(ms)); } - -// Waits for `condition`, for as long as `ms`. -template bool waitFor(Condition condition, int ms) { - const auto deadline = std::chrono::steady_clock::now() + milliseconds(ms); - while (!condition()) { - if (std::chrono::steady_clock::now() > deadline) { - return false; - } - sleepFor(1); - } - return true; -} - -TEST(IdleFlusher, FlushesOnceAfterThingsGoQuiet) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(30), milliseconds(1000), - true); - - flusher.touch(); - flusher.touch(); - flusher.touch(); - - ASSERT_TRUE(waitFor([&] { return flushes == 1; }, 2000)); - sleepFor(150); - EXPECT_EQ(flushes, 1); - flusher.stop(); -} - -TEST(IdleFlusher, FlushesAgainForTouchesAfterAFlush) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(20), milliseconds(1000), - true); - - flusher.touch(); - ASSERT_TRUE(waitFor([&] { return flushes == 1; }, 2000)); - flusher.touch(); - - EXPECT_TRUE(waitFor([&] { return flushes == 2; }, 2000)); - flusher.stop(); -} - -TEST(IdleFlusher, NeverFlushesWithoutATouch) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(10), milliseconds(50), - true); - - sleepFor(150); - flusher.flushNow(); - - EXPECT_EQ(flushes, 0); - flusher.stop(); -} - -// Touches that never stop coming must not hold the flush back for ever. -TEST(IdleFlusher, FlushesWhileTouchesKeepComingOnceTheDelayIsUp) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(400), milliseconds(1000), - true); - - const auto start = std::chrono::steady_clock::now(); - auto lastTouch = start; - std::chrono::milliseconds longestGap{0}; - int flushesBeforeTheDelay = -1; - // Touches until two flushes have happened, with a generous limit for a - // machine that cannot keep up. - while (flushes < 2 && - std::chrono::steady_clock::now() - start < milliseconds(15000)) { - const auto now = std::chrono::steady_clock::now(); - longestGap = std::max( - longestGap, std::chrono::duration_cast(now - lastTouch)); - lastTouch = now; - flusher.touch(); - sleepFor(10); - const auto elapsed = std::chrono::steady_clock::now() - start; - if (flushesBeforeTheDelay < 0 && elapsed > milliseconds(500) && - elapsed < milliseconds(900)) { - flushesBeforeTheDelay = flushes; - } - } - - // Only a run that kept touching without a pause as long as the quiet time - // says anything about the delay: a machine that stalled that long may have - // flushed for being quiet. - if (longestGap < milliseconds(300) && flushesBeforeTheDelay >= 0) { - EXPECT_EQ(flushesBeforeTheDelay, 0); - } - EXPECT_GE(flushes, 2); - flusher.stop(); -} - -TEST(IdleFlusher, DoesNotFlushByItselfWithoutABackground) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(10), milliseconds(50), - false); - - flusher.touch(); - sleepFor(150); - - EXPECT_EQ(flushes, 0); -} - -TEST(IdleFlusher, FlushesNowOnTheCallingThreadOnlyWhatWasTouched) { - std::thread::id flushedOn; - int flushes = 0; - IdleFlusher flusher( - [&] { - flushes++; - flushedOn = std::this_thread::get_id(); - }, - milliseconds(10), milliseconds(50), false); - - flusher.flushNow(); - EXPECT_EQ(flushes, 0); - - flusher.touch(); - flusher.flushNow(); - EXPECT_EQ(flushes, 1); - EXPECT_EQ(flushedOn, std::this_thread::get_id()); - - flusher.flushNow(); - EXPECT_EQ(flushes, 1); -} - -TEST(IdleFlusher, FlushesNothingOnceCancelled) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(20), milliseconds(100), - true); - - flusher.touch(); - flusher.cancel(); - flusher.touch(); - flusher.flushNow(); - sleepFor(150); - - EXPECT_EQ(flushes, 0); - flusher.stop(); -} - -// What the flush uses (a device) may go as soon as stop() returns. -TEST(IdleFlusher, StopWaitsForAFlushInProgress) { - std::promise started; - std::promise release; - auto released = release.get_future().share(); - IdleFlusher flusher( - [&started, released] { - started.set_value(); - released.wait(); - }, - milliseconds(10), milliseconds(50), true); - flusher.touch(); - started.get_future().wait(); - - std::atomic stopped{false}; - std::thread stopping([&] { - flusher.stop(); - stopped = true; - }); - sleepFor(100); - EXPECT_FALSE(stopped); - - release.set_value(); - stopping.join(); - EXPECT_TRUE(stopped); -} - -TEST(IdleFlusher, StopReleasesWhatTheFlushHolds) { - auto held = std::make_shared(0); - IdleFlusher flusher([held] {}, milliseconds(10), milliseconds(50), true); - EXPECT_EQ(held.use_count(), 2); - - flusher.stop(); - - EXPECT_EQ(held.use_count(), 1); -} - -// Two runtimes can destroy a device at once. -TEST(IdleFlusher, MayBeStoppedFromSeveralThreadsAtOnce) { - for (int i = 0; i < 50; i++) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(1), milliseconds(5), - true); - flusher.touch(); - std::thread first([&] { flusher.stop(); }); - std::thread second([&] { flusher.finish(); }); - flusher.stop(); - first.join(); - second.join(); - } -} - -// The flush a device promises at destroy() must not be lost to the thread that -// was just about to run it. -TEST(IdleFlusher, FinishFlushesWhatWasTouchedExactlyOnce) { - for (int i = 0; i < 200; i++) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(1), milliseconds(5), - true); - flusher.touch(); - std::this_thread::sleep_for(std::chrono::microseconds(i % 40 * 50)); - - flusher.finish(); - - ASSERT_EQ(flushes, 1) << "round " << i; - sleepFor(0); - } -} - -TEST(IdleFlusher, FinishFlushesNothingThatWasNotTouched) { - std::atomic flushes{0}; - IdleFlusher flusher([&] { flushes++; }, milliseconds(1), milliseconds(5), - true); - - flusher.finish(); - flusher.touch(); - flusher.finish(); - - EXPECT_EQ(flushes, 0); -} - -// A device lost callback can drop the last reference to the device while Dawn -// holds a lock the flush waits for, so destroying must never wait for one. -TEST(IdleFlusher, DestroyingDoesNotWaitForAFlushInProgress) { - std::promise started; - std::promise release; - auto released = release.get_future().share(); - std::atomic flushed{false}; - { - IdleFlusher flusher( - [&started, released, &flushed] { - started.set_value(); - released.wait(); - flushed = true; - }, - milliseconds(1), milliseconds(5), true); - flusher.touch(); - started.get_future().wait(); - } - - EXPECT_FALSE(flushed); - release.set_value(); - EXPECT_TRUE(waitFor([&] { return flushed.load(); }, 2000)); -} - -TEST(IdleFlusher, KnowsWhenItIsInAFlush) { - bool insideNow = false; - std::atomic insideBackground{false}; - std::atomic backgroundFlushes{0}; - bool background = false; - IdleFlusher flusher( - [&] { - if (background) { - insideBackground = IdleFlusher::flushing(); - backgroundFlushes++; - } else { - insideNow = IdleFlusher::flushing(); - } - }, - milliseconds(300), milliseconds(1000), true); - - EXPECT_FALSE(IdleFlusher::flushing()); - flusher.touch(); - flusher.flushNow(); - EXPECT_TRUE(insideNow); - EXPECT_FALSE(IdleFlusher::flushing()); - - background = true; - flusher.touch(); - ASSERT_TRUE(waitFor([&] { return backgroundFlushes == 1; }, 2000)); - EXPECT_TRUE(insideBackground); - flusher.stop(); -} - -// A device lost callback can run on the thread that is flushing. -TEST(IdleFlusher, MayBeCancelledFromInsideAFlush) { - auto held = std::make_shared(0); - std::atomic self{nullptr}; - std::atomic flushes{0}; - IdleFlusher flusher( - [held, &self, &flushes] { - flushes++; - self.load()->cancel(); - }, - milliseconds(1), milliseconds(5), true); - self = &flusher; - EXPECT_EQ(held.use_count(), 2); - - flusher.touch(); - ASSERT_TRUE(waitFor([&] { return flushes == 1; }, 2000)); - - // The flush function goes once the flush that cancelled has ended. - EXPECT_TRUE(waitFor([&] { return held.use_count() == 1; }, 2000)); - flusher.touch(); - sleepFor(50); - EXPECT_EQ(flushes, 1); -} - -TEST(IdleFlusher, MayBeCancelledFromWhatReleasingTheFlushSetsOff) { - struct CancelsOnRelease { - explicit CancelsOnRelease(std::atomic *flusher) - : flusher(flusher) {} - CancelsOnRelease(const CancelsOnRelease &) = default; - ~CancelsOnRelease() { - if (IdleFlusher *target = flusher->load()) { - target->cancel(); - } - } - std::atomic *flusher; - }; - std::atomic self{nullptr}; - { - auto releases = std::make_shared(&self); - IdleFlusher flusher([releases] {}, milliseconds(1), milliseconds(5), false); - self = &flusher; - releases.reset(); - - flusher.touch(); - flusher.stop(); - self = nullptr; - } -} - -// What the flush holds goes when the flush that was running at cancel() ends, -// not only at stop(). -TEST(IdleFlusher, ReleasesTheFlushAfterAFlushNowThatWasCancelled) { - std::promise started; - std::promise release; - auto released = release.get_future().share(); - auto held = std::make_shared(0); - IdleFlusher flusher( - [held, &started, released] { - started.set_value(); - released.wait(); - }, - milliseconds(1), milliseconds(5), false); - flusher.touch(); - std::thread flushing([&] { flusher.flushNow(); }); - started.get_future().wait(); - - flusher.cancel(); - EXPECT_EQ(held.use_count(), 2); - release.set_value(); - flushing.join(); - - EXPECT_EQ(held.use_count(), 1); -} - -TEST(IdleFlusher, StopsFromADestructorWithAFlushPending) { - std::atomic flushes{0}; - { - IdleFlusher flusher([&] { flushes++; }, milliseconds(500), - milliseconds(1000), true); - flusher.touch(); - } - - sleepFor(50); - EXPECT_EQ(flushes, 0); -} - -} // namespace From ea8db8c985e49f015365c724dfbfaf85659ac742 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 12:19:00 +0100 Subject: [PATCH 17/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20flush=20the=20?= =?UTF-8?q?Vulkan=20pipeline=20cache=20every=20ten=20seconds,=20from=20any?= =?UTF-8?q?=20runtime,=20and=20never=20for=20an=20imported=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app killed in the foreground lost every pipeline built since launch: nothing flushed between onHostPause and destroy. GPUQueue::submit now asks its device to flush when ten seconds have passed since the last one. That is cheap: Dawn only serializes when a pipeline compiled since, and skips the store when the cache's size did not change. The pause flush posted to each device's runtime, but a worklet runtime of the Mailbox kind delivers a post only with its next operation, so a device created there never flushed. Devices with ImplicitDeviceSynchronization, the default, now flush on the calling thread; PerformIdleTasks takes the device's lock itself. Only devices without it are posted to their runtime. A device from importDevice() belongs to someone else: it keeps its origin and is never flushed, on destroy included. --- .../docs/getting-started/native-api.mdx | 2 +- packages/webgpu/android/cpp/cpp-adapter.cpp | 3 +- packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 5 +- packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 46 ++++++++++++++++--- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 44 ++++++++++++++---- packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp | 7 +++ packages/webgpu/cpp/rnwgpu/api/GPUQueue.h | 9 +++- packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h | 3 +- 8 files changed, 96 insertions(+), 23 deletions(-) diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index 567a76020..2e45d7129 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -93,7 +93,7 @@ See [Vision Camera](/docs/integrations/vision-camera) for an end-to-end example ## 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 the app goes to the background and when `device.destroy()` is called; an app killed while in the foreground keeps what was written before. 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. +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 every few seconds while pipelines are being built, when the app goes to the background and when `device.destroy()` is called, so an app killed while in the foreground loses at most the last few seconds of pipeline building. 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. diff --git a/packages/webgpu/android/cpp/cpp-adapter.cpp b/packages/webgpu/android/cpp/cpp-adapter.cpp index 2c07efd90..577bf3b3f 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -59,7 +59,8 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative( // 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 on the thread of its own runtime. +// 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 */) { diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index a44414957..e474b8345 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -337,8 +337,9 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( }, creationRuntime); - auto deviceHost = std::make_shared(std::move(device), - context, label); + auto deviceHost = + std::make_shared(std::move(device), context, label, + GPUDevice::Origin::Requested); *deviceLostBinding = deviceHost; // Register the device in the static registry so the uncaptured diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index b6c6159d3..890266820 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -1,5 +1,6 @@ #include "GPUDevice.h" +#include #include #include #include @@ -50,11 +51,34 @@ void defineHiddenProperty(jsi::Runtime &runtime, const jsi::Object &target, } // namespace void GPUDevice::flushPipelineCache() { - // Nothing is left to write once the device is lost or destroyed, and the - // backend may already be torn down. - if (!dawn::native::IsDeviceLost(_instance.Get())) { - dawn::native::PerformIdleTasks(_instance); + // An imported device's cache is its owner's: nothing chained the blob cache + // onto it, and its owner's own threads may be using it. Nothing is left to + // write once the device is lost or destroyed either: Dawn drops the + // pipeline cache with the backend. + if (_origin == Origin::Imported || + dawn::native::IsDeviceLost(_instance.Get())) { + return; + } + dawn::native::PerformIdleTasks(_instance); +} + +void GPUDevice::flushPipelineCacheIfDue() { + using Clock = std::chrono::steady_clock; + static constexpr auto kInterval = + std::chrono::duration_cast(std::chrono::seconds(10)) + .count(); + const auto now = Clock::now().time_since_epoch().count(); + auto last = _lastPipelineCacheFlush.load(std::memory_order_relaxed); + if (now - last < kInterval) { + return; } + // Threads submitting at once (ImplicitDeviceSynchronization) race for the + // flush, and the one that claims the time does it. + if (!_lastPipelineCacheFlush.compare_exchange_strong( + last, now, std::memory_order_relaxed)) { + return; + } + flushPipelineCache(); } void GPUDevice::flushPipelineCaches() { @@ -68,10 +92,20 @@ void GPUDevice::flushPipelineCaches() { } } for (const auto &device : devices) { + // Dawn locks a device that synchronizes itself around the flush, so this + // thread can do it, and does: the runtime's thread may be busy, and a + // runtime that polls its mailbox would hold the job until its next + // operation, which may never come before the process is killed. + if (device->_instance.HasFeature( + wgpu::FeatureName::ImplicitDeviceSynchronization)) { + device->flushPipelineCache(); + continue; + } if (!device->_async) { continue; } - // Held weakly: a device collected before its runtime gets to the job has + // A device used from its runtime's thread only is flushed there. Held + // weakly: a device collected before its runtime gets to the job has // nothing left to flush, and the job must not keep it alive. device->_async->post( [weak = std::weak_ptr(device)](jsi::Runtime &) { @@ -172,7 +206,7 @@ std::shared_ptr GPUDevice::getAdapterInfo() { std::shared_ptr GPUDevice::getQueue() { auto result = _instance.GetQueue(); return std::make_shared(_instance, result, _async, _videoFrameBlit, - _label); + _label, weak_from_this()); } std::shared_ptr GPUDevice::createCommandEncoder( diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index ba31bc493..367049a56 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -68,11 +70,15 @@ class GPUDevice : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPUDevice"; + // Where the device came from: requestDevice(), or another library through + // importDevice(). An imported device's pipeline cache is its owner's. + enum class Origin { Requested, Imported }; + explicit GPUDevice(wgpu::Device instance, std::shared_ptr async, - std::string label) + std::string label, Origin origin) : NativeObject(CLASS_NAME), _instance(instance), _async(async), - _label(label) {} + _label(label), _origin(origin) {} ~GPUDevice() override { // Unregister from the static registry @@ -302,16 +308,28 @@ class GPUDevice : public NativeObject { // cache (see DawnBlobCache). Dawn keeps that cache in memory and writes it // only when the embedder says the device is idle (the // vulkan_monolithic_pipeline_cache toggle says so), so without this a - // relaunch would start with an empty one. Nothing to do on other backends. - // Runs on the calling thread, which must be one that may use the device: - // the thread of its runtime, or any thread when the device synchronizes - // itself (ImplicitDeviceSynchronization). destroy() calls it; the app going - // to the background does too, through flushPipelineCaches(). + // relaunch would start with an empty one. Cheap when no pipeline was + // compiled since the last call: Dawn keeps a flag, and skips a cache whose + // size has not changed. Nothing to do on other backends, nor on an imported + // device, whose owner keeps its cache. Runs on the calling thread, which + // must be one that may use the device: the thread of its runtime, or any + // thread when the device synchronizes itself (ImplicitDeviceSynchronization). + // GPUQueue::submit calls it now and then (flushPipelineCacheIfDue), and + // destroy() does; the app going to the background does too, through + // flushPipelineCaches(). void flushPipelineCache(); - // Flushes every live device, each on the thread of the runtime it was - // created on. Any thread: the platform modules call it when the app goes to - // the background, the last moment to write before the process may be killed. + // flushPipelineCache(), at most once every few seconds. GPUQueue::submit + // calls it, so that a session that ends without destroy() or a pause (a + // crash, the system killing the app in the foreground) loses at most those + // seconds of pipeline building. Any thread that may use the device. + void flushPipelineCacheIfDue(); + + // Flushes every live device: here when the device synchronizes itself (the + // default), else on the thread of its runtime, which a runtime that polls + // its mailbox (a worklet runtime's) reaches with its next operation. Any + // thread: the platform modules call it when the app goes to the background, + // the last moment to write before the process may be killed. static void flushPipelineCaches(); private: @@ -324,6 +342,12 @@ class GPUDevice : public NativeObject { wgpu::Device _instance; std::shared_ptr _async; std::string _label; + const Origin _origin; + // When flushPipelineCacheIfDue() last flushed, in steady_clock ticks. A + // flush is claimed by swapping in the time, so that threads submitting at + // once flush once. + std::atomic _lastPipelineCacheFlush{ + std::chrono::steady_clock::now().time_since_epoch().count()}; // Renders native video frames into textures for // GPUQueue.copyExternalImageToTexture; shared by every GPUQueue wrapper of // this device so the pipelines are created once. diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp index f109fe86d..8b5114153 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp @@ -10,6 +10,7 @@ #include "Convertors.h" #include "EnforceRange.h" +#include "GPUDevice.h" #include "ImageBitmap.h" #include "PlatformContext.h" @@ -33,6 +34,12 @@ void GPUQueue::submit( return; } _instance.Submit(bufs_size, bufs.data()); + // Now and then, what the pipelines compiled since the last flush added to + // Dawn's pipeline cache goes to the disk (see + // GPUDevice::flushPipelineCacheIfDue). + if (auto device = _deviceHost.lock()) { + device->flushPipelineCacheIfDue(); + } } void GPUQueue::writeBuffer(std::shared_ptr buffer, diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h index 2fd093164..730378211 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h @@ -27,6 +27,8 @@ namespace rnwgpu { namespace jsi = facebook::jsi; +class GPUDevice; + class GPUQueue : public NativeObject { public: static constexpr const char *CLASS_NAME = "GPUQueue"; @@ -34,10 +36,10 @@ class GPUQueue : public NativeObject { explicit GPUQueue(wgpu::Device device, wgpu::Queue instance, std::shared_ptr async, std::shared_ptr videoFrameBlit, - std::string label) + std::string label, std::weak_ptr deviceHost) : NativeObject(CLASS_NAME), _device(device), _instance(instance), _async(async), _videoFrameBlit(std::move(videoFrameBlit)), - _label(label) {} + _label(label), _deviceHost(std::move(deviceHost)) {} public: std::string getBrand() { return CLASS_NAME; } @@ -100,6 +102,9 @@ class GPUQueue : public NativeObject { const wgpu::Extent3D &size); wgpu::Device _device; + // The device's wrapper, which submit() has flush its pipeline cache now and + // then. + std::weak_ptr _deviceHost; wgpu::Queue _instance; std::shared_ptr _async; std::shared_ptr _videoFrameBlit; diff --git a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h index 3b6ed80fa..4f668265f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h @@ -148,7 +148,8 @@ class RNWebGPU : public NativeObject { wgpu::Device device = wgpu::Device::Acquire(raw); auto ctx = async::RuntimeContext::getOrCreate( runtime, device.GetAdapter().GetInstance()); - return std::make_shared(device, ctx, "Imported Device"); + return std::make_shared(device, ctx, "Imported Device", + GPUDevice::Origin::Imported); } // Wrap an externally created WGPUTexture in a GPUTexture, TAKING OWNERSHIP From d19233a1d9cc510a7aec52a8ed4fe68fcce4bc82 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 12:19:00 +0100 Subject: [PATCH 18/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20keep=20the=20w?= =?UTF-8?q?riter=20thread=20alive=20through=20a=20failing=20store,=20and?= =?UTF-8?q?=20measure=20the=20cache=20again=20before=20dropping=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job that threw took the writer thread down with it, and when no thread could be started the jobs ran inline on Dawn's thread, under the device's lock. Jobs now run under try/catch, and are dropped without a thread. The byte count the writer keeps went stale when the OS purged the files but kept the directory, so stores were dropped from an empty cache. A store that would be dropped measures the directory first. Replacements no larger than the entry they replace stay exempt, as the header promises. Temporary names match exactly the one shape the cache writes; the test pins the older `...tmp` shape as a file the cache leaves alone. The test helpers that create and age files return whether they did, and every call asserts it. --- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 114 ++++++++++-------- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 53 +++++--- 2 files changed, 99 insertions(+), 68 deletions(-) diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index e8ae3e3d0..3cc937367 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -70,22 +70,20 @@ class Writer { return *writer; } + // Queues `job` for the thread. Without a thread to be had (a process at its + // limit) the jobs queued so far are dropped, not run here: a store run on + // Dawn's thread would keep it waiting for the disk, and two run at once + // would write the same temporary file. The next post tries for a thread + // again. void post(std::function job) { - std::unique_lock lock(_mutex); + std::lock_guard lock(_mutex); _queue.push_back(std::move(job)); if (!_started) { try { std::thread([this] { run(); }).detach(); _started = true; } catch (const std::system_error &) { - // No thread to be had (a process at its limit): the work runs here, - // and the next post tries for a thread again. - std::deque> queued = std::move(_queue); _queue.clear(); - lock.unlock(); - for (auto &queuedJob : queued) { - queuedJob(); - } return; } } @@ -111,7 +109,15 @@ class Writer { _queue.pop_front(); _busy = true; lock.unlock(); - job(); + try { + job(); + } catch (...) { + // Out of memory, most likely. The cache is best effort, and a write + // cut short leaves a temporary file the next setup removes; on a + // detached thread the exception would end the process instead. + } + // Frees the job's copy of the blob before the queue is locked again. + job = nullptr; lock.lock(); _busy = false; if (_queue.empty()) { @@ -155,6 +161,14 @@ off_t entryBytes(size_t keySize, size_t valueSize) { return static_cast(2 * sizeof(uint64_t) + keySize + valueSize); } +// What measure() found in the directory. +struct Contents { + std::vector entries; + off_t bytes = 0; +}; + +Contents measure(const std::string &dir); + // An entry is the key's size, the value's size, the key and the value. The // stored key is compared on load, so a hash collision reads as a miss, and so // does an entry shorter than its recorded size. Dawn asks for the size first @@ -205,20 +219,24 @@ void writeEntry(const std::string &dir, const std::vector &key, stat(path.c_str(), &replaced) == 0 ? replaced.st_size : 0; if (previousBytes == 0) { // A new entry, unless the system or the app cleared the cache directory, - // or its parent, during the session: then the entries counted so far are - // gone with it, and the directory has to be made again. + // or its parent, during the session: then it has to be made again. struct stat info{}; if (stat(dir.c_str(), &info) != 0) { - writer.total = 0; makeDirectories(dir); } } // Past the limit the cache stops growing until the next setup clears it. // Replacing an entry with one no larger is not growth: Dawn's monolithic - // pipeline cache is one entry stored over and over. - if (bytes > previousBytes && - writer.total + (bytes - previousBytes) > kMaxCacheBytes) { - return; + // pipeline cache is one entry stored over and over. Before a store is + // dropped the directory is measured again, since the entries counted so + // far may be gone: the system clears app caches as it needs the space (iOS + // removes the files, not always the directory), and so may the app. + const off_t growth = bytes > previousBytes ? bytes - previousBytes : 0; + if (growth > 0 && writer.total + growth > kMaxCacheBytes) { + writer.total = measure(dir).bytes; + if (writer.total + growth > kMaxCacheBytes) { + return; + } } const std::string temporary = path + "." + std::to_string(getpid()) + ".tmp"; FILE *file = fopen(temporary.c_str(), "wb"); @@ -256,9 +274,8 @@ void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, enum class Kind { Entry, Temporary, Other }; // An entry's name is 16 lowercase hex digits. A temporary file's is one -// followed by dot-separated numbers and ".tmp": the writing process's id, and -// in earlier versions a sequence number too. Anything else in the directory -// is not the cache's to remove. +// followed by a dot, the writing process's id and ".tmp". Anything else in +// the directory is not the cache's to remove. Kind kindOf(const std::string &name) { if (name.size() < 16) { return Kind::Other; @@ -272,38 +289,27 @@ Kind kindOf(const std::string &name) { if (name.size() == 16) { return Kind::Entry; } - size_t i = 16; - bool numbers = false; - while (i < name.size() && name[i] == '.') { - size_t j = i + 1; - while (j < name.size() && name[j] >= '0' && name[j] <= '9') { - j++; - } - if (j == i + 1) { - break; - } - numbers = true; - i = j; + if (name[16] != '.') { + return Kind::Other; + } + size_t i = 17; + while (i < name.size() && name[i] >= '0' && name[i] <= '9') { + i++; } - return numbers && name.compare(i, std::string::npos, ".tmp") == 0 + return i > 17 && name.compare(i, std::string::npos, ".tmp") == 0 ? Kind::Temporary : Kind::Other; } -// Removes the temporary files of writes a killed process left unfinished, and -// every entry when together they are past kMaxCacheBytes: a cache that has -// outgrown the limit holds mostly what the app no longer uses, and one launch -// that builds everything is the price of starting over. Leaves the writer's -// total at what the directory holds. Runs on the writer. -void tidy(const std::string &dir) { - Writer &writer = Writer::shared(); - writer.total = 0; +// The entries in the directory and their bytes together: none when the +// directory is gone. Removes the temporary files of writes a killed process +// left unfinished on the way. Runs on the writer. +Contents measure(const std::string &dir) { + Contents contents; DIR *listing = opendir(dir.c_str()); if (listing == nullptr) { - return; + return contents; } - std::vector entries; - off_t total = 0; const time_t now = time(nullptr); while (dirent *entry = readdir(listing)) { const std::string name = entry->d_name; @@ -322,17 +328,27 @@ void tidy(const std::string &dir) { } continue; } - total += info.st_size; - entries.push_back(path); + contents.bytes += info.st_size; + contents.entries.push_back(path); } closedir(listing); - if (total > kMaxCacheBytes) { - for (const auto &path : entries) { + return contents; +} + +// Measures the directory, and removes every entry when together they are past +// kMaxCacheBytes: a cache that has outgrown the limit holds mostly what the +// app no longer uses, and one launch that builds everything is the price of +// starting over. Leaves the writer's total at what the directory holds. Runs +// on the writer. +void tidy(const std::string &dir) { + Contents contents = measure(dir); + if (contents.bytes > kMaxCacheBytes) { + for (const auto &path : contents.entries) { remove(path.c_str()); } - total = 0; + contents.bytes = 0; } - writer.total = total; + Writer::shared().total = contents.bytes; } } // namespace diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index 745a24e58..d56fa8c50 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -42,10 +42,11 @@ void writeFile(const std::string &path, const std::string &contents) { } // A file of `bytes` that takes no disk: a hole, which counts towards the -// cache's limit all the same. -void writeSparseFile(const std::string &path, off_t bytes) { +// cache's limit all the same. False when the file system would not make one; +// a helper's own assertion would only return from the helper. +[[nodiscard]] bool writeSparseFile(const std::string &path, off_t bytes) { writeFile(path, "x"); - ASSERT_EQ(truncate(path.c_str(), bytes), 0); + return truncate(path.c_str(), bytes) == 0; } // The names in a directory, leaving out "." and "..". @@ -154,10 +155,10 @@ class DawnBlobCacheTest : public ::testing::Test { return paths; } - static void age(const std::string &path, time_t seconds) { + [[nodiscard]] static bool age(const std::string &path, time_t seconds) { const time_t then = time(nullptr) - seconds; utimbuf times{then, then}; - ASSERT_EQ(utime(path.c_str(), ×), 0); + return utime(path.c_str(), ×) == 0; } std::string _root; @@ -376,17 +377,12 @@ TEST_F(DawnBlobCacheTest, RecreatesItsParentsIfTheyAreCleared) { TEST_F(DawnBlobCacheTest, RemovesWritesAbandonedByAKilledProcess) { const auto abandoned = _dir + "/0123456789abcdef.4242.tmp"; - // The shape of an earlier version's temporary files. - const auto older = _dir + "/0123456789abcdef.4242.0.tmp"; writeFile(abandoned, "x"); - writeFile(older, "x"); - age(abandoned, 120); - age(older, 120); + ASSERT_TRUE(age(abandoned, 120)); setUpDirectory(_dir); EXPECT_FALSE(exists(abandoned)); - EXPECT_FALSE(exists(older)); } TEST_F(DawnBlobCacheTest, LeavesAWriteAnotherProcessMayStillFinish) { @@ -411,7 +407,7 @@ TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { const auto cache = callbacks(); cache.write({1}, {2, 3}); - writeSparseFile(_dir + "/00000000000000ff", 65 * kMegabyte); + ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000ff", 65 * kMegabyte)); setUpDirectory(_dir); @@ -424,7 +420,7 @@ TEST_F(DawnBlobCacheTest, ClearsACacheThatOutgrewItsLimitAtTheNextSetup) { TEST_F(DawnBlobCacheTest, DropsStoresPastTheLimitUntilTheNextSetup) { const auto cache = callbacks(); const auto large = _dir + "/00000000000000ff"; - writeSparseFile(large, 60 * kMegabyte); + ASSERT_TRUE(writeSparseFile(large, 60 * kMegabyte)); setUpDirectory(_dir); cache.write({1}, Bytes(9 * kMegabyte, 7)); @@ -432,7 +428,7 @@ TEST_F(DawnBlobCacheTest, DropsStoresPastTheLimitUntilTheNextSetup) { EXPECT_EQ(cache.size({1}), 0u); EXPECT_TRUE(exists(large)); - writeSparseFile(_dir + "/00000000000000fe", 5 * kMegabyte); + ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000fe", 5 * kMegabyte)); setUpDirectory(_dir); cache.write({1}, Bytes(9 * kMegabyte, 7)); @@ -444,7 +440,7 @@ TEST_F(DawnBlobCacheTest, DropsStoresPastTheLimitUntilTheNextSetup) { // not the cache growing. TEST_F(DawnBlobCacheTest, DoesNotCountAReplacementAsTheCacheGrowing) { const auto cache = callbacks(); - writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte); + ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte)); setUpDirectory(_dir); cache.write({1}, Bytes(3 * kMegabyte, 7)); @@ -459,7 +455,7 @@ TEST_F(DawnBlobCacheTest, DoesNotCountAReplacementAsTheCacheGrowing) { // longer counts against the limit. TEST_F(DawnBlobCacheTest, StartsCountingAgainWhenItsDirectoryWasCleared) { const auto cache = callbacks(); - writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte); + ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte)); setUpDirectory(_dir); removeTree(_dir); @@ -468,6 +464,20 @@ TEST_F(DawnBlobCacheTest, StartsCountingAgainWhenItsDirectoryWasCleared) { EXPECT_EQ(cache.size({1}), 9u * kMegabyte); } +// The system may clear the entries and leave the directory, as iOS does when +// it needs the space. +TEST_F(DawnBlobCacheTest, StartsCountingAgainWhenItsEntriesWereCleared) { + const auto cache = callbacks(); + const auto large = _dir + "/00000000000000ff"; + ASSERT_TRUE(writeSparseFile(large, 60 * kMegabyte)); + setUpDirectory(_dir); + ASSERT_EQ(unlink(large.c_str()), 0); + + cache.write({1}, Bytes(9 * kMegabyte, 7)); + + EXPECT_EQ(cache.size({1}), 9u * kMegabyte); +} + // Storing it would take most of the cache from all the other entries. TEST_F(DawnBlobCacheTest, DoesNotStoreAnEntryTooLargeForTheCache) { const auto cache = callbacks(); @@ -487,12 +497,16 @@ TEST_F(DawnBlobCacheTest, LeavesFilesThatAreNotTheCachesAlone) { const auto other = _dir + "/notes.txt"; const auto similar = _dir + "/0123456789abcdefg"; const auto lookalike = _dir + "/0123456789abcdef.bak"; + // Nearly a temporary file's name, but not one. + const auto numbered = _dir + "/0123456789abcdef.4242.0.tmp"; const auto large = _dir + "/00000000000000ff"; - writeSparseFile(other, 65 * kMegabyte); + ASSERT_TRUE(writeSparseFile(other, 65 * kMegabyte)); writeFile(similar, "x"); writeFile(lookalike, "x"); - age(lookalike, 3600); - writeSparseFile(large, 65 * kMegabyte); + writeFile(numbered, "x"); + ASSERT_TRUE(age(lookalike, 3600)); + ASSERT_TRUE(age(numbered, 3600)); + ASSERT_TRUE(writeSparseFile(large, 65 * kMegabyte)); setUpDirectory(_dir); @@ -500,6 +514,7 @@ TEST_F(DawnBlobCacheTest, LeavesFilesThatAreNotTheCachesAlone) { EXPECT_TRUE(exists(other)); EXPECT_TRUE(exists(similar)); EXPECT_TRUE(exists(lookalike)); + EXPECT_TRUE(exists(numbered)); } } // namespace From 92f55fa34fb0f50630f2a2ebbd4fdc4af57251c1 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 12:19:00 +0100 Subject: [PATCH 19/20] =?UTF-8?q?chore(=F0=9F=90=99):=20run=20the=20C++=20?= =?UTF-8?q?unit=20tests=20in=20a=20job=20of=20their=20own,=20with=20the=20?= =?UTF-8?q?googletest=20download=20cached?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests ran inside the Android build job, where a failure read as an Android failure and a cold googletest fetch added to the slowest job. They now run on ubuntu in under a minute, and actions/cache keeps the FetchContent checkout under node_modules/.cache/cpp-tests/_deps, keyed on the CMake file. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b0e231c4..d3ba5e1de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,6 +310,34 @@ 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 its + # download and stamps in place, a configure skips the download. + - name: Cache googletest + uses: actions/cache@v4 + with: + path: packages/webgpu/node_modules/.cache/cpp-tests/_deps + key: ${{ runner.os }}-cpp-tests-deps-${{ 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 @@ -348,10 +376,6 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Run the C++ unit tests - working-directory: packages/webgpu - run: yarn test:cpp - - name: Install JDK uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: From 560260e970bfd3a2e3686777f109bba91bf0c4b3 Mon Sep 17 00:00:00 2001 From: William Candillon Date: Mon, 2 Nov 2026 13:59:35 +0100 Subject: [PATCH 20/20] =?UTF-8?q?fix(=E2=9A=A1=EF=B8=8F):=20serialize=20th?= =?UTF-8?q?e=20pipeline=20cache=20off=20the=20frame=20thread,=20hold=20no?= =?UTF-8?q?=20device=20wrapper=20on=20the=20UI=20thread,=20and=20measure?= =?UTF-8?q?=20the=20cache=20again=20only=20when=20it=20changed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The periodic flush of a device that synchronizes itself goes to the blob cache's thread (DawnBlobCache::post), holding the device by its Dawn handle and a shared flag rather than its wrapper, which must only go on its runtime's thread. flushPipelineCaches() copies what it needs out of each wrapper under the registry's mutex (the registry now holds plain pointers and asks weak_from_this()), so the pause-time flush never takes a strong reference on the UI thread. A store the cache refuses as too large is counted on the calling thread, and a flush that sees the count move marks the device's pipeline cache as outgrown and stops having Dawn serialize it. In the cache, the directory is published before its tidying is posted, and a store finding its directory untidied (the tidying dropped for want of a thread, or the store bound for another directory) tidies it first. Before a store is dropped the directory is measured again only when its modification time changed since the last measure, or that measure is ten seconds old. Also: GPUQueue's members in initializer order, the docs on when the Vulkan cache data is written (a submit ten seconds after the last write), and the googletest cache in CI limited to the download and its sub-build. --- .github/workflows/ci.yml | 12 +- .../docs/getting-started/native-api.mdx | 2 +- packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp | 156 ++++++++++++++---- packages/webgpu/cpp/rnwgpu/DawnBlobCache.h | 20 ++- packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp | 3 +- packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp | 92 ++++++++--- packages/webgpu/cpp/rnwgpu/api/GPUDevice.h | 44 +++-- packages/webgpu/cpp/rnwgpu/api/GPUQueue.h | 6 +- .../webgpu/tests/cpp/DawnBlobCacheTest.cpp | 37 ++++- 9 files changed, 292 insertions(+), 80 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3ba5e1de..81848e78a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,13 +326,17 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} - # FetchContent downloads googletest into the build directory; with its - # download and stamps in place, a configure skips the download. + # 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 - key: ${{ runner.os }}-cpp-tests-deps-${{ hashFiles('packages/webgpu/tests/cpp/CMakeLists.txt') }} + 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 diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index 2e45d7129..f0092ce91 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -93,7 +93,7 @@ See [Vision Camera](/docs/integrations/vision-camera) for an end-to-end example ## 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 every few seconds while pipelines are being built, when the app goes to the background and when `device.destroy()` is called, so an app killed while in the foreground loses at most the last few seconds of pipeline building. 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. +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. diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp index 3cc937367..8dca608d4 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -39,6 +40,15 @@ constexpr off_t kMaxEntryBytes = kMaxCacheBytes / 2; // than this loses its entry (its rename fails), which only costs a cache miss. constexpr time_t kAbandonedWriteSeconds = 60; +// Before a store is dropped for taking the cache past its limit, the +// directory is measured again, in case the entries counted so far are gone. +// Not before every one, though: a session at its limit may be refused a store +// per shader it compiles, and a measure reads every entry. The directory is +// measured again when its modification time changed since the last measure +// (an entry written to it or removed from it, by anyone, changes it) and, as +// that time can be coarse, when the last measure is this old. +constexpr std::chrono::seconds kMeasureAgainAfter{10}; + // Never destroyed: a store can still be queued when the process starts to // exit. std::mutex &directoryMutex() { @@ -56,6 +66,10 @@ std::string directory() { return directorySlot(); } +// Stores refused on this thread because the entry was too large. Dawn stores +// on the thread that has it write (see refusedStoresOnThisThread()). +thread_local uint64_t refusedStores = 0; + // Everything that changes the directory runs here, one job after another: the // tidying of setDirectory() and the stores. Dawn stores from the thread that // compiles, a JS thread included, and holds the device's lock while it stores @@ -74,7 +88,8 @@ class Writer { // limit) the jobs queued so far are dropped, not run here: a store run on // Dawn's thread would keep it waiting for the disk, and two run at once // would write the same temporary file. The next post tries for a thread - // again. + // again, and the first store to run tidies the directory in place of the + // tidying dropped (see writeEntry). void post(std::function job) { std::lock_guard lock(_mutex); _queue.push_back(std::move(job)); @@ -96,10 +111,17 @@ class Writer { _idle.wait(lock, [this] { return _queue.empty() && !_busy; }); } - // Bytes of entries in the directory: what tidying found, plus what this - // session stored since. Read and written by jobs only. + // The directory last tidied, and the bytes of the entries in it: what the + // tidying found, plus what this session stored since. Read and written by + // jobs only. + std::string tidied; off_t total = 0; + // When the directory was last measured: its modification time then, and the + // time (see kMeasureAgainAfter). Read and written by jobs only. + timespec measuredModification{}; + std::chrono::steady_clock::time_point measuredAt{}; + private: void run() { std::unique_lock lock(_mutex); @@ -161,6 +183,14 @@ off_t entryBytes(size_t keySize, size_t valueSize) { return static_cast(2 * sizeof(uint64_t) + keySize + valueSize); } +timespec modificationTime(const struct stat &info) { +#ifdef __APPLE__ + return info.st_mtimespec; +#else + return info.st_mtim; +#endif +} + // What measure() found in the directory. struct Contents { std::vector entries; @@ -168,6 +198,26 @@ struct Contents { }; Contents measure(const std::string &dir); +void tidy(const std::string &dir); + +// Whether the directory may hold other entries than the last measure found: +// it was changed since, or is gone, or the measure is old enough for its +// modification time to be the same as then although it was changed (see +// kMeasureAgainAfter). Runs on the writer. +bool changedSinceMeasured(const std::string &dir) { + const Writer &writer = Writer::shared(); + if (std::chrono::steady_clock::now() - writer.measuredAt > + kMeasureAgainAfter) { + return true; + } + struct stat info{}; + if (stat(dir.c_str(), &info) != 0) { + return true; + } + const timespec modified = modificationTime(info); + return modified.tv_sec != writer.measuredModification.tv_sec || + modified.tv_nsec != writer.measuredModification.tv_nsec; +} // An entry is the key's size, the value's size, the key and the value. The // stored key is compared on load, so a hash collision reads as a miss, and so @@ -212,6 +262,13 @@ size_t loadEntry(size_t keySize, const uint8_t *key, size_t valueSize, void writeEntry(const std::string &dir, const std::vector &key, const std::vector &value) { Writer &writer = Writer::shared(); + // The tidying of setDirectory() has not run for this directory: it was + // dropped for want of a thread then, or this store was queued ahead of it, + // or the store is bound for a directory set before the current one. Done + // now, so that the store is counted against its own directory. + if (writer.tidied != dir) { + tidy(dir); + } const std::string path = entryPath(dir, key.size(), key.data()); const off_t bytes = entryBytes(key.size(), value.size()); struct stat replaced{}; @@ -228,12 +285,15 @@ void writeEntry(const std::string &dir, const std::vector &key, // Past the limit the cache stops growing until the next setup clears it. // Replacing an entry with one no larger is not growth: Dawn's monolithic // pipeline cache is one entry stored over and over. Before a store is - // dropped the directory is measured again, since the entries counted so - // far may be gone: the system clears app caches as it needs the space (iOS - // removes the files, not always the directory), and so may the app. + // dropped the directory is measured again, if it may have changed, since + // the entries counted so far may be gone: the system clears app caches as + // it needs the space (iOS removes the files, not always the directory), and + // so may the app. const off_t growth = bytes > previousBytes ? bytes - previousBytes : 0; if (growth > 0 && writer.total + growth > kMaxCacheBytes) { - writer.total = measure(dir).bytes; + if (changedSinceMeasured(dir)) { + writer.total = measure(dir).bytes; + } if (writer.total + growth > kMaxCacheBytes) { return; } @@ -261,7 +321,11 @@ void storeEntry(size_t keySize, const uint8_t *key, size_t valueSize, const uint8_t *value, void * /* userdata1 */, void * /* userdata2 */) { const std::string dir = directory(); - if (dir.empty() || entryBytes(keySize, valueSize) > kMaxEntryBytes) { + if (dir.empty()) { + return; + } + if (entryBytes(keySize, valueSize) > kMaxEntryBytes) { + refusedStores++; return; } Writer::shared().post( @@ -301,37 +365,46 @@ Kind kindOf(const std::string &name) { : Kind::Other; } +// Remembers the directory as measured now, as it is (see +// changedSinceMeasured). Runs on the writer. +void noteMeasured(const std::string &dir) { + Writer &writer = Writer::shared(); + struct stat info{}; + writer.measuredModification = + stat(dir.c_str(), &info) == 0 ? modificationTime(info) : timespec{}; + writer.measuredAt = std::chrono::steady_clock::now(); +} + // The entries in the directory and their bytes together: none when the // directory is gone. Removes the temporary files of writes a killed process // left unfinished on the way. Runs on the writer. Contents measure(const std::string &dir) { Contents contents; - DIR *listing = opendir(dir.c_str()); - if (listing == nullptr) { - return contents; - } - const time_t now = time(nullptr); - while (dirent *entry = readdir(listing)) { - const std::string name = entry->d_name; - const Kind kind = kindOf(name); - if (kind == Kind::Other) { - continue; - } - const std::string path = dir + "/" + name; - struct stat info{}; - if (stat(path.c_str(), &info) != 0) { - continue; - } - if (kind == Kind::Temporary) { - if (now - info.st_mtime > kAbandonedWriteSeconds) { - remove(path.c_str()); + if (DIR *listing = opendir(dir.c_str())) { + const time_t now = time(nullptr); + while (dirent *entry = readdir(listing)) { + const std::string name = entry->d_name; + const Kind kind = kindOf(name); + if (kind == Kind::Other) { + continue; + } + const std::string path = dir + "/" + name; + struct stat info{}; + if (stat(path.c_str(), &info) != 0) { + continue; + } + if (kind == Kind::Temporary) { + if (now - info.st_mtime > kAbandonedWriteSeconds) { + remove(path.c_str()); + } + continue; } - continue; + contents.bytes += info.st_size; + contents.entries.push_back(path); } - contents.bytes += info.st_size; - contents.entries.push_back(path); + closedir(listing); } - closedir(listing); + noteMeasured(dir); return contents; } @@ -341,14 +414,17 @@ Contents measure(const std::string &dir) { // starting over. Leaves the writer's total at what the directory holds. Runs // on the writer. void tidy(const std::string &dir) { + Writer &writer = Writer::shared(); Contents contents = measure(dir); if (contents.bytes > kMaxCacheBytes) { for (const auto &path : contents.entries) { remove(path.c_str()); } contents.bytes = 0; + noteMeasured(dir); } - Writer::shared().total = contents.bytes; + writer.total = contents.bytes; + writer.tidied = dir; } } // namespace @@ -364,11 +440,17 @@ void DawnBlobCache::setDirectory(const std::string &dir) { usable.clear(); } } + { + std::lock_guard lock(directoryMutex()); + directorySlot() = usable; + } + // Posted once the directory is in use, so that a store that reaches the + // writer first is bound for this directory, and counted by the tidying. A + // store still bound for the previous directory, queued after the tidying, + // is counted against that one (see writeEntry). if (!usable.empty()) { Writer::shared().post([usable] { tidy(usable); }); } - std::lock_guard lock(directoryMutex()); - directorySlot() = usable; } bool DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, @@ -384,6 +466,12 @@ bool DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, return true; } +void DawnBlobCache::post(std::function job) { + Writer::shared().post(std::move(job)); +} + +uint64_t DawnBlobCache::refusedStoresOnThisThread() { return refusedStores; } + void DawnBlobCache::waitForWrites() { Writer::shared().wait(); } } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h index 686e9bf37..50c0f3b75 100644 --- a/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include "webgpu/webgpu_cpp.h" @@ -51,9 +53,23 @@ class DawnBlobCache { static bool chain(wgpu::DeviceDescriptor &descriptor, WGPUDawnCacheDeviceDescriptor &storage); + // Runs `job` on the cache's thread, after the jobs queued so far (the + // tidying, the stores), or not at all when no thread can be had. GPUDevice + // has Dawn write a device's pipeline cache there (flushPipelineCacheIfDue), + // so that the thread submitting a frame does not wait while Dawn serializes + // it. + static void post(std::function job); + + // How many stores asked for on the calling thread the cache has refused + // because the entry was too large (see setDirectory). Dawn stores on the + // thread that has it write, so a flush (GPUDevice::flushPipelineCache) that + // sees this grow learns that the device's pipeline cache has outgrown what + // the cache keeps, and stops having Dawn serialize it. + static uint64_t refusedStoresOnThisThread(); + // Blocks until the cache's thread has done everything asked of it so far: - // the tidying of setDirectory() and the stores. Nothing in an app needs to - // wait for a write; tests do. + // the tidying of setDirectory(), the stores and the posted jobs. Nothing in + // an app needs to wait for a write; tests do. static void waitForWrites(); }; diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index e474b8345..91d78e20f 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp @@ -344,7 +344,8 @@ async::AsyncTaskHandle GPUAdapter::requestDevice( // Register the device in the static registry so the uncaptured // error callback can find it - GPUDevice::registerDevice(deviceHost->get().Get(), deviceHost); + GPUDevice::registerDevice(deviceHost->get().Get(), + deviceHost.get()); resolve([deviceHost = std::move(deviceHost)]( jsi::Runtime &runtime) mutable { diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp index 890266820..723027a66 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -1,6 +1,8 @@ #include "GPUDevice.h" +#include #include +#include #include #include #include @@ -13,6 +15,7 @@ #include "dawn/native/DawnNative.h" #include "Convertors.h" +#include "DawnBlobCache.h" #include "JSIConverter.h" #include "GPUFeatures.h" @@ -48,21 +51,44 @@ void defineHiddenProperty(jsi::Runtime &runtime, const jsi::Object &target, jsi::String::createFromUtf8(runtime, name), descriptor); } +// Has Dawn write what its pipeline cache holds to the blob cache. Nothing is +// left to write once the device is lost or destroyed (Dawn drops the pipeline +// cache with the backend), and nothing is written once the cache has refused +// its data as too large. Any thread that may use the device. +void flushDawnPipelineCache(const wgpu::Device &device, + std::atomic &outgrown) { + if (outgrown.load(std::memory_order_relaxed) || + dawn::native::IsDeviceLost(device.Get())) { + return; + } + // Dawn stores on this thread, before PerformIdleTasks returns, and stores + // nothing but the pipeline cache on idle: a store refused as too large on + // the way is the pipeline cache's, and from then on serializing it (Dawn + // copies the whole of it out of the driver, hashes it and copies it again) + // would be for nothing. + const uint64_t refused = DawnBlobCache::refusedStoresOnThisThread(); + dawn::native::PerformIdleTasks(device); + if (DawnBlobCache::refusedStoresOnThisThread() != refused) { + outgrown.store(true, std::memory_order_relaxed); + } +} + } // namespace void GPUDevice::flushPipelineCache() { // An imported device's cache is its owner's: nothing chained the blob cache - // onto it, and its owner's own threads may be using it. Nothing is left to - // write once the device is lost or destroyed either: Dawn drops the - // pipeline cache with the backend. - if (_origin == Origin::Imported || - dawn::native::IsDeviceLost(_instance.Get())) { + // onto it, and its owner's own threads may be using it. + if (_origin == Origin::Imported) { return; } - dawn::native::PerformIdleTasks(_instance); + flushDawnPipelineCache(_instance, *_pipelineCacheOutgrown); } void GPUDevice::flushPipelineCacheIfDue() { + if (_origin == Origin::Imported || + _pipelineCacheOutgrown->load(std::memory_order_relaxed)) { + return; + } using Clock = std::chrono::steady_clock; static constexpr auto kInterval = std::chrono::duration_cast(std::chrono::seconds(10)) @@ -78,41 +104,69 @@ void GPUDevice::flushPipelineCacheIfDue() { last, now, std::memory_order_relaxed)) { return; } + // Off the submitting thread when Dawn's lock lets any thread use the + // device. The job holds the device by its Dawn handle, not this wrapper: it + // may outlive the wrapper, which must only go on its runtime's thread (see + // flushPipelineCaches). + if (_instance.HasFeature(wgpu::FeatureName::ImplicitDeviceSynchronization)) { + DawnBlobCache::post( + [device = _instance, outgrown = _pipelineCacheOutgrown] { + flushDawnPipelineCache(device, *outgrown); + }); + return; + } flushPipelineCache(); } void GPUDevice::flushPipelineCaches() { - std::vector> devices; + // What flushing a device takes, copied out of its wrapper under the + // registry's mutex, rather than the wrapper itself: a strong reference to + // the wrapper released on this thread (the platform's UI thread) could be + // its last, and a wrapper, with its JSI members, must only go on its + // runtime's thread. + struct Flush { + wgpu::Device device; + std::shared_ptr> outgrown; + std::shared_ptr async; + std::weak_ptr wrapper; + }; + std::vector flushes; { std::lock_guard lock(getRegistryMutex()); for (const auto &entry : getRegistry()) { - if (auto device = entry.second.lock()) { - devices.push_back(std::move(device)); + GPUDevice *device = entry.second; + // A wrapper on its way out (its last reference gone, its destructor + // about to unregister it) has nothing left to flush; nor has an + // imported device, whose owner keeps its cache. + if (device->_origin == Origin::Imported || + device->weak_from_this().expired()) { + continue; } + flushes.push_back({device->_instance, device->_pipelineCacheOutgrown, + device->_async, device->weak_from_this()}); } } - for (const auto &device : devices) { + for (const auto &flush : flushes) { // Dawn locks a device that synchronizes itself around the flush, so this // thread can do it, and does: the runtime's thread may be busy, and a // runtime that polls its mailbox would hold the job until its next // operation, which may never come before the process is killed. - if (device->_instance.HasFeature( + if (flush.device.HasFeature( wgpu::FeatureName::ImplicitDeviceSynchronization)) { - device->flushPipelineCache(); + flushDawnPipelineCache(flush.device, *flush.outgrown); continue; } - if (!device->_async) { + if (!flush.async) { continue; } // A device used from its runtime's thread only is flushed there. Held // weakly: a device collected before its runtime gets to the job has // nothing left to flush, and the job must not keep it alive. - device->_async->post( - [weak = std::weak_ptr(device)](jsi::Runtime &) { - if (auto device = weak.lock()) { - device->flushPipelineCache(); - } - }); + flush.async->post([weak = flush.wrapper](jsi::Runtime &) { + if (auto device = weak.lock()) { + device->flushPipelineCache(); + } + }); } } diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index 367049a56..f224c73ad 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h @@ -85,9 +85,13 @@ class GPUDevice : public NativeObject { unregisterDevice(_instance.Get()); } - // Static registry for looking up GPUDevice from wgpu::Device in callbacks - static void registerDevice(WGPUDevice handle, - std::weak_ptr device) { + // Static registry for looking up GPUDevice from wgpu::Device in callbacks. + // Holds the wrapper by plain pointer: it unregisters itself in its + // destructor, under the registry's mutex, before any of its members go, so + // an entry's wrapper is alive for as long as the mutex is held, and its + // weak_from_this() tells whether it may still be shared (expired once it is + // being destroyed). + static void registerDevice(WGPUDevice handle, GPUDevice *device) { std::lock_guard lock(getRegistryMutex()); getRegistry()[handle] = device; } @@ -101,15 +105,14 @@ class GPUDevice : public NativeObject { std::lock_guard lock(getRegistryMutex()); auto it = getRegistry().find(handle); if (it != getRegistry().end()) { - return it->second.lock(); + return it->second->weak_from_this().lock(); } return nullptr; } private: - static std::unordered_map> & - getRegistry() { - static std::unordered_map> registry; + static std::unordered_map &getRegistry() { + static std::unordered_map registry; return registry; } @@ -311,18 +314,24 @@ class GPUDevice : public NativeObject { // relaunch would start with an empty one. Cheap when no pipeline was // compiled since the last call: Dawn keeps a flag, and skips a cache whose // size has not changed. Nothing to do on other backends, nor on an imported - // device, whose owner keeps its cache. Runs on the calling thread, which - // must be one that may use the device: the thread of its runtime, or any - // thread when the device synchronizes itself (ImplicitDeviceSynchronization). - // GPUQueue::submit calls it now and then (flushPipelineCacheIfDue), and - // destroy() does; the app going to the background does too, through - // flushPipelineCaches(). + // device, whose owner keeps its cache, nor once the blob cache has refused + // the pipeline cache's data as too large (see DawnBlobCache::setDirectory): + // serializing it again would be for nothing. Runs on the calling thread, + // which must be one that may use the device: the thread of its runtime, or + // any thread when the device synchronizes itself + // (ImplicitDeviceSynchronization). destroy() calls it, and so do + // flushPipelineCacheIfDue() and flushPipelineCaches(). void flushPipelineCache(); - // flushPipelineCache(), at most once every few seconds. GPUQueue::submit + // flushPipelineCache(), at most once every ten seconds. GPUQueue::submit // calls it, so that a session that ends without destroy() or a pause (a // crash, the system killing the app in the foreground) loses at most those - // seconds of pipeline building. Any thread that may use the device. + // seconds of pipeline building. Serializing the pipeline cache would hold up + // the frame being submitted (Dawn copies the whole of it out of the driver, + // hashes it and copies it again), so a device that synchronizes itself (the + // default) is flushed on the blob cache's thread instead; a device only its + // runtime's thread may use is flushed here. Any thread that may use the + // device. void flushPipelineCacheIfDue(); // Flushes every live device: here when the device synchronizes itself (the @@ -343,6 +352,11 @@ class GPUDevice : public NativeObject { std::shared_ptr _async; std::string _label; const Origin _origin; + // Set once the blob cache refused the pipeline cache's data as too large + // (see flushPipelineCache). Shared with the flushes on the cache's thread, + // which hold the device by its Dawn handle and may outlive this wrapper. + std::shared_ptr> _pipelineCacheOutgrown = + std::make_shared>(false); // When flushPipelineCacheIfDue() last flushed, in steady_clock ticks. A // flush is claimed by swapping in the time, so that threads submitting at // once flush once. diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h index 730378211..00f18a0ed 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h +++ b/packages/webgpu/cpp/rnwgpu/api/GPUQueue.h @@ -102,13 +102,13 @@ class GPUQueue : public NativeObject { const wgpu::Extent3D &size); wgpu::Device _device; - // The device's wrapper, which submit() has flush its pipeline cache now and - // then. - std::weak_ptr _deviceHost; wgpu::Queue _instance; std::shared_ptr _async; std::shared_ptr _videoFrameBlit; std::string _label; + // The device's wrapper, which submit() has flush its pipeline cache now and + // then. + std::weak_ptr _deviceHost; }; } // namespace rnwgpu diff --git a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp index d56fa8c50..6c045e43f 100644 --- a/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -452,10 +453,13 @@ TEST_F(DawnBlobCacheTest, DoesNotCountAReplacementAsTheCacheGrowing) { } // What the system cleared mid-session is no longer in the cache, so it no -// longer counts against the limit. +// longer counts against the limit. The cache tells a change by the +// directory's modification time, which clocks keep coarsely: the directory is +// aged, so that the clearing is never in the same tick as the setup. TEST_F(DawnBlobCacheTest, StartsCountingAgainWhenItsDirectoryWasCleared) { const auto cache = callbacks(); ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte)); + ASSERT_TRUE(age(_dir, 3600)); setUpDirectory(_dir); removeTree(_dir); @@ -470,6 +474,7 @@ TEST_F(DawnBlobCacheTest, StartsCountingAgainWhenItsEntriesWereCleared) { const auto cache = callbacks(); const auto large = _dir + "/00000000000000ff"; ASSERT_TRUE(writeSparseFile(large, 60 * kMegabyte)); + ASSERT_TRUE(age(_dir, 3600)); setUpDirectory(_dir); ASSERT_EQ(unlink(large.c_str()), 0); @@ -491,6 +496,36 @@ TEST_F(DawnBlobCacheTest, DoesNotStoreAnEntryTooLargeForTheCache) { EXPECT_EQ(entries().size(), 2u); } +// How a device learns that its pipeline cache has outgrown the cache, and +// stops serializing it: Dawn stores on the thread that flushes. +TEST_F(DawnBlobCacheTest, + CountsTheStoresItRefusesAsTooLargeOnTheCallingThread) { + const auto cache = callbacks(); + const uint64_t before = DawnBlobCache::refusedStoresOnThisThread(); + + cache.write({1}, {2, 3}); + EXPECT_EQ(DawnBlobCache::refusedStoresOnThisThread(), before); + + cache.write({2}, Bytes(33 * kMegabyte, 7)); + EXPECT_EQ(DawnBlobCache::refusedStoresOnThisThread(), before + 1); + + std::thread([&] { cache.storeOnly({3}, Bytes(33 * kMegabyte, 7)); }).join(); + EXPECT_EQ(DawnBlobCache::refusedStoresOnThisThread(), before + 1); +} + +// A device's pipeline cache is serialized on the cache's thread, behind the +// stores queued before it. +TEST_F(DawnBlobCacheTest, RunsAPostedJobAfterTheStoresQueuedBeforeIt) { + const auto cache = callbacks(); + std::atomic seen{0}; + + cache.storeOnly({1}, {2, 3}); + DawnBlobCache::post([&] { seen = cache.size({1}); }); + DawnBlobCache::waitForWrites(); + + EXPECT_EQ(seen, 2u); +} + // A wrong path must never turn clearing the cache into deleting someone's // files: only names shaped like the cache's own are counted or removed. TEST_F(DawnBlobCacheTest, LeavesFilesThatAreNotTheCachesAlone) {