Skip to content

Impeller and the GPU

The Render Pipeline already quoted Impeller’s core objective (“Predictable performance… compiles all shaders and reflection offline at build time”) and the real per-platform default status as of Flutter 3.47 — this lesson does not repeat either. It goes one level deeper: the actual C++ architecture Impeller is built from (straight from engine/src/flutter/impeller/README.md and the real header files at this course’s pinned revision), and it corrects a claim the internal Flutter Deep Dive team caught in an earlier draft of this course — that Impeller “merged the UI and raster threads.” It did not; that is not even the real merge that happened. The real one, quoted below, is different, more recent, and only on two platforms.

Impeller’s architecture, from its own README

Section titled “Impeller’s architecture, from its own README”

The engine’s own impeller/README.md, at this course’s pinned revision (6a19cca564 — confirmed identical between the framework and engine paths, since Flutter 3.x merged both into one monorepo), states Impeller’s five objectives verbatim — The Render Pipeline already quoted the first; the other four are new here:

“Instrumentable: All graphics resources (textures, buffers, pipeline state objects, etc..) are tagged and labeled. Animations can be captured and persisted to disk without affecting per-frame rendering performance.” “Portable: Not tied to a specific client rendering API. Shaders are authored once and converted as necessary.” “Uses Modern Graphics APIs Effectively: Makes heavy use of (but doesn’t depend on) features available in Modern APIs like Metal and Vulkan.” “Makes Effective Use of Concurrency: Can distribute single-frame workloads across multiple threads if necessary.”

(impeller/README.md#L18-L29)

The same README’s Project Organization section names the exact modules — quoted verbatim:

  • //impeller/renderer: “The very lowest level of the renderer that is still backend agnostic… Has utilities for creating allocators, generating pipeline state objects from bindings generated by //impeller/compiler, setting up render passes, managing jumbo uniform-buffers, tessellators, etc.”
  • //impeller/entity: “Sits one level above //impeller/renderer and provides a framework for building 2D renderers. Most of the pipeline state objects generated from shaders authored at build time reside in this framework.”
  • //impeller/display_list: “The replacement for //impeller/aiks to serve in the integration of Impeller in //flutter/flow… This sub-framework primarily provides a custom implementation of the flutter::DisplayListDispatcher that forwards Flutter rendering intent to Impeller.”

That middle line is worth pausing on: //impeller/aiks no longer exists as its own module in this course’s installed engine revision — it was folded into //impeller/display_list, which the README states plainly is its literal replacement. Aiks (an internal joke name, “An Impeller Kit for Skia”) survives today only as a smaller piece inside display_list: a real file at this exact revision, impeller/display_list/aiks_context.h, defines class AiksContext — its own doc comment: “Construct a new AiksContext. context: The Impeller context that Aiks should use for allocating resources and executing device commands.” If you see “Aiks” described elsewhere as Impeller’s top-level public drawing API, that description predates this course’s pinned revision; the module boundary moved, the name partially survived.

Entity, Contents, and RenderPass: the real class doc comments

Section titled “Entity, Contents, and RenderPass: the real class doc comments”

Entity’s own doc comment: “Represents a renderable object within the Impeller scene. An Entity combines graphical content (Contents) with properties like transformation (Matrix), blend mode (BlendMode), and stencil clip depth. It serves as the primary unit for constructing and rendering scenes.” Contents (the same file’s contents/contents.h) is the drawable payload an Entity carries — its RenderProc signature is bool Render(const ContentContext& renderer, const Entity& entity, RenderPass& pass), meaning every Contents subclass’s actual job is to record its drawing work into a RenderPass, whose own doc comment is exactly this: “Render passes encode render commands directed as one specific render target into an underlying command buffer.”

flowchart TD
  dl["display_list
(DisplayListDispatcher -- Flutter's rendering intent arrives here)"] --> aiks["AiksContext
(the surviving piece of the old //impeller/aiks)"]
  aiks --> entity["entity
(Entity + Contents -- 2D renderer framework, PSOs built at compile time live here)"]
  entity -->|"Contents::Render(...) writes into"| pass["RenderPass
(encodes commands into a command buffer)"]
  pass --> renderer["renderer
(backend-agnostic: allocators, PSOs, command buffers)"]
  renderer --> backend["renderer/backend
(Metal / Vulkan / OpenGL -- platform-specific)"]
Entity + Contents record work into a RenderPass, backend-agnostic renderer underneath

Why precompiled PSOs remove Skia’s shader-compile jank

Section titled “Why precompiled PSOs remove Skia’s shader-compile jank”

The mechanism The Render Pipeline already named — Skia compiling a shader the first time a draw operation is encountered, live, mid-frame — is a real cost this course’s own engine source quantifies directly. ContentContextOptions, the struct that keys Impeller’s per-pipeline variant cache, has this doc comment: “Pipeline state configuration. Each unique combination of these options requires a different pipeline state object to be built. This struct is used as a key for the per-pipeline variant cache. When adding fields to this key, reliant features should take care to limit the combinatorical explosion of variations. A sufficiently complicated Flutter application may easily require building hundreds of PSOs in total, but they shouldn’t require e.g. 10s of thousands.” This is the concrete shape of “precompiled at build time”: not one monolithic shader, but potentially hundreds of PSO variants (one per meaningful combination of blend mode, stencil mode, and similar options), every one of them built by impellerc (the offline shader compiler named in the README) before the app ever runs — none of that combinatorial cost is paid live, on a frame, the way Skia’s runtime compilation was.

Per-platform status, and a real discrepancy worth being honest about

Section titled “Per-platform status, and a real discrepancy worth being honest about”

The Render Pipeline already covers the authoritative per-platform default status for this course’s Flutter 3.47 (iOS exclusive, Android default on modern devices, macOS/Windows/Linux default since 3.47) — this lesson does not repeat it. One thing worth flagging honestly instead: this SDK’s own installed CLI text disagrees with docs.flutter.dev on Android’s current default. Running flutter run --help -v on this exact Mac (real output, no device needed for --help):

$ flutter run --help -v
--[no-]enable-impeller Whether to enable the Impeller rendering engine. Impeller is the
default renderer on iOS. On Android, Impeller is available but not
the default. This flag will cause Impeller to be used on Android.
On other platforms, this flag will be ignored.

That contradicts docs.flutter.dev/perf/impeller’s current wording (fetched during this lesson’s research) that Impeller is “available and enabled by default on Android API 29+.” This course’s own Render Pipeline lesson, verified separately against Flutter’s official 3.27 release notes, already settled on “default on modern (Vulkan-capable) Android devices” as the correct, cited claim — the point here is narrower and more useful than picking a winner: a CLI’s own --help text can genuinely lag behind current docs, on the very SDK you have installed. Treat installed-tool help text as a snapshot, not a live source, exactly the same skepticism this course teaches toward AI-generated claims.

--enable-impeller/--no-enable-impeller and the embedding-level switches

Section titled “--enable-impeller/--no-enable-impeller and the embedding-level switches”

Beyond the CLI flag quoted above, docs.flutter.dev/perf/impeller gives the real, per-embedding way to force Impeller off in a shipped app (not just during flutter run):

  • Android, AndroidManifest.xml: <meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false" />
  • iOS, Info.plist: <key>FLTEnableImpeller</key><false/>

Both are real, documented keys the embedding reads at startup — distinct from the flutter run --no-enable-impeller CLI flag, which only affects a debug/profile run from the tool, not a built, installed app.

The real GPU frame timeline: UI thread and raster thread are NOT merged

Section titled “The real GPU frame timeline: UI thread and raster thread are NOT merged”

docs.flutter.dev/tools/devtools/performance describes the two threads a frame’s time is split across, in its own words:

“The UI thread executes Dart code in the Dart VM. This includes code from your application as well as the Flutter framework. When your app creates and displays a scene, the UI thread creates a layer tree, a lightweight object containing device-agnostic painting commands, and sends the layer tree to the raster thread to be rendered on the device.”

“The raster thread executes graphics code from the Flutter Engine. This thread takes the layer tree and displays it by talking to the GPU (graphic processing unit)… Skia, the graphics library, runs on this thread. Impeller also uses this thread.”

Two genuinely separate threads, exactly as Production Concerns’s own real showPerformanceOverlay description already shows correctly (two separate graphs — “the UI thread’s per-frame time… and the raster thread’s per-frame time”). Impeller changed what runs on the raster thread (precompiled PSOs and command-buffer submission instead of Skia’s runtime shader compilation) — it did not change the thread split itself.

flutter run --trace-skia and what DevTools actually offers today

Section titled “flutter run --trace-skia and what DevTools actually offers today”

--trace-skia’s real, installed help text on this Mac:

$ flutter run --help -v
--trace-skia Enable tracing of Skia code. This is useful when debugging the raster thread
(formerly known as the GPU thread). By default, Flutter will not log Skia
code, as it introduces significant overhead that may affect recorded
performance metrics in a misleading way.

Worth noting honestly: this course’s research for this lesson looked for a dedicated “Raster stats” panel in current DevTools (a real feature in older, Skia-era tooling) and did not find one documented on docs.flutter.dev/tools/devtools/performance today — the current Performance view’s real, documented tools are the Flutter frames chart, a Frame analysis tab, a Timeline events tab, and (for exactly the paint-cost questions this course’s own Render Pipeline lesson raises) toggles for visualizing Clip, Opacity, and Physical Shape layers. This environment has no device to actually drive any of these, so this section is docs-sourced, not demonstrated — stated plainly rather than guessed at.

saveLayer’s real cost, from its own doc comment

Section titled “saveLayer’s real cost, from its own doc comment”

Canvas.saveLayer — the mechanism Clip.antiAliasWithSaveLayer (The Render Pipeline’s own proof) and a fractional-opacity Opacity widget both ultimately use — has a “Performance considerations” section in its own dart:ui doc comment, quoted verbatim:

“Generally speaking, saveLayer is relatively expensive. There are a several different hardware architectures for GPUs…, but most of them involve batching commands and reordering them for performance. When layers are used, they cause the rendering pipeline to have to switch render target (from one layer to another). Render target switches can flush the GPU’s command buffer, which typically means that optimizations that one could get with larger batching are lost. Render target switches also generate a lot of memory churn because the GPU needs to copy out the current frame buffer contents…, and then needs to copy it back in once the previous render target (layer) is restored.”

This is the precise mechanism behind every “avoid saveLayer” recommendation in this course: not a vague slowness, but a specific, named GPU cost (command-buffer flush + frame-buffer memory churn) that happens on the raster thread proven above, on every render-target switch a layer forces. ImageFilter.blur (used by BackdropFilter for frosted-glass effects, and by ImageFiltered) goes through this same offscreen-buffer machinery to apply the Gaussian kernel, so it inherits this exact cost profile — the same reason the course already teaches to reach for it deliberately, not as a default.

Proven: image decode is still a real, measurable cost — even on Impeller

Section titled “Proven: image decode is still a real, measurable cost — even on Impeller”

Impeller removes shader-compile jank at the raster step; it does nothing about the cost of turning compressed image bytes (PNG/JPEG/etc.) into raw pixels in the first place. A real, timed decode on this Mac:

test/decode_timing_test.dart
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
Future<Uint8List> makePngBytes(int side) async {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final paint = ui.Paint();
for (var i = 0; i < 200; i++) {
paint.color = ui.Color.fromARGB(255, (i * 37) % 255, (i * 91) % 255, (i * 53) % 255);
canvas.drawCircle(ui.Offset((i * 13 % side).toDouble(), (i * 29 % side).toDouble()), 30, paint);
}
final picture = recorder.endRecording();
final image = await picture.toImage(side, side);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData!.buffer.asUint8List();
}
void main() {
test('instantiateImageCodec decode timing scales with image size', () async {
final small = await makePngBytes(64);
final large = await makePngBytes(2048);
final swSmall = Stopwatch()..start();
final codecSmall = await ui.instantiateImageCodec(small);
final frameSmall = await codecSmall.getNextFrame();
swSmall.stop();
final swLarge = Stopwatch()..start();
final codecLarge = await ui.instantiateImageCodec(large);
final frameLarge = await codecLarge.getNextFrame();
swLarge.stop();
// Real, printed numbers from this Mac -- see below.
// ignore: avoid_print
print('decode small (${frameSmall.image.width}x${frameSmall.image.height}): ${swSmall.elapsedMicroseconds}us');
// ignore: avoid_print
print('decode large (${frameLarge.image.width}x${frameLarge.image.height}): ${swLarge.elapsedMicroseconds}us');
expect(frameSmall.image.width, 64);
expect(frameLarge.image.width, 2048);
});
}
instantiateImageCodec decode timing scales with image size
small PNG bytes: 1166, large PNG bytes: 211695
decode small (64x64): 4650us
decode large (2048x2048): 11930us
00:00 +1: All tests passed!

(real, unedited numbers from a flutter test run on this Mac — timings vary run to run and by machine; the point proven is the relative cost, not an absolute number to memorize)

11.93ms for one large decode is not nothing against a ~16.6ms frame budget (The Render Pipeline’s own number) — and this timing measures the full await latency the calling code experiences regardless of which thread does the underlying work. In fact, modern Flutter dispatches most of the actual pixel-decoding work to the engine’s IO thread, specifically to avoid blocking the UI thread for the full decode — but the UI thread still has to block briefly parsing the image header, and a real, currently open flutter/flutter issue documents that certain platform-codec paths (iOS’s native HEIC decoder, invoked via ImageDescriptor::initEncoded) are called synchronously on the UI thread, not the IO thread, which is exactly the kind of jank Impeller’s PSO precompilation cannot touch, because it never happens on the raster thread at all — it happens before the frame’s paint step is even reached. precacheImage, cacheWidth/cacheHeight (to avoid decoding pixels you’ll immediately downscale away), and simply not decoding a full-resolution image for a thumbnail are the real, practical mitigations — none of them are an Impeller setting, because this cost sits upstream of everything Impeller touches.

Think of the old Skia pipeline as a print shop that mixes each new ink color live, the first time a job calls for it — the first customer to ask for “forest green” waits while the ink is mixed. Impeller’s impellerc is that same shop pre-mixing every ink color the shop’s catalog of jobs could ever need, in bulk, overnight, before the doors open — ContentContextOptions’ “hundreds of PSOs, not 10,000s” is literally the shop’s own estimate of how many colors that catalog actually needs. Entity+Contents+RenderPass is the shop’s own internal assembly line (design → ink selection → the actual press run), all of it happening on the raster thread proven above, downstream of the UI thread’s layer-tree “purchase order.” Image decode is a different vendor entirely — unpacking a shipment of pre-printed material — and no amount of ink pre-mixing speeds up unpacking a large box.

ChoiceBenefitCost
Precompiled PSOs (Impeller’s core design, proven above)No live shader-compile stutter; ContentContextOptions bounds the variant count deliberatelyThe variant space must genuinely stay bounded (“hundreds, not 10,000s”) — an engine feature that explodes PSO combinations undermines the whole design
AiksContext/display_list as the integration surfaceA stable, documented seam between Flutter’s DisplayListDispatcher and Impeller’s internalsThe module boundary has moved before (aiks → display_list) — code or docs referencing the old module name are describing history, not this revision
saveLayer/ImageFilter.blurReal visual effects (frosted glass, precise anti-aliased clipping) with no alternativeA genuine, named GPU cost (command-buffer flush, memory churn) proven above from the engine’s own doc comment — not free on Impeller either
Offloading image decode work to the IO threadKeeps most decode cost off the UI thread’s critical pathNot universal — a real, cited GitHub issue shows some platform-codec paths still block the UI thread synchronously; cacheWidth/cacheHeight and precacheImage are the real mitigations, not an Impeller flag
  • “Impeller merged the UI thread and the raster thread into one.” Proven false above with real, quoted DevTools docs: the UI thread and raster thread remain genuinely separate; the real 3.29+ merge is UI+platform (iOS/Android), and it doesn’t touch the raster thread at all.
  • “//impeller/aiks is Impeller’s current top-level public API.” Proven false above, directly from the engine README: aiks was replaced by display_list; AiksContext survives as one class inside it, not a standalone module.
  • “Impeller means image decode is fast now too.” Proven false above with a real timed test: decode cost is a genuinely separate concern from anything Impeller’s PSO precompilation touches, and per a real GitHub issue, some platform decode paths still run synchronously on the UI thread.
  • “A CLI tool’s --help text is always in sync with the current official docs.” Proven false above by direct comparison: this SDK’s own flutter run --help -v and docs.flutter.dev/perf/impeller genuinely disagree about Android’s current Impeller default.

AI-generated Flutter code that reaches for a frosted-glass or blur effect frequently wraps BackdropFilter/ImageFilter.blur around large, frequently-rebuilding content without a RepaintBoundary (Layout and Paint Internals’s own proof) isolating it — paying saveLayer’s real, documented cost (proven above) on every rebuild of everything nearby, not just the blurred region.

// @expect-error compiles clean -- BackdropFilter's ImageFilter.blur forces the same expensive saveLayer-style offscreen pass proven above, and with no RepaintBoundary around it, every unrelated rebuild in this subtree re-triggers that cost
class FrostedHeader extends StatelessWidget {
const FrostedHeader({super.key, required this.content});
final Widget content;
@override
Widget build(BuildContext context) {
return Stack(
children: [
content,
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(color: Colors.white.withValues(alpha: 0.2)),
),
],
);
}
}

Before trusting generated code that reaches for BackdropFilter/ImageFilter.blur/saveLayer-based effects, check:

  • Is a RepaintBoundary isolating the blurred/filtered region from unrelated content that rebuilds nearby — the exact mechanism Layout and Paint Internals proved with OffsetLayer reuse?
  • Is a large network/asset image being decoded at its full resolution just to display as a small thumbnail? cacheWidth/cacheHeight on the ImageProvider, proven above to matter for a real, measured decode-timing cost, is the fix — not an Impeller setting.
  • Does the code assume --enable-impeller/FLTEnableImpeller is irrelevant “because Impeller is always on now”? Proven above: iOS has no opt-out, but Android’s default status is genuinely unsettled between this SDK’s own --help text and current docs — an app that must render identically across old and new Android devices may still need to test both paths.

🐛 Spot the bug

class ProductThumbnail extends StatelessWidget {
const ProductThumbnail({super.key, required this.imageUrl});
final String imageUrl;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 48,
height: 48,
child: Image.network(imageUrl, fit: BoxFit.cover),
);
}
}
Show the answer

Image.network with no cacheWidth/cacheHeight decodes the source image at its FULL resolution — proven above, decode time (and memory) scales with pixel count, and a product photo shot at, say, 3000x3000 for a full-screen gallery view pays that full decode cost just to be squeezed into a 48x48 thumbnail. The fix: Image.network(imageUrl, fit: BoxFit.cover, cacheWidth: 96, cacheHeight: 96) (roughly 2x the display size for higher-density screens) — this tells the underlying ImageProvider to decode at a much smaller target size, directly reducing the real cost this lesson’s own timing test measured, not just the eventual display size.

Per the engine's own README (quoted above), what happened to the //impeller/aiks module?
Quoted verbatim from ContentContextOptions' own doc comment, roughly how many PSO variants does a "sufficiently complicated" Flutter app need?
Per docs.flutter.dev (quoted above), what is the REAL relationship between the UI thread and the raster thread?
Quoted verbatim above from docs.flutter.dev, what is the REAL thread merge that happened as of Flutter 3.29, and on which platforms?
Proven above with a real timed test, what does the instantiateImageCodec decode-timing measurement show?
Per Canvas.saveLayer's own "Performance considerations" doc comment (quoted above), what specifically makes it expensive on the GPU?