Skip to content

[flutter_tools] Cache pubspec reads and share PackageGraph/PackageConfig across workspace packages during pub get post-processing#184528

Merged
auto-submit[bot] merged 13 commits intoflutter:masterfrom
aNOOBisTheGod:improve-workspaces-pub-get
Apr 13, 2026
Merged

[flutter_tools] Cache pubspec reads and share PackageGraph/PackageConfig across workspace packages during pub get post-processing#184528
auto-submit[bot] merged 13 commits intoflutter:masterfrom
aNOOBisTheGod:improve-workspaces-pub-get

Conversation

@aNOOBisTheGod
Copy link
Copy Markdown
Contributor

In large pub workspaces, flutter pub get post-processing was reading
pubspec.yaml files redundantly — once per transitive dependency per
workspace root package (O(N×deps) disk reads). On a repo with 500+
plugin packages this caused flutter pub get to take ~10 minutes.

This PR fixes that with three changes:

1. PubspecCachebuildPubspecCache() reads every package's
pubspec.yaml once and caches it by root URI. All workspace root
packages share this cache during findPlugins / computeTransitiveDependencies,
reducing pubspec reads from O(N×deps) to O(deps).

2. Shared PackageGraph and PackageConfig — both are loaded once
from the workspace root and passed down through regeneratePlatformSpecificTooling
refreshPluginsList / injectPluginsfindPlugins /
computeTransitiveDependencies. A useSharedResources guard ensures
shared caches are only used when the project is actually a member of
the workspace graph (non-workspace projects and example apps that are
not workspace roots fall back to loading their own resources).

3. Pool-based concurrency — workspace root packages are processed
concurrently using package:pool, capped to platform.numberOfProcessors,
matching the pattern in build_system.dart.

Tested changes on the https://github.com/aNOOBisTheGod/too-long-post-pub-get
Before: 40-60 seconds
After: ~8 seconds

Also tested on our production application
Before: 6-10 mins
After: ~40 seconds

Fixes #184515

@flutter-dashboard
Copy link
Copy Markdown

It looks like this pull request may not have tests. Please make sure to add tests or get an explicit test exemption before merging.

If you are not sure if you need tests, consider this rule of thumb: the purpose of a test is to make sure someone doesn't accidentally revert the fix. Ask yourself, is there anything in your PR that you feel it is important we not accidentally revert back to how it was before your fix?

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing. If you believe this PR qualifies for a test exemption, contact "@test-exemption-reviewer" in the #hackers channel in Discord (don't just cc them here, they won't see it!). The test exemption team is a small volunteer group, so all reviewers should feel empowered to ask for tests, without delegating that responsibility entirely to the test exemption group.

@github-actions github-actions bot added the tool Affects the "flutter" command-line tool. See also t: labels. label Apr 2, 2026
@google-cla
Copy link
Copy Markdown

google-cla bot commented Apr 2, 2026

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.

Copy link
Copy Markdown
Contributor

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

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 optimizes the flutter pub get command by implementing a PubspecCache to reduce redundant file I/O and enabling concurrent processing of workspace packages using a Pool. It also refactors plugin discovery and platform-specific tooling regeneration to share these cached resources. Feedback suggests parallelizing the cache initialization and restoring error logging for malformed pubspec files.

Comment on lines +42 to +63
Future<PubspecCache> buildPubspecCache(
PackageConfig packageConfig, {
FileSystem? fileSystem,
}) async {
final FileSystem fs = fileSystem ?? globals.fs;
final cache = <String, YamlMap?>{};
for (final Package package in packageConfig.packages) {
final key = package.root.toString();
final File pubspecFile = fs.file(package.root.resolve('pubspec.yaml'));
if (!pubspecFile.existsSync()) {
cache[key] = null;
continue;
}
try {
final Object? parsed = loadYaml(await pubspecFile.readAsString());
cache[key] = parsed is YamlMap ? parsed : null;
} on YamlException {
cache[key] = null;
}
}
return cache;
}
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.

medium

The buildPubspecCache function currently reads and parses pubspec.yaml files sequentially. In a large workspace with hundreds of packages, this creates a serial bottleneck before the concurrent processing of roots begins. Parallelizing these I/O operations would further improve performance.

Additionally, this implementation loses the error logging for YamlException that was present in the original _pluginFromPackage logic. Restoring this logging (and adding it for FileSystemException) ensures that malformed or unreadable pubspecs are still reported in the trace logs.

