Skip to content

[Windows] Keep ANGLE's compiled GL programs across launches - #193419

Open
0nilinkz wants to merge 7 commits into
flutter:masterfrom
0nilinkz:windows-angle-program-cache
Open

0nilinkz wants to merge 7 commits into
flutter:masterfrom
0nilinkz:windows-angle-program-cache

Conversation

@0nilinkz

@0nilinkz 0nilinkz commented Sep 28, 2026 •

Copy link
Copy Markdown

On Windows, Impeller draws through ANGLE on Direct3D 11, and linking a GL
program runs the Direct3D shader compiler (D3DCompile). The engine creates
its default pipelines when it starts, so every launch compiles the same
programs again: about 1.5 s of D3DCompile critical path on a Ryzen 7 2700X
with a GTX 1080. On 3.47 this runs before main(); on master it runs on the
IO thread, and the first frame waits for about half of it.

ANGLE supports persistent program binaries through EGL_ANDROID_blob_cache
when the application registers the callbacks. This PR registers a disk-backed
cache in the Windows embedder right after eglInitialize, before any context
exists:

  • Correctness stays with ANGLE. Its key covers the shader sources, ANGLE's
    program format version, GL_RENDERER (GPU, device id, driver version) and
    bindings, and loading rejects a binary whose adapter or feature level
    differs. A rejected or undecodable entry is relinked and replaced.
  • One file per entry, holding the key, sizes and a checksum, written through
    a temporary file and an atomic rename on a background thread (started by
    the first write, so a warm launch starts no thread). A failing file reads
    as a miss and is deleted.
  • The default folder is
    %LOCALAPPDATA%\<CompanyName>\<ProductName>\flutter_program_cache, named
    from the executable's version resource like path_provider's application
    cache folder, falling back to the executable name. It is per user.
    FlutterDesktopEngineProperties::program_cache_path and
    DartProject::set_program_cache_path() move it, or turn it off when
    empty; --program-cache-path=<folder> overrides it in debug and profile
    builds. The callbacks carry no context, so the engines of a process share
    one cache; the headers say to give every engine the same setting.
  • Entries live in a folder per engine version, so builds on different engine
    versions can run side by side. The first write of a process removes
    another version's folder once all of its files are older than 30 days
    (only files the cache writes, and a folder only once it is empty). A
    folder deleted while the app runs is created again by the next write. Each version targets 32 MiB, least recently used first, and a single entry is limited to 4 MiB. Eviction rescans current entries and hit timestamps, and only counts successful deletions; it is best effort under filesystem failures and concurrent writers.

The following measurements were captured at cache commit ac45a70, before the eviction rescan in 191ae6e. Fresh first-frame timings for 191ae6e remain unverified; these are earlier measurements, not a current-head timing guarantee. The real-app runs included an App Check banner in all variants and establish renderer comparison only, not app readiness.

Measured with #193066 (parallel links), which this complements: master
2abcfdc6 with #193066's head applied, with and without this change, each app
built once with only flutter_windows.dll swapped, alternating profile
launches on a Ryzen 7 2700X with a GTX 1080 (driver 32.0.15.8266), Windows 11.
Medians: the window of 10 untraced launches, and engine start to first frame
rasterized (start_up_info.json) of 5 traced ones. "Empty" clears the cache
before the launch.

flutter_gallery window flutter_gallery first frame real app window real app first frame
official engine 1,230 ms 930 ms 2,244 ms 2,040 ms
#193066 811 ms 507 ms 1,545 ms 1,212 ms
#193066 + this change, empty cache 823 ms 508 ms 1,575 ms 1,205 ms
#193066 + this change, filled cache 453 ms 144 ms 585 ms 329 ms

With a filled cache the app also uses about 2.0 s (gallery) and 2.7 s (the
real app) less CPU in its first seconds: #193066 spreads the compiles across
cores, and the cache removes them. Ranges, CPU and the cache without #193066
are in the review thread.

Without #193066, on master 2abcfdc6, profile builds of the real app,
alternating fresh launches, same machine:

2abcfdc6 engine start → first frame window visible programs from the cache
official engine 1,641–1,683 ms 1,960–2,320 ms —
this change, empty cache (first launch) 1,571–1,707 ms 2,019–2,060 ms 0
this change, filled cache 167–176 ms 487–556 ms 72 of 72

(Three alternating launches each; the app holds its first frame for one extra
program of its own. --trace-startup stops at the first frame, which on
master comes after 34–43 of the 72 links; the rest link on the IO thread
afterwards.)

