Skip to content

[Flutter GPU] HostBuffer never reuses a block after a boundary crossing, leaking one block per frame #192392

Description

@dzolotov

Steps to reproduce

HostBuffer is a bump allocator over a list of DeviceBuffer blocks, frameCount (4) slots deep. When a write does not fit in the current block, _allocateEmplacement rolls over into a new one; reset() rewinds the cursors so the next pass through a slot reuses that slot's blocks.

The rollover branch never reuses a block. It allocates and appends unconditionally, without checking whether the frame slot already holds a block at the new cursor from an earlier frame. So a frame that crosses one block boundary appends another blockLengthInBytes (1 024 000 by default) block to that slot on every pass, for the life of the HostBuffer. Nothing frees it — reset() only rewinds, and there is no disposal path (cf. #191867).

  1. Create a macOS app with flutter_gpu as a dependency and FLTEnableFlutterGPU set to true in macos/Runner/Info.plist.
  2. Paste the code sample below into lib/main.dart.
  3. flutter run -d macos --release.

The sample runs 200 "frames". Each fills the block exactly, writes 256 more bytes (which takes the rollover branch), and calls reset(). It records the identity of the DeviceBuffer handed out for the rollover write.

Expected results

The number of distinct DeviceBuffer objects handed out for the rollover write is bounded by frameCount: the block appended on frame n is handed back on frame n + 4, when reset() has brought the cursor round to the same slot again. Memory is flat after the first four frames.

Separately, BufferView.lengthInBytes describes the write: 256.

Actual results

200 distinct DeviceBuffer objects, one per frame. 196 blocks of 1 000 KB — about 191 MB — are allocated and never handed back, and RSS grows from 95 MB to 238 MB over the loop. At 60 fps, a renderer whose per-frame transients cross one boundary leaks on the order of 60 MB per second.

BufferView.lengthInBytes for the rollover write is 1 024 000 rather than 256.

Program output
[IMPORTANT:flutter/shell/platform/embedder/embedder_surface_metal_impeller.mm(53)] Using the Impeller rendering backend (MetalSDF).
flutter: minimumUniformByteAlignment = 256
flutter: blockLengthInBytes          = 1024000
flutter:
flutter: --- rollover BufferView ---
flutter: wrote 256 bytes
flutter: view.offsetInBytes = 0
flutter: view.lengthInBytes = 1024000   (expected 256)
flutter:
flutter: --- 200 frames, one boundary crossing each ---
flutter: frameCount                       = 4
flutter: distinct rollover DeviceBuffers  = 200   (expected at most 4)
flutter: blocks never handed back         = 196 x 1000 KB = 191 MB
flutter: RSS 95 MB -> 238 MB

Two defects, both in the same branch

Current code on master, engine/src/flutter/lib/gpu/lib/src/buffer.dart — identical in the 3.47.0 stable SDK at bin/cache/pkg/flutter_gpu/lib/src/buffer.dart, which is what the numbers above were measured on:

if (_offsetCursor + padding >= blockLengthInBytes) {
  DeviceBuffer buffer = _allocateNewBlock(blockLengthInBytes);
  _buffers[_frameCursor].add(buffer);
  _bufferCursor++;
  _offsetCursor = bytes.lengthInBytes;

  return BufferView(
    buffer,
    offsetInBytes: 0,
    lengthInBytes: blockLengthInBytes,
  );
}

1. The block is never reused. _buffers[_frameCursor] is only ever appended to. reset() sets _bufferCursor = 0, so on the next pass through this slot the allocator walks the same first block and then, at the crossing, allocates yet another one instead of taking _buffers[_frameCursor][_bufferCursor] when it already exists. That is the growth above.

2. The returned BufferView claims the whole block. lengthInBytes: blockLengthInBytes should be bytes.lengthInBytes. The write itself succeeds — DeviceBuffer.overwrite takes its length from bytes — but the caller receives a view describing a megabyte. Handing that to RenderPass.bindUniform binds a range far larger than the uniform block it stands for, which on backends with a bounded uniform range is a range the driver may refuse. Happy to split this into its own issue if triage prefers; it is three lines from the first and one patch covers both.

Both are separate from the off-by-one in the size check itself, reported at #186526 with a fix in flight at #186539. That PR changes only the condition, so neither of these is addressed by it.

Suggested fix

Sketch, untested; happy to send a PR:

if (_offsetCursor + padding + bytes.lengthInBytes > blockLengthInBytes) {
  _bufferCursor++;
  final DeviceBuffer buffer;
  if (_bufferCursor < _buffers[_frameCursor].length) {
    buffer = _buffers[_frameCursor][_bufferCursor];
  } else {
    buffer = _allocateNewBlock(blockLengthInBytes);
    _buffers[_frameCursor].add(buffer);
  }
  _offsetCursor = bytes.lengthInBytes;
  return BufferView(buffer, offsetInBytes: 0, lengthInBytes: bytes.lengthInBytes);
}

The condition also folds in #186526; if #186539 lands first, only the reuse and the view length remain. This is pure Dart — no engine C++ involved.

Code sample

Code sample
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/widgets.dart';
import 'package:flutter_gpu/gpu.dart' as gpu;

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  final context = gpu.gpuContext;
  const block = gpu.HostBuffer.kDefaultBlockLengthInBytes; // 1024000
  print('minimumUniformByteAlignment = ${context.minimumUniformByteAlignment}');
  print('blockLengthInBytes          = $block');

  // Defect 2: the BufferView returned by the rollover branch claims the whole
  // block rather than the length that was written.
  final probe = context.createHostBuffer();
  probe.emplace(ByteData(block)); // fills the block exactly, cursor == block
  final rolled = probe.emplace(ByteData(256)); // takes the rollover branch
  print('');
  print('--- rollover BufferView ---');
  print('wrote 256 bytes');
  print('view.offsetInBytes = ${rolled.offsetInBytes}');
  print('view.lengthInBytes = ${rolled.lengthInBytes}   (expected 256)');

  // Defect 1: every boundary crossing allocates a fresh block and appends it;
  // reset() rewinds the cursors but never hands an existing block back.
  // A correct bump allocator hands out the same block on the next pass through
  // the same frame slot, so the number of distinct DeviceBuffers is bounded by
  // frameCount.
  const frames = 200;
  final hostBuffer = context.createHostBuffer();
  final distinct = Set<gpu.DeviceBuffer>.identity();
  final rssBefore = ProcessInfo.currentRss;
  for (var frame = 0; frame < frames; frame++) {
    hostBuffer.emplace(ByteData(block)); // fills this frame slot's block
    distinct.add(hostBuffer.emplace(ByteData(256)).buffer); // crosses over
    hostBuffer.reset();
  }
  final rssAfter = ProcessInfo.currentRss;

  print('');
  print('--- $frames frames, one boundary crossing each ---');
  print('frameCount                       = ${hostBuffer.frameCount}');
  print('distinct rollover DeviceBuffers  = ${distinct.length}   '
      '(expected at most ${hostBuffer.frameCount})');
  print('blocks never handed back         = ${distinct.length - hostBuffer.frameCount}'
      ' x ${block ~/ 1024} KB '
      '= ${((distinct.length - hostBuffer.frameCount) * block) ~/ (1024 * 1024)} MB');
  print('RSS ${rssBefore >> 20} MB -> ${rssAfter >> 20} MB');

  exit(0);
}