Future<PubspecCache> buildPubspecCache(
  PackageConfig packageConfig, {
  FileSystem? fileSystem,
}) async {
  final FileSystem fs = fileSystem ?? globals.fs;
  final Map<String, YamlMap?> cache = <String, YamlMap?>{};
  await Future.wait(packageConfig.packages.map((Package package) async {
    final String key = package.root.toString();
    final File pubspecFile = fs.file(package.root.resolve('pubspec.yaml'));
    if (!pubspecFile.existsSync()) {
      cache[key] = null;
      return;
    }
    try {
      final String content = await pubspecFile.readAsString();
      final Object? parsed = loadYaml(content);
      cache[key] = parsed is YamlMap ? parsed : null;
    } on YamlException catch (err) {
      globals.printTrace('Failed to parse pubspec.yaml for ${package.name}: $err');
      cache[key] = null;
    } on FileSystemException catch (err) {
      globals.printTrace('Failed to read pubspec.yaml for ${package.name}: $err');
      cache[key] = null;
    }
  }));
  return cache;
}

Copy link
Copy Markdown
Contributor Author

@aNOOBisTheGod aNOOBisTheGod Apr 2, 2026

Choose a reason for hiding this comment

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

Future.wait over a thousands files will likely hit the file descriptor limit which is 256 by default. this will result as a crash

error handling updated, good suggestion

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.

Actually, consider using package:pool to allow capped concurrency. Like 16?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually, consider using package:pool to allow capped concurrency. Like 16?

Thanks for your suggestion, implemented concurrent pubspec.yaml readns in f5152cf

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

https://pub.dev/documentation/pool/latest/pool/Pool/forEach.html

Even better!

Implemented in both places instead of Future.wait :)

expect(cache[withoutPubspec.root.toString()], isNull);
});

