renderer/metal: warm up the Metal device machinery at app creation

The first Metal device query in a process (MTLCopyAllDevices) takes
multiple milliseconds; once the framework is warm, subsequent queries
are effectively free (measured ~15ms cold, ~1us warm in isolation).
This cost was paid during the first surface's renderer
initialization, on the critical path to the first window.

Measured on macOS (Apple Silicon) with local timing instrumentation
during app launch, first surface renderer initialization:

  GraphicsAPI.init before: 4227us (device query ~3.5ms)
  GraphicsAPI.init after:  ~900us (device query 20-25us)
This commit is contained in:
Mitchell Hashimoto
2026-08-09 15:43:31 -07:00
parent c454a3bf47
commit 131b293dbb
2 changed files with 29 additions and 0 deletions

View File

@@ -106,6 +106,18 @@ fn threadedWarmup() void {
log.warn("font warmup thread spawn failed err={}", .{err});
}
}
// Same for the renderer's graphics API (e.g. Metal), which pays
// one-time framework initialization costs on first use.
if (comptime @hasDecl(renderer.Renderer.API, "warmup")) {
if (std.Thread.spawn(
.{},
renderer.Renderer.API.warmup,
.{},
)) |thr| thr.detach() else |err| {
log.warn("renderer warmup thread spawn failed err={}", .{err});
}
}
}
/// Initialize the main app instance. This creates the main window, sets

View File

@@ -407,6 +407,23 @@ pub inline fn beginFrame(
return try Frame.begin(.{ .queue = self.queue }, renderer, target);
}
/// Warm up the Metal device machinery. The first Metal device query in
/// a process takes multiple milliseconds; once warm, subsequent queries
/// are effectively free. Calling this early (e.g. on a background
/// thread at app startup; Metal device queries are thread-safe) moves
/// that one-time cost off the critical path of the first surface's
/// renderer initialization.
///
/// We deliberately do NOT cache the chosen device: the device set can
/// change at runtime (e.g. an eGPU being plugged in or removed) and
/// chooseDevice prefers removable GPUs, so every renderer init must
/// re-choose. Only the underlying framework initialization is a
/// one-time cost.
pub fn warmup() void {
const device = chooseDevice() catch return;
device.release();
}
fn chooseDevice() error{NoMetalDevice}!objc.Object {
var chosen_device: ?objc.Object = null;