8000 metal: build pipeline states asynchronously, and start them ahead by benface · Pull Request #688 · not-fl3/miniquad · GitHub
[go: up one dir, main page]

Skip to content

metal: build pipeline states asynchronously, and start them ahead - #688

Open
benface wants to merge 2 commits into
not-fl3:masterfrom
benface:metal-prepare-pipelines
Open

benface wants to merge 2 commits into
not-fl3:masterfrom
benface:metal-prepare-pipelines

Conversation

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

Builds on #678, which builds a pipeline state for each set of attachment formats a pipeline draws into: this branch's first commit is #678's, and its second is this change.

Why

Building a Metal pipeline state takes milliseconds when the system hasn't cached it: 3–25 ms each on an iPhone 17 Pro, in a macroquad game (CHOMP). With #678, a pipeline builds one for the view as it's made, and one for each other set of attachment formats the first time it draws into them, each with the synchronous newRenderPipelineStateWithDescriptor:error:. An app that makes many pipelines and draws with each into each kind of target while it loads, so that nothing builds mid-frame, waits for every build in turn.

Metal's asynchronous newRenderPipelineStateWithDescriptor:completionHandler: builds on threads of its own, so builds started together run at once.

Change

  • Every pipeline state is now built with newRenderPipelineStateWithDescriptor:completionHandler:, from a copy of the pipeline's descriptor with the formats filled in. The first draw that needs a state waits for it, if it isn't done yet.
  • new_pipeline starts the view's state as before, without waiting for it.
  • RenderingBackend::prepare_pipeline(pipeline, pass) starts a pipeline's state for a render pass's attachment formats, or the view's with None, ahead of its first draw there. Its default does nothing, which is all OpenGL needs.

The completion handler is a block literal laid out by hand, as miniquad has no blocks crate. It has no copy or dispose helpers: it holds the descriptor's copy and an Arc to where the result goes, as raw pointers, and releases both when Metal calls it, which Metal does once.

Measurements

CHOMP makes 31 materials while it loads, then draws a pixel with each into every kind of target it draws into: about 96 pipeline states. Its loading screen times that phase.

On an iPhone 17 Pro, with the app uninstalled before each first launch:

First launch Cached
#678 693 / 569 / 569 ms 22 ms
This PR, with every state started before the first draw 217 / 234 / 217 ms 21 ms

On a Mac (M2 Max), with a fresh bundle id for each first launch so Metal's cache for it starts empty:

First launch Cached
8000 #678 437–593 ms (6 launches) 16 ms
This PR 83–93 ms (4 launches) 15 ms

Verification

  • cargo check passes for aarch64-apple-darwin, aarch64-apple-ios, aarch64-apple-tvos and aarch64-linux-android.
  • On the iPhone, CHOMP runs through its frame bench, which plays several levels, from a first launch.
  • On macOS with MTL_DEBUG_LAYER=1, CHOMP runs through its frame bench, which plays several levels, with no validation error.
  • CHOMP's deterministic captures of a level in each of its six worlds, 116 frames, are byte-identical to metal: build pipeline states for render targets' attachment formats #678's, through Metal and through OpenGL.

🤖 Generated with Claude Code

benface and others added 2 commits October 9, 2026 16:52
A Metal pipeline state is built for one set of attachment formats, and
Metal only draws with it into a render pass whose attachments match.
Every pipeline state was built for the view's: its color format, its
depth/stencil format and its sample count. A render target that
differs in any of them failed validation as soon as a pipeline was
applied in it. On macOS, where the view has a depth/stencil buffer,
that's every render target made without a depth texture:

    For depth attachment, the renderPipelineState pixelFormat must be
    MTLPixelFormatInvalid, as no texture is set.
    For stencil attachment, the renderPipelineState pixelFormat must
    be MTLPixelFormatInvalid, as no texture is set.

With `sample_count: 4`, every 1x render target failed too, and with
the default of 1, every multisampled one:

    For color attachment 0, the texture sample count (1) does not
    match the renderPipelineState colorSampleCount (4).

A pipeline now keeps its descriptor. It still builds the view's
pipeline state when it's created, so an app that only draws to the
screen compiles the same state at the same time as before. The first
time a pass whose attachments differ applies it, it builds a state for
those formats too, and keeps it for later passes.

Only other formats are built on first use: a pipeline doesn't know
which render targets it will draw into, and building every combination
up front would make every app compile states it never uses. An app
that doesn't want a compile mid-frame can draw into each kind of
target once while it loads.

Verified on macOS with `MTL_DEBUG_LAYER=1`: with the view at 1x and at
4x, drawing into the window and into 1x and 4x render targets, with
and without depth, in the same frame raises no validation error. With
only the window drawn into, no pipeline state is built after
`new_pipeline`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Building a Metal pipeline state takes milliseconds: 3–25 ms each on an
iPhone 17 Pro with nothing cached, in a macroquad game (CHOMP). A
pipeline builds one for the view as it's made and one for each other
set of attachment formats it draws into, each with the synchronous
`newRenderPipelineStateWithDescriptor:error:`, so an app that makes
or prepares many pipelines while it loads waits for each in turn.

Each state is now built with
`newRenderPipelineStateWithDescriptor:completionHandler:`, which Metal
runs on threads of its own, so states started together build at once.
The first draw that needs one waits for it, if it isn't done yet.
`new_pipeline` starts the view's as before, without waiting for it.

`RenderingBackend::prepare_pipeline` starts a pipeline's state for a
render pass's attachment formats, or the view's, ahead of its first
draw there. An app that doesn't want a build mid-frame can start every
pipeline it has for every kind of target it draws into, then draw into
each once while it loads. It does nothing on OpenGL, which has no
such states.

The completion handler is a block literal laid out by hand, with no
copy or dispose helpers: it holds a copy of the descriptor and a
counted reference to where the result goes, and releases both when
Metal calls it, once.

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