8000 metal: encode the next frame while the GPU draws the last by benface · Pull Request #695 · not-fl3/miniquad · GitHub
[go: up one dir, main page]

Skip to content

metal: encode the next frame while the GPU draws the last - #695

Open
benface wants to merge 2 commits into
not-fl3:masterfrom
benface:metal-frames-in-flight
Open

benface wants to merge 2 commits into
not-fl3:masterfrom
benface:metal-frames-in-flight

Conversation

@benface
@benface benface commented Oct 19, 2026 •
Copy link
Copy Markdown
Contributor

Builds on #677, which releases deleted textures once the frame's command buffer has run: this branch's first commit is #677's, and its second is this change.

Why

commit_frame waits for every frame's command buffer to complete, so the CPU and the GPU take turns: the CPU never starts a frame while the GPU draws the one before. In a macroquad game (CHOMP) on an iPhone 17 Pro, the main thread spent 7.3 ms of each 16.7 ms frame in that wait, on a frame that takes 1.8 ms of CPU. It still makes 60 fps, but the two together wouldn't fit a 120 Hz frame where either alone would. An app that draws off-screen as fast as it can, such as CHOMP's capture mode with #693 and #694, spends most of each frame there: 1.1 ms of a 1.5 ms frame on an M2 Max.

The wait is what kept each frame's data safe from the next:

  • Uniforms: current_frame_index moves to the next of the three uniform buffers on every apply_uniforms, not every frame, and the offset starts again at 0 each frame, so the next frame's first uniforms can land where this frame's were.
  • Buffer stores: next_value starts again at 0 each frame, so the next frame's first buffer_update writes the store this frame's first draw read.
  • Deleted objects were released as soon as the frame completed.

Change

commit_frame now waits only for the frame before the one it commits, so up to two frames are in flight:

  • Each frame in flight writes its uniforms to a buffer of its own.
  • buffer_update writes a new store each time, and reuses one it replaced only once the frames that may read it are drawn. A store is sized for its data, rounded up to a power of two, rather than for the whole buffer, which an update rarely fills: macroquad gives each draw call a 400 KB vertex buffer and refills it on every flush, so it kept 286 stores, 61.5 MB, mostly empty.
  • What a frame deletes or replaces is released once the GPU has drawn that frame, and so every frame before it.
  • texture_update_part writes the texture from the CPU at once, so it first waits for the frames in flight that sampled it. A texture made or only drawn into doesn't wait.
8000

Also, on macOS, didModifyRange names the uniforms just written, where it named the start of the buffer. A buffer holds the one reference newBuffer… returns, where it held two, so deleting one frees it.

Measurements

CHOMP, on an iPhone 17 Pro holding its heaviest level for 20 s, and on an M2 Max, alternating builds:

Before After
iPhone: main thread waiting on the GPU, a frame 7.3 ms (44%) 0 ms
iPhone: frame rate, frames late 60.0 fps, 0.1–0.2% 60.0 fps, 0.1%
iPhone: CPU a frame, median 1.8 ms 1.8–1.9 ms
Mac: 2,400 frames drawn off-screen, two levels 4.8 s, 5.7–5.9 s 1.9–2.6 s, 2.1–2.6 s
Mac: the game's footprint after 20 s 307 MB 279 MB

With two frames in flight and stores still sized for the whole buffer, the footprint was 343 MB.

Verification

  • CHOMP's captures, read back from render targets, came out byte-identical to the frames before: 1,031 frames, contact sheets and a video across five levels, and the key frames of 2,400-frame captures that read nothing back in between.
  • With Metal's validation layer and zombie objects on, the game in a window and a capture through its menus and a level, which deleted 23 textures and made 11 texture updates wait for a frame in flight, raise nothing.
  • cargo check passes for aarch64-apple-darwin, aarch64-apple-ios and x86_64-unknown-linux-gnu.

🤖 Generated with Claude Code

benface and others added 2 commits October 18, 2026 14:04
`new_texture` retained the texture `newTextureWithDescriptor` had
already returned retained, and the `MTLSamplerDescriptor` `new` had,
and `delete_texture` released only the texture, once. So no texture
deleted on Metal was ever freed, render targets included, nor any
texture's sampler descriptor or sampler state. The filter and wrap
setters also dropped the sampler state they replaced, and
`texture_set_min_filter` the descriptor it made.

Each object now holds the one reference its constructor returns.
`delete_texture` releases the texture, its sampler state and its
sampler descriptor, and each setter releases the state it replaces and
any descriptor it made for the new one.

These releases wait until `commit_frame` has seen the frame's command
buffer complete, since a draw queued earlier in the frame can still be
encoded with a texture after it's deleted. macroquad does this
whenever a font atlas grows: it deletes the old atlas texture
mid-frame, and the text it already batched is only encoded at the end
of the frame. Released at once, Metal was handed a freed texture.

Verified on macOS: a program that makes a render target with depth and
a texture, draws into the target and deletes all three every frame
now holds steady, as long as the run loop drains an autorelease pool;
without one, every frame's command buffer stays alive with the
textures it drew with. With zombies enabled, drawing text, then
growing its font's atlas in the same frame, runs clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
`commit_frame` waited for each frame's command buffer to complete, so
the CPU and the GPU took turns: the CPU never started a frame while the
GPU drew the one before. The wait kept a frame's data safe, as the next
frame wrote the same uniform buffers and buffer stores.

`commit_frame` now waits only for the frame before the one it commits,
so up to two frames are in flight:

- Each frame in flight writes its uniforms to a buffer of its own.
- `buffer_update` writes a new store each time, and reuses one it
  replaced only once the frames that may read it are drawn. A store is
  sized for its data, rounded up to a power of two, rather than for the
  whole buffer, which an update rarely fills.
- What a frame deletes or replaces is released once the GPU has drawn
  that frame, and so every frame before it.
- `texture_update_part` writes the texture from the CPU at once, so it
  first waits for the frames in flight that sampled it.

On macOS, `didModifyRange` now names the uniforms just written rather
than the start of the buffer. A buffer holds the one reference
`newBuffer…` returns, so deleting one frees it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0