Repository navigation
Conversation
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_framewaits 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:
current_frame_indexmoves to the next of the three uniform buffers on everyapply_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.next_valuestarts again at 0 each frame, so the next frame's firstbuffer_updatewrites the store this frame's first draw read.Change
commit_framenow 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.
- What a frame deletes or replaces is released once the GPU has drawn that frame, and so every frame before it.
8000buffer_updatewrites 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.texture_update_partwrites 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.Also, on macOS,
didModifyRangenames the uniforms just written, where it named the start of the buffer. A buffer holds the one referencenewBuffer…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:
With two frames in flight and stores still sized for the whole buffer, the footprint was 343 MB.
Verification
cargo checkpasses foraarch64-apple-darwin,aarch64-apple-iosandx86_64-unknown-linux-gnu.🤖 Generated with Claude Code