跪拜 Guibai
← Back to the summary

Flutter Impeller Vulkan Memory Leak: Shaky Shadow Cache Key and a Per-Frame Native Heap Tax

Recently, Flutter's #195323 is quite interesting. The user initially discovered that in a Flame game, a dozen or so HUD texts with fuzzy shadows, as long as each frame is redrawn, Native Heap keeps rising, from 34 MB to 775 MB in 27 minutes.

But later, after deleting the text and shadows, leaving only a CustomPainter that continuously triggers redraw but does not actually draw any graphics, the Native Heap under Vulkan still grows steadily, which makes one question life.

That is, text shadows repeatedly allocate textures every frame, so there is indeed a problem, but a Vulkan empty frame without text also grows memory. The reason is still unknown.

Why does unchanged text also increase memory overhead?

The author used Flutter 3.47.7, Impeller Vulkan, Release mode on an Adreno 725 with Android 16, and ran four 60-second experiments: one counter text that changes every frame, another STATIC LABEL with completely unchanged content, shadow blur radius set to 4, and compared with no blur:

Scenario Native Heap growth rate
Changing counter + unchanged label, blurRadius: 4 123 KB/s
Only changing counter, blurRadius: 4 66 KB/s
Only unchanged label, blurRadius: 4 8 KB/s
Both texts, blurRadius: 0 14 KB/s

Figure 1: Comparison chart redrawn from 60-second Native Heap sampling data of the original issue

Then look at the unchanged label. When it exists alone, although the interface still goes through the build process, essentially no continuous real redraw happens, but when placed together with the changing counter, the label is also redrawn, and the whole group's growth rate goes from 66 KB/s to 123 KB/s.

So just looking at whether the Text content has changed or whether it is const actually cannot reveal the problem, because it enters the draw process every frame.

The reason is that Flutter 3.47.7's TextShadowCache uses the address of TextFrame to generate the cache key. In the next frame, even if the characters are exactly the same, as long as the corresponding TextFrame has changed, the previous shadow may not hit the cache, and MarkFrameEnd() will directly delete cache entries that were not used in this frame.

For example, in an input field, Typing., Typing.., Typing... cycle back and forth. It looks like a few fixed states, but when a state returns after a few frames, the original cache may have already been cleared, so it has to regenerate the Gaussian Blur shadow texture.

Figure 2: The invalidation of text shadow cache and Vulkan empty-frame growth are two separate paths that need to be verified.

Flutter has already merged #190681, which changes the cache key to a content fingerprint, solving the problem of unchanged text failing to hit across frames. Then there is also #194769, which plans to keep reusing the temporary RenderTargets during blur, and only exclude the output textures that must be retained across frames from the reuse pool, while setting a budget of up to 240 unused frames and 8 MiB for the text shadow cache. That is, cache entries currently in use in the frame will not be immediately evicted due to exceeding the budget.

In the PR's Mali-G68 reproduction scenario, this set of adjustments reduced Raster P50 from 5.35 ms to 1.94 ms, a clear improvement.

But the problem is that without shadows, Vulkan still grows

The user then did a cleaner control: CustomPainter executes save/clip/translate/scale/restore every frame, but does not call any actual drawing operations.

On the same device, observing continuously for about 7 minutes, Release + Vulkan's Native Heap grew at 10.1 KB/s, and clearing the game's drawing content also gave 10.3 KB/s, while after pausing the game loop and no longer producing frames, growth dropped to 0.

And on the same device, Profile comparison:

Roughly calculated at 60Hz, Release Vulkan is equivalent to about 170-175 bytes of Native Heap growth per rendered frame, so previously the blur shadow was just significantly amplifying this phenomenon.

Interestingly, the Dart Heap remains small, and forcing Dart GC cannot make this portion of memory fall back. dumpsys meminfo's Graphics is also basically unchanged.

I feel that the Native Heap keeps growing here. It cannot be said to be a C++ object leak? It also feels like it may involve the allocator retaining memory or driver-side allocation.

This type of problem is indeed abstract. Who would have thought that memory would grow even when the text is unchanged?

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

栈与烟火

大佬太厉害了,这种问题我连怎么复现都不会。