Repository navigation
Description
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).
- Create a macOS app with
flutter_gpuas a dependency andFLTEnableFlutterGPUset totrueinmacos/Runner/Info.plist. - Paste the code sample below into
lib/main.dart. 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 MBTwo 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.Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fields🤔 Needs Triage
Activity
saurabh-mirajkar commentedon Sep 8, 2026
@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