test('cached data survives deletion of source files', () async {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the key optimization property: once the cache is built, the original files are no longer needed. findPlugins (and related functions) can use the in-memory map instead of re-reading from disk

Comment on lines +354 to +355
final pool = Pool(globals.platform.numberOfProcessors);
await Future.wait(
Copy link
Copy Markdown
Contributor Author

@aNOOBisTheGod aNOOBisTheGod Apr 3, 2026

Choose a reason for hiding this comment

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

Not really sure if it's OK to relate on numberOfProcessors like that in here

The reason I've used this variable (instead of hard-coded value) here is that because inside the function we use considerable amount of processing power. So I think this kind of safety mechanism is a great solution to prevent pub get crashes on less powerful machines

But I also admit that this number might be increased 2 times (or hard-coded) and still there will be no crashes

@aNOOBisTheGod aNOOBisTheGod force-pushed the improve-workspaces-pub-get branch from 6fbf469 to 610f26b Compare April 4, 2026 18:29
final FileSystem fs = fileSystem ?? globals.fs;
YamlMap? pubspec;
if (pubspecCache != null) {
if (pubspecCache != null && pubspecCache.containsKey(packageRoot.toString())) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pubspecCache is built from the root package's PackageConfig, which only contains packages in its transitive dependency graph. If a package is not in the cache, it means it's not a dependency of the root — for example, a plugin used only by an example app but not by the plugin itself. Without containsKey, a cache miss returns null, which is indistinguishable from "package has no pubspec.yaml", causing real plugins to be silently skipped in .flutter-plugins, that's why for such examples we have to re-read pubspec.yaml from disk once again

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.

This would be a great comment to be added in the code. I was curious if the distinction between "in the map but null" or "not in the map" is important.

// Process workspace root packages concurrently, capped to
// numberOfProcessors to avoid exhausting file descriptors while still
// getting parallelism on multi-core machines.
await Pool(globals.platform.numberOfProcessors)
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.

We likely can be MORE concurrent than the number of processors. A good thing to Google.

Thus says Gemini:

The short answer is: mapping to the number of CPU cores is a great default, but it isn't always the optimal ceiling for file operations. Whether you should tie your concurrency limit to the core count depends entirely on what your developer tool does with the file after it reads it.

Here is how to think about setting a reasonable number based on your tool's bottleneck.

1. When to Map to CPU Cores (CPU-Bound Tools)

If your tool reads a file and then immediately performs heavy processing—like a linter analyzing an AST, a compiler transforming code, or a bundler minifying assets—your bottleneck is the CPU, not the disk.

  • The Heuristic: Use Number of Cores (or Cores - 1 to keep the developer's OS responsive).
  • Why: If you read 32 files concurrently but only have 8 cores, you will just end up queueing 24 files in memory waiting for a CPU thread (or Isolate, if you're working in Dart) to become available. This spikes memory usage without speeding up the overall process.

2. When to Exceed CPU Cores (I/O-Bound Tools)

If your tool is simply reading files to hash them, copy them, or do a very quick regex search, your bottleneck is the storage drive. Modern NVMe SSDs are incredibly fast and use Native Command Queuing (NCQ) to handle multiple I/O requests in parallel.

  • The Heuristic: Use a multiple of cores (e.g., Cores * 2 or Cores * 4), or a fixed cap between 16 and 64.
  • Why: An idle CPU core can easily issue multiple read requests to an SSD and wait for the hardware interrupts. Tying an I/O-bound task strictly to a small core count (like 4 or 8) will underutilize the parallel read capabilities of modern drives.

3. The Danger Zone: File Descriptor Limits

The biggest risk of cranking up concurrent reads for a developer tool isn't usually CPU thrashing; it's crashing the tool by exhausting the operating system's file descriptors.

Operating systems strictly limit how many files a single process can have open simultaneously. On many macOS and Linux systems, the default soft limit is often 1024 or even 256. If you set your concurrency to 256 or higher to crawl a massive repository, your tool will likely throw an EMFILE (Too many open files) error.

For a bulletproof developer tool, staying under a concurrency of 64 to 128 is usually the sweet spot to saturate I/O without risking OS-level file descriptor limits.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, bumped the cap to 32 in d6c584c, I think it's the safest option here to make sure pub get will not crash on less powerful machines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

noticed that there is a limit of 64 threads in the bundle_builder:

so decided to increase the cap to 64 in the c038687 on my machine runs fine

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tested this on 2-core CPU machine also, works perfectly fine

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.

Out of curiosity, do we know what are the performance gain from the two parallelization in this PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I didn't benchmark this formally, but tested it in my production application
Before the PR: 8-10 minutes
Sequential file reads: around 2 minutes
With 2 threads from the pool everywhere: around 75 seconds
With 64 threads from the pool everywhere: around 40 seconds

Seems like not that much of improvement but still nice-to-have change

@bkonyi bkonyi requested review from bkonyi and chingjun April 13, 2026 16:16
@bkonyi bkonyi added the CICD Run CI/CD label Apr 13, 2026
bkonyi
bkonyi previously approved these changes Apr 13, 2026
Copy link
Copy Markdown
Contributor

@bkonyi bkonyi left a comment

Choose a reason for hiding this comment

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

This seems reasonable, so LGTM! We'll need a second pair of eyes on this before it can land though.

@github-actions github-actions bot removed the CICD Run CI/CD label Apr 13, 2026
chingjun
chingjun previously approved these changes Apr 13, 2026
Copy link
Copy Markdown
Contributor

@chingjun chingjun left a comment

Choose a reason for hiding this comment

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

LGTM with nits in the comments.

final FileSystem fs = fileSystem ?? globals.fs;
YamlMap? pubspec;
if (pubspecCache != null) {
if (pubspecCache != null && pubspecCache.containsKey(packageRoot.toString())) {
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.

This would be a great comment to be added in the code. I was curious if the distinction between "in the map but null" or "not in the map" is important.

Comment thread packages/flutter_tools/lib/src/flutter_plugins.dart
Comment thread packages/flutter_tools/lib/src/commands/packages.dart Outdated
// Process workspace root packages concurrently, capped to
// numberOfProcessors to avoid exhausting file descriptors while still
// getting parallelism on multi-core machines.
await Pool(globals.platform.numberOfProcessors)
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.

Out of curiosity, do we know what are the performance gain from the two parallelization in this PR?

@aNOOBisTheGod aNOOBisTheGod dismissed stale reviews from chingjun and bkonyi via 516ca3a April 13, 2026 17:48
@aNOOBisTheGod aNOOBisTheGod requested a review from chingjun April 13, 2026 17:54
@chingjun chingjun added the CICD Run CI/CD label Apr 13, 2026
bkonyi
bkonyi previously approved these changes Apr 13, 2026
@bkonyi bkonyi added the autosubmit Merge PR when tree becomes green via auto submit App label Apr 13, 2026
@auto-submit
Copy link
Copy Markdown
Contributor

auto-submit bot commented Apr 13, 2026

autosubmit label was removed for flutter/flutter/184528, because - The status or check suite Linux analyze has failed. Please fix the issues identified (or deflake) before re-applying this label.

@auto-submit auto-submit bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Apr 13, 2026
@aNOOBisTheGod aNOOBisTheGod dismissed stale reviews from bkonyi and chingjun via 9bb37a4 April 13, 2026 18:40
@github-actions github-actions bot removed the CICD Run CI/CD label Apr 13, 2026
@aNOOBisTheGod
Copy link
Copy Markdown
Contributor Author

Very sorry to bother, messed up with formatting a bit :(

Now everything should be fine

@chingjun chingjun added the CICD Run CI/CD label Apr 13, 2026
@bkonyi bkonyi added the autosubmit Merge PR when tree becomes green via auto submit App label Apr 13, 2026
@auto-submit auto-submit bot added this pull request to the merge queue Apr 13, 2026
Merged via the queue into flutter:master with commit 2e3b7c0 Apr 13, 2026
158 of 159 checks passed
@flutter-dashboard flutter-dashboard bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Apr 13, 2026
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Apr 14, 2026
…PackageConfig across workspace packages during pub get post-processing (flutter/flutter#184528)
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Apr 14, 2026
…PackageConfig across workspace packages during pub get post-processing (flutter/flutter#184528)
auto-submit bot pushed a commit to flutter/packages that referenced this pull request Apr 14, 2026
flutter/flutter@2fa45e0...c1b14e9

2026-04-14 15619084+vashworth@users.noreply.github.com Rebuild flutter tool skill (flutter/flutter#184975)
2026-04-14 engine-flutter-autoroll@skia.org Roll Skia from 0851d988db03 to 4c382df6a25a (1 revision) (flutter/flutter#185025)
2026-04-14 engine-flutter-autoroll@skia.org Roll Dart SDK from 5504504b38c2 to ee5afcef0596 (1 revision) (flutter/flutter#185024)
2026-04-14 dacoharkes@google.com [ci] Split up integration.shard record_use_test.dart (flutter/flutter#185022)
2026-04-14 engine-flutter-autoroll@skia.org Roll Skia from d34c84df4c37 to 0851d988db03 (3 revisions) (flutter/flutter#185012)
2026-04-14 dacoharkes@google.com [record_use] Add recorded uses to link hooks (flutter/flutter#184869)
2026-04-14 engine-flutter-autoroll@skia.org Roll Skia from 0e98a9c635bb to d34c84df4c37 (5 revisions) (flutter/flutter#185009)
2026-04-14 engine-flutter-autoroll@skia.org Roll Dart SDK from ef28089d6533 to 5504504b38c2 (3 revisions) (flutter/flutter#185008)
2026-04-14 engine-flutter-autoroll@skia.org Roll Fuchsia Linux SDK from K_2AkZL3Drs6cGE1q... to rB8LAuZL_DwHMssTU... (flutter/flutter#185007)
2026-04-14 rmacnak@google.com [fuchsia] Replace ambient-replace-as-executable with VmexResource. (flutter/flutter#184967)
2026-04-13 chinmaygarde@google.com [Impeller] Commands that don't specify their own viewports get the viewport of the render pass. (flutter/flutter#177473)
2026-04-13 engine-flutter-autoroll@skia.org Roll Skia from bc1df263ff3f to 0e98a9c635bb (1 revision) (flutter/flutter#184995)
2026-04-13 737941+loic-sharma@users.noreply.github.com Update autosubmit guide with the emergency label (flutter/flutter#184993)
2026-04-13 69043738+aNOOBisTheGod@users.noreply.github.com [flutter_tools] Cache pubspec reads and share PackageGraph/PackageConfig across workspace packages during pub get post-processing (flutter/flutter#184528)
2026-04-13 15619084+vashworth@users.noreply.github.com Fix codesign verification test for SwiftPM Add to App (flutter/flutter#184980)
2026-04-13 87962825+kyungilcho@users.noreply.github.com Preprovision Android NDK for flavored builds and reuse matching unflavored NDKs (flutter/flutter#183555)
2026-04-13 15619084+vashworth@users.noreply.github.com Reland "Disable async mode with LLDB" (flutter/flutter#184970)
2026-04-13 engine-flutter-autoroll@skia.org Roll Skia from 55ddd6bb8be5 to bc1df263ff3f (6 revisions) (flutter/flutter#184968)
2026-04-13 matej.knopp@gmail.com Expose platform specific handles for multi-window API (flutter/flutter#184662)

If this roll has caused a breakage, revert this CL and stop the roller
using the controls here:
https://autoroll.skia.org/r/flutter-packages
Please CC boetger@google.com,stuartmorgan@google.com on the revert to ensure that a human
is aware of the problem.

To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose

To report a problem with the AutoRoller itself, please file a bug:
https://issues.skia.org/issues/new?component=1389291&template=1850622

Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
master-wayne7 pushed a commit to master-wayne7/flutter that referenced this pull request Apr 15, 2026
…fig across workspace packages during pub get post-processing (flutter#184528)

In large pub workspaces, `flutter pub get` post-processing was reading
`pubspec.yaml` files redundantly — once per transitive dependency per
workspace root package (O(N×deps) disk reads). On a repo with 500+
plugin packages this caused `flutter pub get` to take ~10 minutes.

This PR fixes that with three changes:

**1. PubspecCache** — `buildPubspecCache()` reads every package's
`pubspec.yaml` once and caches it by root URI. All workspace root
packages share this cache during `findPlugins` /
`computeTransitiveDependencies`,
reducing pubspec reads from O(N×deps) to O(deps).

**2. Shared PackageGraph and PackageConfig** — both are loaded once
from the workspace root and passed down through
`regeneratePlatformSpecificTooling`
→ `refreshPluginsList` / `injectPlugins` → `findPlugins` /
`computeTransitiveDependencies`. A `useSharedResources` guard ensures
shared caches are only used when the project is actually a member of
the workspace graph (non-workspace projects and example apps that are
not workspace roots fall back to loading their own resources).

**3. Pool-based concurrency** — workspace root packages are processed
concurrently using `package:pool`, capped to
`platform.numberOfProcessors`,
matching the pattern in `build_system.dart`.

Tested changes on the
https://github.com/aNOOBisTheGod/too-long-post-pub-get
Before: 40-60 seconds
After: ~8 seconds

Also tested on our production application
Before: 6-10 mins
After: ~40 seconds

Fixes flutter#184515
mbcorona pushed a commit to mbcorona/flutter that referenced this pull request Apr 15, 2026
…fig across workspace packages during pub get post-processing (flutter#184528)

In large pub workspaces, `flutter pub get` post-processing was reading
`pubspec.yaml` files redundantly — once per transitive dependency per
workspace root package (O(N×deps) disk reads). On a repo with 500+
plugin packages this caused `flutter pub get` to take ~10 minutes.

This PR fixes that with three changes:

**1. PubspecCache** — `buildPubspecCache()` reads every package's
`pubspec.yaml` once and caches it by root URI. All workspace root
packages share this cache during `findPlugins` /
`computeTransitiveDependencies`,
reducing pubspec reads from O(N×deps) to O(deps).

**2. Shared PackageGraph and PackageConfig** — both are loaded once
from the workspace root and passed down through
`regeneratePlatformSpecificTooling`
→ `refreshPluginsList` / `injectPlugins` → `findPlugins` /
`computeTransitiveDependencies`. A `useSharedResources` guard ensures
shared caches are only used when the project is actually a member of
the workspace graph (non-workspace projects and example apps that are
not workspace roots fall back to loading their own resources).

**3. Pool-based concurrency** — workspace root packages are processed
concurrently using `package:pool`, capped to
`platform.numberOfProcessors`,
matching the pattern in `build_system.dart`.

Tested changes on the
https://github.com/aNOOBisTheGod/too-long-post-pub-get
Before: 40-60 seconds
After: ~8 seconds

Also tested on our production application
Before: 6-10 mins
After: ~40 seconds

Fixes flutter#184515
vashworth added a commit to vashworth/flutter that referenced this pull request Apr 15, 2026
…ckageConfig across workspace packages during pub get post-processing (flutter#184528)"

This reverts commit 2e3b7c0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD tool Affects the "flutter" command-line tool. See also t: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

flutter pub get performance regression in large workspaces (introduced in #170517)

4 participants