On 3.47.2 with the same change, engine start to main() fell from
1,702–1,760 ms to 222–228 ms on warm launches and the window appeared
1.7 s sooner; a probe counted 146 D3DCompile calls stock and none warm.
The first launch after an install, an update or a driver change costs
~0.1 s more (ANGLE serialising and compressing each program).

Part of #191860.

Tests: 26 store and location tests in egl/program_cache_unittests.cc
(round trip across instances, the size-then-copy protocol, corrupted,
truncated and mismatched files, size limits, eviction order, versions kept
side by side and removed after 30 days unused, cleanup that never touches
foreign files, stale temporary files, a folder deleted while running,
concurrent use, folder resolution, interleaved cache instances, hits after the first write, and locked entries that cannot be deleted), and pass-through tests for the project
bundle, DartProject and FlutterEngine. Every engine the embedder's tests
create keeps no cache (WindowsTest.TestEnginesKeepNoProgramCache checks the
config builder). In host_debug_unopt, flutter_windows_unittests (430
tests; 6 more are disabled upstream) and client_wrapper_windows_unittests
(29) pass, and a full run leaves no cache folder. clang-tidy with the
engine's .clang-tidy reports no diagnostics on the changed lines. Two diagnostics originate in MSVC standard-library exception/filesystem internals reached by existing tests; they are not suppressed by this patch. Debug and profile Windows builds pass. The three new eviction tests were run against ac45a70 first and all three failed, then all passed with 191ae6e.

Review follow-up

  • Different engine versions retain each other's active cache. Only cache-owned files in versions unused for 30 days are removed.
  • Deleting the cache directory while an app runs is recovered by recreating the directory and retrying the next write once.
  • All Windows test-engine creation paths disable the shared cache. The full suite leaves the default test cache directory absent.
  • Default-on remains proposed for maintainer review; an explicit empty path disables caching. No maintainer approval of that policy is implied.
  • Relative paths are joined to LocalAppData; Windows resolves parent components such as ... An absolute path is preferable for an explicitly external location. This GPU/driver-specific cache is not intended for roaming between machines.
  • The eviction follow-up removes stale per-instance bookkeeping: it observes other instances' entries, refreshed hit times and failed deletions. Three regression tests cover those failures.

Pre-launch Checklist

AI-assisted with Claude Code and Codex. Human-review checklist remains for the contributor to complete.

@0nilinkz
0nilinkz requested a review from a team as a code owner September 28, 2026 00:36
@github-actions

Copy link
Copy Markdown

This pull request targets the main branch, but the primary branch for this repository is master.

Please change the base branch of this pull request to master.

See: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request

@google-cla

google-cla Bot commented Sep 28, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. platform-windows Building on or for Windows specifically a: desktop Running on desktop team-windows Owned by the Windows platform team labels Sep 28, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a persistent program cache for compiled GL programs on Windows to improve startup performance. It implements the ProgramCache and ProgramCacheLocation classes under the egl namespace, leveraging the EGL_ANDROID_blob_cache extension to store ANGLE-compiled shaders on disk. The changes expose a program_cache_path configuration option across FlutterDesktopEngineProperties, DartProject, and FlutterProjectBundle, and integrate cache initialization into FlutterWindowsEngine. Comprehensive unit tests are also added to verify the caching behavior and location resolution. There are no review comments to address, and I have no feedback to provide.

@0nilinkz
0nilinkz changed the base branch from main to master September 28, 2026 00:40
On Windows, Impeller draws through ANGLE, which compiles every GL program
with the Direct3D shader compiler when it is linked. The engine links its
built-in programs at startup, so every launch spends most of its startup
time in that compiler, and apps pay again for each runtime effect.

ANGLE supports persistent program binaries through EGL_ANDROID_blob_cache
when the application registers the callbacks. The Windows embedder now
registers a disk-backed cache right after eglInitialize, before any context
exists.

- ANGLE's keys cover the shader sources, its program format version, the
  GPU and driver version (GL_RENDERER) and bindings, and it rejects a binary
  whose adapter or feature level differs. A rejected or undecodable entry is
  relinked and replaced.
- One file per entry, with the key, sizes and a checksum, written through a
  temporary file and an atomic rename on a background thread. A failing file
  reads as a miss and is deleted.
- The default folder is
  %LOCALAPPDATA%\<CompanyName>\<ProductName>\flutter_program_cache, named
  from the executable's version resource like path_provider's application
  cache folder. FlutterDesktopEngineProperties::program_cache_path and
  DartProject::set_program_cache_path() move it, or turn it off when empty;
  --program-cache-path=<folder> overrides it in debug and profile builds.
- Entries live in a folder per engine version; the first write of a new
  version removes other versions' cache files. The total is bounded to
  32 MiB, least recently used first.

Part of flutter#191860.
- Pass the 64-bit value to swprintf_s without a cast to unsigned long long
  (google-runtime-int); uint64_t is that type on Windows.
- Reserve the concurrency test's thread vector before filling it
  (performance-inefficient-vector-operation).
@0nilinkz
0nilinkz force-pushed the windows-angle-program-cache branch from 3cc50eb to a58a060 Compare September 28, 2026 00:55
@krll-kov

krll-kov commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

In my tests with caching in original issue i ended up with a lof of unresolved decisions especially for cleanups, corruptions, sharing between different apps and paths. Hope all those will be covered

Personal questions after reading the PR (not tested, just from a look at the code):

  • Two builds of the same app on different engine versions (same CompanyName/ProductName) seem to share one flutter_program_cache folder. PrepareDirectory() runs on the first write of every process and removes the other version's folder, so it looks like the two builds would keep wiping each other's cache. Should cleanup skip recently used versions, or should the folder depend on the executable too?
  • The directory is created only in PrepareDirectory(), on the first write. The default path is inside getApplicationCacheDirectory(), so if an app clears its cache while running, it looks like nothing would reach the disk until the process restarts. Won't that affect fragment shaders that are loaded in runtime?
  • WindowsConfigBuilder doesn't set program_cache_path, and the PR only disables the cache in flutter_windows_engine_builder.cc. Won't WindowsTest.* then write into the real %LOCALAPPDATA% and, on the next run, load programs from there instead of compiling them?
  • This is not my decision, but cache is on by default, and an app can only turn it off from the runner. Is default-on fine, or should it be opt-in first?
  • Also, can i specify something like set_program_cache_path(L"..\\Roaming\\MyApp\\cache") to move outside of local appdata into shared_preferences package folder in standard appdata? That would seem to be handy.

I'm not from flutter team, do not request to change/fix that, just asking for curiosity

@gaaclarke
gaaclarke self-requested a review September 28, 2026 18:17

@gaaclarke gaaclarke left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@0nilinkz you'll have to sign the CLA to land this PR.

Adding a cache comes with a lot of bug vectors. It's not the sort of decision we'd take lightly. I'd like to see the measurement for this after #193066 since we are almost certainly going to land that and that may change the calculus for deciding to accept this additional risk or not.

Please respond to #193419 (comment). @krll-kov 's opinion on this matter is respected and he raises some valid questions =)

…folder

Follow-ups from review of the program cache:

- Builds on different engine versions can run side by side. The first
  write of a process removed the other versions' folders, so two builds
  of one app kept wiping each other's cache. A version's folder is now
  removed only once all of its entries and temporary files are older
  than 30 days. A hit refreshes an entry at most daily, and a temporary
  file is a write in progress, so a version in use keeps its folder.
- A cache folder deleted while the process runs, for example when the
  app clears its cache folder, is created again by the next write.
  Before, nothing reached the disk until the process restarted.
- Test engines keep no program cache. WindowsConfigBuilder and four test
  helpers created engines with the default cache. The engines of a
  process share one cache through the EGL display, so later tests'
  engines wrote programs to %LOCALAPPDATA%\flutter_windows_unittests,
  where a later run could load them instead of compiling them. A full
  run of flutter_windows_unittests now leaves no folder.
- The public headers say that the engines of a process share one cache.
@github-actions github-actions Bot added the a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) label Sep 28, 2026
An instance-local index misses writes from another instance and access times
refreshed after its first write. Rescan the current version before trimming
and count an entry as removed only when Windows confirms deletion.

Rejected: Maintain a process-local eviction index | misses other processes and refreshed file timestamps
Confidence: high
Scope-risk: narrow
Directive: Eviction remains best effort when files cannot be deleted; do not claim a transactional cross-process size bound
Tested: Three new regression tests fail on ac45a70 and pass with the fix; 430 Windows embedder tests and 29 client-wrapper tests pass; debug and profile builds; clang-format; git diff --check
Not-tested: Fresh first-frame timings after the eviction change; maintainer CI and multi-process simultaneous-write stress
@krll-kov

krll-kov commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

I think if handle == INVALID_HANDLE_VALUE should also be checked against ERROR_SHARING_VIOLATION that occurs rarely, yet still happens when antivirus is checking files, backup in progress, etc, especially on slower machines. While that happens, the cache entry can't be loaded, so ANGLE compiles the program again. (Any tool that opens the entry without FILE_SHARE_DELETE, .OpenRead).

ReadEntry asks for DELETE access, which then fails with ERROR_SHARING_VIOLATION, and the fallback to GENERIC_READ only runs on ERROR_ACCESS_DENIED. If the fallback also runs on ERROR_SHARING_VIOLATION, these entries load normally

   if (handle == INVALID_HANDLE_VALUE &&
-      ::GetLastError() == ERROR_ACCESS_DENIED) {
+      (::GetLastError() == ERROR_ACCESS_DENIED ||
+       ::GetLastError() == ERROR_SHARING_VIOLATION)) {

// Readers never block a writer that replaces the entry or evicts it.
Also replacing does get blocked: MoveFileExW(MOVEFILE_REPLACE_EXISTING) fails with ERROR_ACCESS_DENIED while a reader has the entry open, even with FILE_SHARE_DELETE. The old entry stays, so it's harmless, only the comment is off, in practice it's like this

// Readers never block a writer that evicts the entry. Replacing it fails
// while a reader has it open, and the old entry stays.

@krll-kov

krll-kov commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

I understand that such a scenario is almost impossible to achieve, but one more data point on the risk side. ANGLE trusts any blob that passes the cache's checks. When I stored valid ANGLE blobs under the wrong keys (with correct checksums), the window stayed black on every launch, while ANGLE and Impeller logged no errors and the app still ran at 120 FPS. Only deleting the cache folder fixed it.

I couldn't get into this state through disk damage, two instances, a killed write or Defender, so it's not a realistic bug by itself. But if a wrong program ever ends up in the cache, for example through an ANGLE key that misses some state or a driver bug, users will be stuck with a broken app and no obvious way out.

Because of this, I think the cache is safer as opt-in (if approved), at least at first: apps that want the startup win can enable it, and the rest don't carry a risk that reinstalling can't fix.

Normal cache:
image

Same cache with blobs moved to the wrong keys, black on every launch until the folder is deleted:
image

@0nilinkz

0nilinkz commented Oct 7, 2026

Copy link
Copy Markdown
Author

@gaaclarke @krll-kov — following up, and thanks for the detailed feedback.

The PR description now includes comparisons with #193066 applied: the recorded first-frame medians were 507 → 144 ms for flutter_gallery and 1,212 → 329 ms for the real app with a warm cache. Those measurements are from ac45a70, before the eviction follow-up; they are not fresh timings for the current head or the latest revision of #193066.

The description also summarises the earlier changes for retaining recently used engine-version folders, recreating a deleted cache directory, and disabling the cache in test engines. The later ERROR_SHARING_VIOLATION fallback and reader/replacement comment feedback remain outstanding.

The deliberately mismatched-blob result is also a useful recovery concern, even though it was not reproduced through ordinary corruption or concurrent use. Would an explicitly opt-in initial implementation, with the outstanding fixes and refreshed measurements, be an acceptable direction to pursue? Or would you prefer to defer the cache decision until #193066 has landed?

The CLA check and merge conflicts are still outstanding too; this is a request for guidance on the design direction, not a claim that the current PR is ready to land.

Resolve the EGL manager and Windows engine conflicts from flutter#193133 by passing both the upstream surface-orientation flag and the optional program cache. Keep cache installation after eglInitialize and before context creation.

Validation: clang-format checks on resolved ranges, whitespace checks, call-site review, and unchanged cache implementation/tests. Native Windows build and unit tests were not run on this macOS host.
…e-level cap

master added an optional D3D11 feature-level cap to egl::Manager::Create,
its constructor and InitializeDisplay, in the same place as this change's
program cache parameter, and a matching FlutterProjectBundle setting next
to the cache's. Keep both: the cap comes before the cache in the
parameter lists, so master's call sites are unchanged, and the engine
passes the project's cap and its cache together.
A virus scanner or a backup can hold an entry open without sharing write
or delete access. Opening the entry with the rights to refresh or delete
it then fails with ERROR_SHARING_VIOLATION, which read as a miss and made
ANGLE compile the program again. Read it without those rights instead,
as already happens when access is denied.

Also correct the comment on the readers' sharing mode: a reader does not
block eviction, but replacing an entry fails while a reader has it open,
and the old entry stays. A test checks that such a failed replace keeps a
readable entry and leaves no temporary file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) a: desktop Running on desktop engine flutter/engine related. See also e: labels. platform-windows Building on or for Windows specifically team-windows Owned by the Windows platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants