diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d986f278d..81848e78a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,6 +310,38 @@ jobs: exit 1 } + # The C++ unit tests, built for the host (yarn test:cpp). A job of their + # own, so that a failing test is reported in a minute or two rather than + # after the Android job's setup, and so that a googletest download that + # fails does not fail the Android e2e run. + cpp-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + # FetchContent downloads googletest into the build directory; with the + # download, its stamps and the extracted source in place, a configure + # skips the download. Those only: the objects built from them belong to + # the runner image's compiler, which the key does not name. + - name: Cache googletest + uses: actions/cache@v4 + with: + path: | + packages/webgpu/node_modules/.cache/cpp-tests/_deps/googletest-src + packages/webgpu/node_modules/.cache/cpp-tests/_deps/googletest-subbuild + key: ${{ runner.os }}-cpp-tests-googletest-${{ hashFiles('packages/webgpu/tests/cpp/CMakeLists.txt') }} + + - name: Run the C++ unit tests + working-directory: packages/webgpu + run: yarn test:cpp + # Android e2e on a GitHub-hosted emulator. The runner has no GPU, so the guest # sees a SwiftShader (software) Vulkan 1.3 device through gfxstream, which Dawn # exposes as a fallback adapter. Specs that need hardware-only paths (hardware diff --git a/apps/docs/content/docs/getting-started/native-api.mdx b/apps/docs/content/docs/getting-started/native-api.mdx index 61054cf5d..a66ddc546 100644 --- a/apps/docs/content/docs/getting-started/native-api.mdx +++ b/apps/docs/content/docs/getting-started/native-api.mdx @@ -102,6 +102,22 @@ A device created by another library and wrapped with [`importDevice()`](#importi See [Vision Camera](/docs/integrations/vision-camera) for an end-to-end example applying WGSL effects to live camera frames, and [Native Extensions](/api/gpu-device-extensions) for the full API. +## Pipeline cache + +Every device `requestDevice()` creates keeps what Dawn produces while it builds shaders and pipelines in a cache on disk, in the app's cache directory, so a relaunch can reuse it instead of redoing the work. On Vulkan (Android) that includes the driver's pipeline cache data, which saves the most, since building pipelines is where Vulkan drivers spend their time. That data is written when `device.destroy()` is called, when the app goes to the background and, while the app submits work to the queue, about every ten seconds: a `queue.submit()` ten seconds or more after the last write triggers the next. An app killed while in the foreground therefore loses at most the last ten seconds of pipeline building, or everything built since its last submit. On Metal (iOS) the cache holds the shaders Dawn translates to MSL; Metal still builds the pipelines, with the help of its own system cache. + +The cache is kept to about 64 MB. An app that finds its cache past that size when it starts clears it, and builds everything once more; a session that would take the cache past that size stops adding to it until then. An entry larger than half of that is not stored, so the Vulkan pipeline cache data stops being updated if it ever grows past 32 MB. The system may also clear the cache, as it does any app cache. + +To opt a device out, disable it with a Dawn toggle: + +```tsx +const device = await adapter.requestDevice({ + dawnToggles: { enabledToggles: ["disable_blob_cache"] }, +}); +``` + +A device wrapped with [`importDevice()`](#importing-an-external-device) is created by its own library and does not use this cache. + ## Importing an external device `importDevice()` wraps a GPU device created by another native library into a regular `GPUDevice`. The device handle is passed as a `BigInt`, and the original owner keeps its own reference. diff --git a/packages/webgpu/CONTRIBUTING.md b/packages/webgpu/CONTRIBUTING.md index aa9a21588..9adcf50a0 100644 --- a/packages/webgpu/CONTRIBUTING.md +++ b/packages/webgpu/CONTRIBUTING.md @@ -16,7 +16,7 @@ Example app (from `apps/example/`): `yarn start` · `yarn ios` · `yarn android` From the repo root: `yarn lint` · `yarn tsc` · `yarn build:docs` -Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests) +Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests) · `yarn test:cpp` (C++ unit tests of `cpp/rnwgpu/DawnBlobCache.cpp`, built for the host with CMake and a C++20 compiler against Dawn's headers; one test after a build: `ctest --test-dir node_modules/.cache/cpp-tests -R `) `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/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..577bf3b3f 100644 --- a/packages/webgpu/android/cpp/cpp-adapter.cpp +++ b/packages/webgpu/android/cpp/cpp-adapter.cpp @@ -21,7 +21,9 @@ #include #include "AndroidPlatformContext.h" +#include "DawnBlobCache.h" #include "GPUCanvasContext.h" +#include "GPUDevice.h" #include "RNWebGPUManager.h" #include "WebGPURuntime.h" @@ -31,8 +33,18 @@ 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); + if (path == nullptr) { + // The JVM could not copy the string, and an OutOfMemoryError is pending: + // no further JNI call is allowed, so let it reach the caller. + return; + } + rnwgpu::DawnBlobCache::setDirectory(path); + env->ReleaseStringUTFChars(cacheDirectory, path); + } jobject globalBlobModule = env->NewGlobalRef(blobModule); jobject globalModule = env->NewGlobalRef(thiz); auto jsCallInvoker{ @@ -45,6 +57,16 @@ extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_initializeNative( platformContext); } +// WebGPUModule.onHostPause (UI thread): the app is going to the background, +// the last moment to write what Dawn's pipeline caches hold before the process +// may be killed. Each device flushes here, or on the thread of its own runtime +// when it does not synchronize itself. +extern "C" JNIEXPORT void JNICALL +Java_com_webgpu_WebGPUModule_flushPipelineCaches(JNIEnv * /* env */, + jobject /* this */) { + rnwgpu::GPUDevice::flushPipelineCaches(); +} + // Completion of WebGPUModule.snapshotView (UI thread). Reclaims the callbacks // handed to Java and delivers either the bitmap's pixels or the error. extern "C" JNIEXPORT void JNICALL Java_com_webgpu_WebGPUModule_onViewSnapshot( 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..d670caa86 100644 --- a/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java +++ b/packages/webgpu/android/src/main/java/com/webgpu/WebGPUModule.java @@ -8,12 +8,14 @@ import androidx.annotation.OptIn; +import java.io.File; import java.util.HashSet; import java.util.Set; import com.facebook.proguard.annotations.DoNotStrip; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.JavaScriptContextHolder; +import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.UIManager; import com.facebook.react.bridge.UiThreadUtil; @@ -26,7 +28,7 @@ import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder; @ReactModule(name = WebGPUModule.NAME) -public class WebGPUModule extends NativeWebGPUModuleSpec { +public class WebGPUModule extends NativeWebGPUModuleSpec implements LifecycleEventListener { static { System.loadLibrary("react-native-webgpu"); // Load the C++ library } @@ -35,8 +37,34 @@ public WebGPUModule(ReactApplicationContext reactContext) { super(reactContext); // Initialize the C++ module initialize(); + reactContext.addLifecycleEventListener(this); } + @Override + public void invalidate() { + getReactApplicationContext().removeLifecycleEventListener(this); + super.invalidate(); + } + + @Override + public void onHostResume() {} + + /** + * The app is going to the background: the last moment to write what Dawn's + * pipeline caches hold before the process may be killed (see + * GPUDevice::flushPipelineCaches). + */ + @Override + public void onHostPause() { + flushPipelineCaches(); + } + + @Override + public void onHostDestroy() {} + + @DoNotStrip + private native void flushPipelineCaches(); + @OptIn(markerClass = FrameworkAPI.class) @ReactMethod(isBlockingSynchronousMethod = true) public boolean install() { @@ -47,13 +75,16 @@ public boolean install() { if (blobModule == null) { throw new RuntimeException("React Native's BlobModule was not found!"); } - initializeNative(jsContext.get(), (CallInvokerHolderImpl) callInvokerHolder, blobModule); + // Dawn keeps the pipelines it compiles here, so a relaunch does not + // compile them again (see DawnBlobCache). + String cacheDirectory = new File(context.getCacheDir(), "webgpu-dawn").getAbsolutePath(); + initializeNative(jsContext.get(), (CallInvokerHolderImpl) callInvokerHolder, blobModule, cacheDirectory); return true; } @OptIn(markerClass = FrameworkAPI.class) @DoNotStrip - private native void initializeNative(long jsRuntime, CallInvokerHolderImpl jsInvoker, BlobModule blobModule); + private native void initializeNative(long jsRuntime, CallInvokerHolderImpl jsInvoker, BlobModule blobModule, String cacheDirectory); /** * Called from C++ (AndroidPlatformContext::snapshotView) on an arbitrary diff --git a/packages/webgpu/apple/WebGPUModule.mm b/packages/webgpu/apple/WebGPUModule.mm index d0334a0c8..da6679ff6 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" @@ -109,6 +110,20 @@ - (void)invalidate { std::shared_ptr platformContext = std::make_shared( std::move(viewLookup), std::move(blobManagerLookup)); + // Dawn keeps the pipelines it compiles here, so a relaunch does not compile + // them again (see DawnBlobCache). The caches directory is per app on iOS but + // shared on macOS, hence the bundle identifier. + NSString *caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, + NSUserDomainMask, YES) + .firstObject; + NSString *bundle = NSBundle.mainBundle.bundleIdentifier; + if (caches != nil) { + if (bundle != nil) { + caches = [caches stringByAppendingPathComponent:bundle]; + } + rnwgpu::DawnBlobCache::setDirectory( + [caches stringByAppendingPathComponent:@"webgpu-dawn"].UTF8String); + } webgpuManager = std::make_shared(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..8dca608d4 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.cpp @@ -0,0 +1,477 @@ +#include "DawnBlobCache.h" + +#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. +// 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 off_t kMaxEntryBytes = kMaxCacheBytes / 2; + +// 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; + +// 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() { + static std::mutex *const mutex = new std::mutex; + return *mutex; +} + +std::string &directorySlot() { + static std::string *const directory = new std::string; + return *directory; +} + +std::string directory() { + std::lock_guard lock(directoryMutex()); + 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 +// 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; + } + + // 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, 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)); + if (!_started) { + try { + std::thread([this] { run(); }).detach(); + _started = true; + } catch (const std::system_error &) { + _queue.clear(); + 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; }); + } + + // 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); + for (;;) { + _wake.wait(lock, [this] { return !_queue.empty(); }); + std::function job = std::move(_queue.front()); + _queue.pop_front(); + _busy = true; + lock.unlock(); + 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()) { + _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) { + 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) { + 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; +} + +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; + off_t bytes = 0; +}; + +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 +// 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 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(); + // 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{}; + 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 it has to be made again. + struct stat info{}; + if (stat(dir.c_str(), &info) != 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. Before a store is + // 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) { + if (changedSinceMeasured(dir)) { + 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"); + if (file == nullptr) { + return; + } + const uint64_t sizes[2] = {key.size(), value.size()}; + bool written = fwrite(sizes, sizeof(sizes), 1, file) == 1 && + fwrite(key.data(), 1, key.size(), file) == key.size() && + fwrite(value.data(), 1, value.size(), file) == value.size(); + written = fclose(file) == 0 && written; + if (!written || rename(temporary.c_str(), path.c_str()) != 0) { + remove(temporary.c_str()); + return; + } + 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()) { + return; + } + if (entryBytes(keySize, valueSize) > kMaxEntryBytes) { + refusedStores++; + return; + } + Writer::shared().post( + [dir, key = std::vector(key, key + keySize), + value = std::vector(value, value + valueSize)] { + writeEntry(dir, key, value); + }); +} + +enum class Kind { Entry, Temporary, Other }; + +// An entry's name is 16 lowercase hex digits. A temporary file's is one +// 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; + } + for (size_t i = 0; i < 16; i++) { + const char c = name[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + return Kind::Other; + } + } + if (name.size() == 16) { + return Kind::Entry; + } + if (name[16] != '.') { + return Kind::Other; + } + size_t i = 17; + while (i < name.size() && name[i] >= '0' && name[i] <= '9') { + i++; + } + return i > 17 && name.compare(i, std::string::npos, ".tmp") == 0 + ? Kind::Temporary + : 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; + 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; + } + contents.bytes += info.st_size; + contents.entries.push_back(path); + } + closedir(listing); + } + noteMeasured(dir); + 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) { + 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.total = contents.bytes; + writer.tidied = dir; +} + +} // namespace + +void DawnBlobCache::setDirectory(const std::string &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)) { + // Nowhere to keep blobs: leave the cache off rather than fail every + // load and store. + 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); }); + } +} + +bool DawnBlobCache::chain(wgpu::DeviceDescriptor &descriptor, + WGPUDawnCacheDeviceDescriptor &storage) { + if (directory().empty()) { + 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; +} + +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 new file mode 100644 index 000000000..50c0f3b75 --- /dev/null +++ b/packages/webgpu/cpp/rnwgpu/DawnBlobCache.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +#include "webgpu/webgpu_cpp.h" + +namespace rnwgpu { + +// 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, +// 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. +// +// 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. +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. 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 + // 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); + + // 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(), the stores and the posted jobs. Nothing in + // an app needs to wait for a write; tests do. + static void waitForWrites(); +}; + +} // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp index 97154b24a..eadab8b89 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" @@ -254,8 +255,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 GPUDevice::flushPipelineCache). static const char *const kAndroidEnabledToggles[] = { "ignore_imported_ahardwarebuffer_vulkan_image_size", "vulkan_monolithic_pipeline_cache"}; @@ -305,6 +307,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); return _instance.RequestDevice( &deviceDesc, wgpu::CallbackMode::WaitAnyOnly, [context, resolve, reject, label, creationRuntime, @@ -355,13 +363,15 @@ 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 // 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 dcd83c72e..723027a66 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp @@ -1,5 +1,8 @@ #include "GPUDevice.h" +#include +#include +#include #include #include #include @@ -9,7 +12,10 @@ #include +#include "dawn/native/DawnNative.h" + #include "Convertors.h" +#include "DawnBlobCache.h" #include "JSIConverter.h" #include "GPUFeatures.h" @@ -45,8 +51,125 @@ 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. + if (_origin == Origin::Imported) { + return; + } + 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)) + .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; + } + // 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() { + // 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()) { + 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 &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 (flush.device.HasFeature( + wgpu::FeatureName::ImplicitDeviceSynchronization)) { + flushDawnPipelineCache(flush.device, *flush.outgrown); + continue; + } + 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. + flush.async->post([weak = flush.wrapper](jsi::Runtime &) { + if (auto device = weak.lock()) { + device->flushPipelineCache(); + } + }); + } +} + void GPUDevice::notifyDeviceLost(wgpu::DeviceLostReason reason, std::string message) { std::vector toResolve; @@ -137,7 +260,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( @@ -154,6 +277,9 @@ std::shared_ptr GPUDevice::createCommandEncoder( } void GPUDevice::destroy() { + // 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). diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h b/packages/webgpu/cpp/rnwgpu/api/GPUDevice.h index f2deb045d..f224c73ad 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,20 +70,28 @@ 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 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; } @@ -95,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; } @@ -298,6 +307,40 @@ class GPUDevice : public NativeObject { inline const wgpu::Device get() { return _instance; } + // 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. 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, 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 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. 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 + // 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: friend class GPUAdapter; @@ -308,6 +351,17 @@ class GPUDevice : public NativeObject { wgpu::Device _instance; 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. + 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..00f18a0ed 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; } @@ -104,6 +106,9 @@ class GPUQueue : public NativeObject { 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/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 diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json index 7a0a3c3f4..43710b712 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..55b423784 --- /dev/null +++ b/packages/webgpu/tests/cpp/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.24) +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..6c045e43f --- /dev/null +++ b/packages/webgpu/tests/cpp/DawnBlobCacheTest.cpp @@ -0,0 +1,555 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DawnBlobCache.h" + +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; +} + +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; +} + +// A file of `bytes` that takes no disk: a hole, which counts towards the +// 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"); + return truncate(path.c_str(), bytes) == 0; +} + +// 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; + 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; + } + + // 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 +// the cache when it ends. +class DawnBlobCacheTest : public ::testing::Test { +protected: + void SetUp() override { + const char *tmp = getenv("TMPDIR"); + std::string pattern = + std::string(tmp != nullptr ? tmp : "/tmp") + "/dawn-blob-cache-XXXXXX"; + ASSERT_NE(mkdtemp(pattern.data()), nullptr); + _root = pattern; + _dir = _root + "/cache"; + 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 = + 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 &name : list(_dir)) { + if (name.size() < 4 || name.compare(name.size() - 4, 4, ".tmp") != 0) { + paths.push_back(_dir + "/" + name); + } + } + return paths; + } + + [[nodiscard]] static bool age(const std::string &path, time_t seconds) { + const time_t then = time(nullptr) - seconds; + utimbuf times{then, then}; + return utime(path.c_str(), ×) == 0; + } + + std::string _root; + std::string _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, 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); +} + +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); +} + +// 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}); + + setUpDirectory(_dir); + + 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); + + ASSERT_EQ(truncate(entry.c_str(), fileSize(entry) - 1), 0); + + EXPECT_EQ(cache.size({1}), 0u); +} + +// 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}); + 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.storeOnly(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(); + removeTree(_dir); + + cache.write({1}, {2, 3}); + + 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"; + setUpDirectory(parent + "/cache"); + const auto cache = callbacks(); + removeTree(parent); + + cache.write({1}, {2, 3}); + + EXPECT_EQ(cache.read({1}), (Bytes{2, 3})); +} + +TEST_F(DawnBlobCacheTest, RemovesWritesAbandonedByAKilledProcess) { + const auto abandoned = _dir + "/0123456789abcdef.4242.tmp"; + writeFile(abandoned, "x"); + ASSERT_TRUE(age(abandoned, 120)); + + setUpDirectory(_dir); + + EXPECT_FALSE(exists(abandoned)); +} + +TEST_F(DawnBlobCacheTest, LeavesAWriteAnotherProcessMayStillFinish) { + const auto writing = _dir + "/0123456789abcdef.4242.tmp"; + writeFile(writing, "x"); + + setUpDirectory(_dir); + + EXPECT_TRUE(exists(writing)); +} + +TEST_F(DawnBlobCacheTest, KeepsACacheWithinItsLimit) { + callbacks().write({1}, {2, 3}); + + setUpDirectory(_dir); + + EXPECT_EQ(entries().size(), 1u); +} + +// 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}); + ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000ff", 65 * kMegabyte)); + + setUpDirectory(_dir); + + EXPECT_TRUE(entries().empty()); + EXPECT_EQ(cache.size({1}), 0u); +} + +// 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"; + ASSERT_TRUE(writeSparseFile(large, 60 * kMegabyte)); + setUpDirectory(_dir); + + cache.write({1}, Bytes(9 * kMegabyte, 7)); + + EXPECT_EQ(cache.size({1}), 0u); + EXPECT_TRUE(exists(large)); + + ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000fe", 5 * kMegabyte)); + setUpDirectory(_dir); + cache.write({1}, Bytes(9 * kMegabyte, 7)); + + EXPECT_FALSE(exists(large)); + EXPECT_EQ(cache.size({1}), 9u * kMegabyte); +} + +// 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(); + ASSERT_TRUE(writeSparseFile(_dir + "/00000000000000ff", 60 * kMegabyte)); + setUpDirectory(_dir); + cache.write({1}, Bytes(3 * kMegabyte, 7)); + + 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); +} + +// What the system cleared mid-session is no longer in the cache, so it no +// 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); + + cache.write({1}, Bytes(9 * kMegabyte, 7)); + + 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)); + ASSERT_TRUE(age(_dir, 3600)); + 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(); + cache.write({1}, {2, 3}); + cache.write({2}, {4}); + + 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); +} + +// 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) { + 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"; + ASSERT_TRUE(writeSparseFile(other, 65 * kMegabyte)); + writeFile(similar, "x"); + writeFile(lookalike, "x"); + writeFile(numbered, "x"); + ASSERT_TRUE(age(lookalike, 3600)); + ASSERT_TRUE(age(numbered, 3600)); + ASSERT_TRUE(writeSparseFile(large, 65 * kMegabyte)); + + setUpDirectory(_dir); + + EXPECT_FALSE(exists(large)); + EXPECT_TRUE(exists(other)); + EXPECT_TRUE(exists(similar)); + EXPECT_TRUE(exists(lookalike)); + EXPECT_TRUE(exists(numbered)); +} + +} // namespace