Metal I/O on macOS: Stream Assets with Bounded Queues and Shared Events
Move file-backed assets into Metal resources with dedicated I/O command queues, completion handling, event synchronization, and measured fallbacks.
Loading a texture or large buffer on the main thread creates a familiar hitch: the application waits for storage, decodes or copies data, and then uploads it before the next frame can proceed. Metal I/O gives Metal applications a separate queue for file-backed resource loading. An I/O command buffer can load data into a Metal buffer, texture, or system memory, and a shared event can connect that work to a regular GPU command queue.
This is a scheduling and lifetime tool, not a blanket guarantee of zero-copy access or faster storage. Metal’s resource-loading guide describes using an I/O queue alongside GPU work, loading essential assets first, and canceling batches that are no longer needed. The actual benefit depends on the device, storage, asset layout, and how much work can overlap.
Create a queue for the workload, not for every asset
The queue descriptor controls queue characteristics such as relative priority, queue type, maximum command buffers, and maximum commands in flight. A concurrent queue can run multiple I/O command buffers in parallel; those limits are workload controls, not throughput promises. Start with a small in-flight bound so a burst of requests cannot turn into unbounded memory and scratch-buffer pressure. Increase it only after measuring queue wait, completion latency, memory use, and frame-time impact on target devices.
Create both the I/O queue and its file handles from the same MTLDevice used by the destination resources. The I/O queue and command-buffer APIs are available starting with macOS 13 and iOS 16, while Apple’s makeIOFileHandle(url:) is available starting with macOS 14 and iOS 17. Guard the file-handle call with an availability check and keep a fallback for older deployment targets. Apple’s makeIOCommandQueue(descriptor:) and file-handle creation can also fail at runtime; treat a thrown error as a normal resource or feature-availability error. Query and handle the real creation result rather than gating solely on a marketing label.
An illustrative Swift-style flow for loading a contiguous file range into a preallocated buffer looks like this:
if #available(macOS 14, iOS 17, *) {
let descriptor = MTLIOCommandQueueDescriptor()
descriptor.type = .concurrent
descriptor.maxCommandBufferCount = 2
descriptor.maxCommandsInFlight = 4
let ioQueue = try device.makeIOCommandQueue(descriptor: descriptor)
let source = try device.makeIOFileHandle(url: assetURL)
let ioBuffer = ioQueue.makeCommandBuffer()
ioBuffer.addCompletedHandler { completed in
switch completed.status {
case .complete:
publishLoadedBuffer(destination)
case .cancelled:
recordCancellation()
case .error:
recordLoadError(completed.error)
default:
recordUnexpectedTerminalState(completed.status)
}
}
ioBuffer.load(destination,
offset: 0,
size: byteCount,
sourceHandle: source,
sourceHandleOffset: fileOffset)
ioBuffer.commit()
} else {
loadWithWorkerQueueAndUploadFallback(assetURL, destination)
}
This is illustrative rather than a drop-in loader: validate that byteCount fits the destination buffer and the source file range, keep the buffer and file handle alive through completion, and route publication back to the application’s appropriate execution context. The completion handler is a synchronization point for application state; it is not a reason to block the UI with waitUntilCompleted(). MTLIOCommandBuffer provides load, commit, addCompletedHandler, status, error, tryCancel, and waitUntilCompleted operations. Its status distinguishes pending, complete, cancelled, and error states (MTLIOCommandBuffer, MTLIOStatus).
Coordinate I/O with rendering explicitly
Submitting an I/O command buffer does not mean its output is already ready for a render or compute pass. When the queue workflow needs GPU work to wait for an asset, signal a MTLSharedEvent after the load commands and encode a wait for the matching event value in the consuming Metal command buffer. Use monotonically increasing values or another explicit generation scheme; a reused asset slot must not let an older completion accidentally satisfy a newer request.
ioBuffer.load(texture, /* source range and destination slice */)
ioBuffer.signalEvent(sharedEvent, value: generation)
ioBuffer.commit()
let renderBuffer = renderQueue.makeCommandBuffer()!
renderBuffer.encodeWaitForEvent(sharedEvent, value: generation)
encodePassThatUses(texture, into: renderBuffer)
renderBuffer.commit()
The excerpt omits the texture-region parameters and error handling, but captures the synchronization relationship: the consumer waits on the event value associated with that load. Apple’s resource-loading documentation specifically describes shared events for coordinating I/O and GPU queues. Do not replace the event with an assumption that “submitted first” means “finished first” across independent queues.
Treat cancellation and status as part of ownership
Streaming systems commonly request more assets than the user ultimately sees. Associate each load with an asset key and generation, and call tryCancel() only when the result is no longer useful. Cancellation is a request, not immediate permission to recycle the destination buffer. Keep the buffer, source handle, and request record valid until the command buffer reaches a terminal status and the completion handler has accounted for it. If a canceled request still completes, discard its result by checking the generation before publishing it.
Bound pending work at two levels: set queue limits in the descriptor, and apply an application-level budget for queued bytes. A queue may bound command buffers while each buffer still encodes a large amount of work. Prioritize latency-sensitive assets such as a first visible scene over speculative background batches, and cancel only work that can be safely retried or dropped.
Diagnose the stage that stalled
Record a request ID, asset URL or stable asset key, file offset and size, destination type, queue priority, submission time, completion time, terminal status, and error. Avoid logging full paths if they reveal private user data. Useful measurements separate time waiting before submission, time pending on the I/O queue, and time between I/O completion and the consumer’s first use.
- If queue or file-handle creation fails, take the non-Metal-I/O path and record the API error.
- If buffers remain pending, inspect the in-flight limit and whether the storage path or request pattern is the bottleneck.
- If the I/O command buffer completes but the consumer sees stale data, check that it waits on the same shared event and generation value.
- If completion reports an error, retain the underlying
errorand file metadata; do not report success merely because a command buffer was committed. - If the first frame still stalls, check whether CPU parsing, decompression, resource creation, or a blocking wait remains on the frame path. Metal I/O does not remove unrelated work.
Keep a conventional fallback: read or decode on a worker queue and upload through the application’s established Metal path. Feature availability and hardware storage behavior vary; compare the same assets, workload, and device configuration against that fallback instead of assuming the dedicated queue is faster.
The Swift fragments were not compiled, and no macOS Metal device was used for a hardware test. Verify availability, exact API signatures for the SDK you target, cancellation behavior, and measured frame times on each supported device family before shipping.
Related:
- Metal on macOS: Command Buffers, Resource Hazards, and GPU Completion
- Metal Binary Archives on macOS: Pipeline Compilation and Cache Strategy
Sources:
- Metal Resource Loading - Apple Developer Documentation
- MTLIOCommandQueueDescriptor - Apple Developer Documentation
- MTLIOCommandBuffer - Apple Developer Documentation
- MTLIOStatus - Apple Developer Documentation
- MTLDevice.makeIOCommandQueue(descriptor:) - Apple Developer Documentation
- MTLDevice.makeIOFileHandle(url:) - Apple Developer Documentation