Flutter Doctor output

Doctor output
[!] Flutter (Channel stable, 3.47.0, on macOS 27.0 26A5388g darwin-arm64, locale ru-TR) [277ms]
    • Flutter version 3.47.0 on channel stable
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 4cf2416426 (4 weeks ago), 2026-08-11 11:53:49 -0700
    • Engine revision 5f77625673
    • Dart version 3.13.0
    • DevTools version 2.60.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1 142ms]
    • Android SDK at ~/Library/Android/sdk
    • Emulator version 37.1.11.0 (build_id 15917651) (CL:N/A)
    • Platform android-37.0, build-tools 36.0.0
    • Java version OpenJDK Runtime Environment Temurin-26.0.1+8 (build 26.0.1+8)
    • All Android licenses accepted.

[!] Xcode - develop for iOS and macOS (Xcode 27.0) [666ms]
    • Xcode at /Applications/Xcode-beta.app/Contents/Developer
    • Build 27A5218g
    ! iOS 27.0 Simulator not installed; this may be necessary for iOS and macOS development.
    • CocoaPods version 1.16.2

[✓] Chrome - develop for the web [8ms]
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Connected device (2 available) [6,2s]
    • macOS (desktop) • macos  • darwin-arm64   • macOS 27.0 26A5388g darwin-arm64
    • Chrome (web)    • chrome • web-javascript • Google Chrome 152.0.7977.77

[✓] Network resources [728ms]
    • All expected network resources are available.

! Doctor found issues in 2 categories.

Activity

  1. saurabh-mirajkar commented on Sep 8, 2026

    @saurabh-mirajkar
    Contributor

    @dzolotov Thanks for the detailed report. I was able to reproduce both issues locally on macOS 26.6.2. Routing this to team for further evaluation.

    stable
    Flutter 3.47.2 • channel stable • https://github.com/flutter/flutter.git
    Framework • revision d3b14c8769 (12 days ago) • 2026-08-26 16:07:51 -0700
    Engine • hash 1cf1c4773fb941c4c74a7f8bb144a8837596c0f4 (revision a804b26164) (12 days ago) • 2026-08-26 18:46:13.000Z
    Tools • Dart 3.13.2 • DevTools 2.60.0
  2. added
    c: performanceRelates to speed or footprint issues (see "perf:" labels)
    perf: memoryPerformance issues related to memory
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    and removed
    in triagePresently being triaged by the triage team
    on Sep 8, 2026
  3. moved this to 🤔 Needs Triage in Flutter GPUon Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    c: performanceRelates to speed or footprint issues (see "perf:" labels)flutter-gpuhas reproducible stepsThe issue has been confirmed reproducible and is ready to work onperf: memoryPerformance issues related to memoryteam-fluttergpuOwned by Flutter GPU team

    Type

    No type

    Projects

    • Status
      🤔 Needs Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions