8000 metal: free deleted textures and their samplers by benface · Pull Request #677 · not-fl3/miniquad · GitHub
[go: up one dir, main page]

Skip to content

metal: free deleted textures and their samplers - #677

Open
benface wants to merge 1 commit into
not-fl3:masterfrom
benface:metal-release-deleted-textures
Open

benface wants to merge 1 commit into
not-fl3:masterfrom
benface:metal-release-deleted-textures

Conversation

@benface
@benface benface commented Oct 9, 2026
Copy link
Copy Markdown
Contributor

Why

On Metal, no texture is freed when it's deleted, render targets included:

  • new_texture retains the texture newTextureWithDescriptor: already returned retained, and the sampler descriptor new already returned retained. delete_texture releases only the texture, once.
  • No texture's sampler state or sampler descriptor is ever released.
  • texture_set_min_filter, texture_set_mag_filter and texture_set_wrap drop the sampler state they replace, and texture_set_min_filter the descriptor it makes.

Change

  • Each object 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. 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: with zombie objects on, drawing text and then growing its font's atlas in the same frame sent retain to a deallocated texture. It now runs clean.

Measurements

On iOS and tvOS, this is all it takes, since UIKit's run loop drains an autorelease pool each time round. On macOS, memory only stays flat with #676 as well. Its run loop never drains one, so every frame's command buffer stays alive, with every texture it drew with.

Physical footprint of a small program on macOS (Apple silicon, Metal):

master this PR #676 both
Making a 512² render target with depth and a 512² texture each frame, drawing into the target and deleting all three, frames 60 → 300 231 → 1,026 MB 171 → 721 MB 231 → 1,025 MB 35 → 36 MB
A triangle drawn to the window, frames 600 → 1,800 34.4 → 38.5 MB 34.3 → 38.4 MB 31.6 → 32.4 MB 29.8 → 29.9 MB

A macroquad game (CHOMP) sees the same on macOS:

  • Making and dropping a 1024² render target and a texture every frame, it went from 2.7 GB to 19 GB in 20 seconds. With both PRs, it holds at 89–90 MB.
  • Remaking its textures every frame, it grew from 1.4 GB to 4.9 GB in 20 seconds. With either PR alone, it grew just as fast. With both, it held at 767 MB.

Verification

  • Each texture is deleted once by macroquad, so nothing is released twice. A 960-frame run that also changes filters and grows a font atlas every frame raises no error with Metal's validation layer and zombie objects on.
  • cargo check passes for aarch64-apple-darwin, aarch64-apple-ios and aarch64-apple-tvos.

🤖 Generated with Claude Code

< 8000 /form>
@benface
benface commented Oct 9, 2026
Copy link
Copy Markdown
Contributor Author

The Android check failed before it checked out the code: Docker Hub refused to pull the notfl3/cargo-apk image ("toomanyrequests: You have reached your unauthenticated pull rate limit"). It's unrelated to this change, and a re-run of the job should pass.

`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>
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