Skip to content

feat(⚡️): keep Dawn's compiled pipelines across launches - #594

Open
camchis wants to merge 11 commits into
wcandillon:mainfrom
camchis:feat/dawn-blob-cache
Open

camchis wants to merge 11 commits into
wcandillon:mainfrom
camchis:feat/dawn-blob-cache

Conversation

@camchis

@camchis camchis commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Dawn can reuse what it produces while building shaders and pipelines across launches, but only when the embedder gives it storage. That storage is either load/store callbacks on the device descriptor (DawnCacheDeviceDescriptor), or a caching interface on the platform the instance was created with, which is how Chrome does it. react-native-webgpu provides neither, so every launch builds every pipeline from scratch.

On Vulkan (Android) the cached data includes the driver's pipeline data, which is where most of the time goes. On Metal (iOS) it is Dawn's MSL translation; Metal's own system cache keeps the compiled pipelines.

This adds a small file-backed cache (cpp/rnwgpu/DawnBlobCache) and chains it onto every device requestDevice() creates. It uses device callbacks rather than a platform interface because the instance can be React Native Skia's.

  • Location: each platform's module points the cache at the app's cache directory when it installs.
    • Android: getCacheDir()/webgpu-dawn.
    • Apple: Caches/<bundle id>/webgpu-dawn. The bundle id is included because macOS shares ~/Library/Caches between apps.
  • Entries: one file per entry, named by a hash of its key. Each file holds the key and value sizes, the key and the value. A stored key that differs, or a file shorter than its recorded size, reads as a miss. Dawn already keys entries by its version, adapter, features and toggles, and validates them by hash, so there is no extra versioning.
  • Concurrency: writes go to a temporary file that is renamed into place, so concurrent loads and stores never see half an entry.
  • Housekeeping: this runs synchronously when the module installs: at launch, and again on a reload.
    • Temporary files more than a minute old are treated as writes a killed process never finished, and are removed. This is best effort: a writer suspended for longer loses that entry.
    • Past 64 MB the oldest entries (by write time) are removed until it fits. The limit is checked at install and again after every 8 MB stored, so a long session stays near it.
    • Only the cache's own files are ever removed. If no directory can be made, the cache stays off.
    • Measured on the Seeker with a native timer around the real setup code:
      • about 0.6 ms for a production app's cache (284 entries, 5 MB);
      • 2.5 ms for 1,000 entries (19 MB);
      • 85 ms, once, to clear a cache that had outgrown the limit (4,000 entries, 75 MB), measured when the limit still cleared the whole cache.
    • The directory was probably in the page cache during these runs, so a cold boot may take longer.
  • Directory cleared mid-session: if the system or the app clears the directory or its parent, the next store recreates them.
  • Opt-out: dawnToggles: { enabledToggles: ["disable_blob_cache"] } is Dawn's own toggle. It clears that device's cache callbacks. The install-time housekeeping still runs.
  • Docs: a "Pipeline cache" section on the Native API page.

Measurements

These were taken with Release builds of the example app from this branch. The later commits change only directory handling. A temporary screen, not part of the PR, requests a device and builds 48 distinct render pipelines, each with a fragment shader using lighting, noise and a short loop. Times run from requestAdapter() to the last pipeline resolving, so they leave out the housekeeping above. Cold is the first launch after install; warm is a later launch.

Phone Cold launch Warm, with cache Warm, disable_blob_cache
Solana Seeker (Dimensity 7300, Mali-G615 MC2, Android 16) 450–466 ms (pipelines 364–373) 57–62 ms (pipelines 37–45) 419–432 ms (pipelines 342–352)
iPhone 12 (A14, iOS 27) 769–919 ms (pipelines 713–841) 36–50 ms (pipelines 11) 71–78 ms (pipelines 33–34)
  • Seeker: without the cache, each launch of this workload paid nearly the cold cost.
  • iPhone: Metal's system cache already removes most of that cost, and this cache roughly halves what is left.
  • First launch: writing the cache costs about 30 ms on the Seeker: around 455 ms against 421 ms with it disabled.

In a production app that builds 88 pipelines at startup, the same logic applied as a patch to 0.13.0 took the Seeker from 2.6 s, plus 0.6 s of JS-thread stall creating shader modules, to 0.27 s on warm launches.

Tests

yarn test:cpp builds 20 host unit tests of DawnBlobCache with CMake (3.24 or later) and GoogleTest against Dawn's headers, with no GPU needed. CI runs them in the Android job. They cover:

  • round trips and misses;
  • truncated and mismatched entries;
  • 8-thread concurrent loads and stores;
  • temporary-file cleanup and the size limit;
  • leaving unrelated files alone;
  • recreating cleared directories;
  • staying off without a usable directory.

They pass under AddressSanitizer and UndefinedBehaviorSanitizer with -Werror. Each behavioural fix made during review has a test that fails without it.

What the tests don't cover:

  • the call site in GPUAdapter::requestDevice, the JNI and Apple wiring, and the opt-out toggle, which were checked on the devices above;
  • separate writer processes, injected write failures and Dawn's own hash validation.

The test setup mirrors #550, which adds the repo's first host C++ tests and fetches GoogleTest the same way. Whichever lands second needs the two CMakeLists.txt source lists and the CONTRIBUTING lines combined.

Not covered

  • Imported devices: devices wrapped with importDevice(), such as React Native Skia's Graphite device, are created by their own library and don't get the cache. Skia would need the same descriptor on its side.
  • Eviction: the limit removes the oldest entries by write time (a load does not refresh an entry), not the least recently used, and 64 MB is a fixed constant. That is enough while an app's live pipelines fit well under the limit; a production app's 88 pipelines took about 5 MB. Either is easy to change if you prefer.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant