Skip to content

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

Merged
wcandillon merged 23 commits into
wcandillon:mainfrom
camchis:feat/dawn-blob-cache
Nov 2, 2026
Merged

wcandillon merged 23 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.

@wcandillon
wcandillon self-requested a review October 28, 2026 10:46
@wcandillon

Copy link
Copy Markdown
Owner

This is very compelling Thank You.

@wcandillon wcandillon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great and I am excited about this improvement.

Since you made the PR, we now set vulkan_monolithic_pipeline_cache for all Android devices, does this affect this feature on Android?

The documentation reads nicely. Is the 64MB claim still true?

If there is a crash happening, could we corrupt the cache file?

Do we need a single writer thread with a queue for safe access to the cache?

… as no growth

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.
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.
@camchis

camchis commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Short answers first, details below. I checked these on a Solana Seeker (Mali-G615, Android 16) and pushed the changes in a987838, 3361634 and 8357d57.

1. Does vulkan_monolithic_pipeline_cache affect this on Android?

Yes, and it needed a fix. With that toggle Dawn keeps one VkPipelineCache per device and writes it to the blob cache only inside dawn::native::PerformIdleTasks() (the toggle's own description says the embedder is responsible for calling it). Nothing in this PR called it, so on Android only the shader entries were being persisted: on the Seeker the Vulkan entry never appeared, even after backgrounding the app.

What I changed:

  • New IdleFlusher, owned by a device only when the blob cache is chained, the backend is Vulkan, and Dawn actually applied vulkan_monolithic_pipeline_cache (and not disable_blob_cache). It calls PerformIdleTasks after roughly one second without pipeline creation, or roughly ten seconds after the first unflushed creation if creation continues. device.destroy() flushes any remaining pending work before destroying Dawn's device. Sync creation, successful async completion and VideoFrameBlit all count. Dawn itself skips the store when nothing changed.
  • A device that opts out of implicit synchronization is flushed only on destroy(): otherwise the flush would run on a thread of our own without Dawn's device lock.
  • It is cancelled on device loss, and destroying it never waits for a flush, so it is safe if the last reference to a device is dropped inside a Dawn callback.

One design note, since this changes the shape of #595's toggle: with the monolithic cache, persisting depends on this flusher (one blob per device, rewritten whole, and lost if the app is killed before a flush). Without the toggle, Dawn writes a small entry per pipeline as soon as it compiles and the load/store callbacks alone would persist it. I kept the toggle because #595 is there to avoid the per-pipeline VkPipelineCache destroy on worker threads that crashes on the PowerVR driver, and I did not run the per-pipeline setup on a device. If you would rather trade the crash fix for the simpler setup, that is your call.

Measured on the Seeker (debug build, one launch = four example scenes = 4 render + 1 compute pipelines, total time in CreateRenderPipeline/CreateComputePipeline, median of three rounds):

cache total creation time
empty 56.8 ms
shader entries only (Vulkan entry removed) 53.3 ms
full 8.1 ms

The full-cache launches were substantially faster, consistent with the Vulkan entry doing the work; the shader entries alone made little visible difference here. Each round ran empty, then shader-only, then full, in that fixed order, so system-level warmup and order effects are not controlled; it is one device, one debug build, simple pipelines, and only the synchronous creation path. I would not extrapolate the numbers, and I dropped the "tens to hundreds of milliseconds" claim from the docs for that reason.

Raw numbers (ms, total / slowest single pipeline)
round empty shader only full
1 96.7 / 72.7 53.9 / 33.4 8.1 / 3.1
2 56.8 / 33.9 53.3 / 31.8 7.8 / 3.1
3 56.7 / 33.4 51.8 / 32.1 8.8 / 4.1

Each launch: force-stop, cold start, open Hello Triangle, Hello Triangle MSAA, Image Blur and Cube in turn (7 s each), idle 4 s so the flush runs. "Shader only" is the full cache with the MonolithicVkPipelineCache entry deleted before the launch; after every launch the directory held 15 files including exactly one Vulkan entry again.

2. Is the 64MB claim still true?

The limit is, but the docs were stale: they said the cache is cleared, and since 55997fe it is trimmed. The target is 64 MiB, checked when the app starts and again after about 8 MiB of accumulated growth from stores. A replacement counts only the bytes it adds, and the count is approximate. The oldest-written entries go first (reads do not refresh an entry). Entries above 32 MiB are not stored, so one blob cannot take more than half the cache from everything else, and any previous entry under that key is left as it was; the docs say that a Vulkan blob that outgrew that would stop being updated.

3. Could a crash corrupt the cache file?

Entries are written to a temporary file, closed, then renamed into place, so an ordinary process crash does not publish a partial replacement; a later tidy() pass removes temporary files older than 60 seconds. Our loader checks the recorded sizes and the stored key, and Dawn additionally validates a SHA3-224 prefix on every blob it loads (the blob_cache_hash_validation toggle, on by default) and treats a failed check as a cache miss. Without fsync of the file and directory, nothing is guaranteed across power loss or a kernel crash: an entry can be lost, and a damaged one is caught by the hash check as long as that toggle stays enabled.

4. Do we need a single writer thread with a queue?

Not for integrity. Temporary names are unique per process and write, rename is atomic, and tidy() takes a process-local try_lock, so loads see the old or the new entry and concurrent stores never mix bytes. What a queue would not fix is that stores of the same key replace the whole file, so the writer that finishes last wins; with the monolithic cache that means two devices or processes sharing a key can replace a fuller blob with a smaller one, which only costs the pipelines in it being built again. I documented that instead of adding a queue. The periodic flushes run on a per-device worker and the final one on the caller of destroy(); serializing the cache holds Dawn's device lock, so a pipeline created at the same moment can wait for it.

Not verified

  • Apple builds (iOS, macOS, visionOS). The new code includes Dawn's native header and calls IsDeviceLost, PerformIdleTasks and GetTogglesUsed on every platform (only reached for Vulkan); they are declared in the Apple headers, but I did not build or link an Apple target.
  • JS device.destroy() and Dawn device-loss on a real device. The host tests cover the helper's finish/cancel behavior, not those integration paths.
  • Recovery after a forced crash or power loss, and several devices or processes writing the same key at once.

All 42 host tests pass; the flusher and selected cache tests also passed repeated ThreadSanitizer runs.

Separate from this PR: GPUAdapter::requestDevice assigns *deviceLostBinding (a weak_ptr) on the request thread while Dawn's device-lost callback can read it from another one. That was already the case before this change, so I left it alone; I can send a small follow-up that guards it with a mutex if you want it.

@wcandillon

Copy link
Copy Markdown
Owner

@copilot resolve the merge conflicts in this pull request

@wcandillon
wcandillon self-requested a review November 2, 2026 07:46
# Conflicts:
#	packages/webgpu/apple/WebGPUModule.mm
#	packages/webgpu/cpp/rnwgpu/api/GPUAdapter.cpp
#	packages/webgpu/cpp/rnwgpu/api/GPUDevice.cpp
…clear a cache past its limit

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.
… app pauses

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.
…runtime, and never for an imported device

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.
…asure the cache again before dropping one

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 `<hash>.<pid>.<seq>.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.
…letest download cached

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.
…evice wrapper on the UI thread, and measure the cache again only when it changed

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.
@wcandillon
wcandillon merged commit 0d02086 into wcandillon:main Nov 2, 2026
6 checks passed
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 0.21.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants