metalworkingGitHub
/metal/command-buffers

Command Buffers

GPU work in Metal is encoded into command buffers: batches of dispatches built CPU-side, committed to a queue, and executed asynchronously. How you batch them is one of the biggest free performance levers on the platform.

CUDA equivalent: a stream, roughly (MTLCommandQueue orders work like a stream does), but the batching unit has no direct CUDA analogue, and that's the part that matters. In CUDA, each kernel<<<>>> is its own submission and the driver amortizes; you think about launches. In Metal you explicitly build the batch:

MTLCommandQueue                  — ordered lane of work (make one, keep it)
  MTLCommandBuffer               — one batch: create → encode → commit
    MTLComputeCommandEncoder     — writes dispatches into the buffer:
        setComputePipelineState, setBuffer, dispatchThreadgroups, ...

Encoding happens on the CPU; commit() hands the whole batch to the GPU; waitUntilCompleted (or a completion handler) is the sync point. Two rules of thumb carry most of the value:

CHATTY: commit + wait per op CPU GPU encode wait encode wait encode op 0 op 1 every wait drains the pipeline: the GPU idles while the CPU wakes, reads, re-encodes dashed = idle BATCHED: one command buffer per forward pass encode every layer's dispatches commit CPU free (prepare next step, no waiting) op 0 · op 1 · op 2 · ... continuously busy per-dispatch overhead inside one buffer is tiny; the per-buffer cost is paid once the objects: MTLCommandQueue MTLCommandBuffer (the batch) MTLComputeCommandEncoder ordered lane of work · make one, keep it create → encode → commit → (optionally) wait setComputePipelineState, setBuffer, dispatchThreadgroups... receipts: Luminal runs all of Llama 3 8B as a single command buffer · DFlash gained a measured win from nothing but halving syncs (one mx.eval per step) · GPUStartTime / GPUEndTime on a completed buffer is the platform's only programmatic GPU timing: one more reason buffers should be meaningful units when a CUDA port is mysteriously slow at small batch sizes, count your command buffers first

The same work, submitted two ways. Every dashed gap on the left is the GPU waiting for the CPU to wake, read, and re-encode; the right side pays the per-buffer cost once.

Batch aggressively. Per-dispatch overhead within one command buffer is tiny; per-command-buffer overhead (commit, scheduling, completion) is not. The community-converged pattern for inference is one command buffer per forward pass: encode every layer's dispatches, commit once, wait once. Luminal runs all of Llama 3 8B as a single command buffer; flash-moe pre-encodes command buffers before the GPU needs them; MLX's scheduler exists to batch this way automatically. When a port from CUDA is mysteriously slow at small batch sizes, count your command buffers first.

Encoding itself can become the wall. Host-side encode costs ~2.9 µs per dispatch, size-independent, and at framework-graph scale that adds up: the MTPLX dispatch census measured one bf16 DeepSeek-V4 decode step at 19,809 dispatches in 384 command buffers, with host encode at 56-59 ms against 32-35 ms of GPU execution. The encode was exposed, not hidden. The fix was structural (collapse a normalization chain, one shared mx.compile tape): -26% dispatches, worth ~15 ms of host encode per token. So the earlier rule has a ceiling: batching hides per-buffer overhead, but a graph that emits tens of thousands of tiny dispatches pays the encoder tax anyway. Count dispatches, not just buffers.

Sync as rarely as possible. waitUntilCompleted drains the pipeline: the GPU goes idle while the CPU wakes, reads, and re-encodes. On unified memory the data is already shared, so the sync is the entire cost of reading a result. The DFlash war story got a measurable win from nothing but halving GPU→CPU syncs per decode step (one mx.eval() instead of two).

What you don't manage, coming from CUDA: events for cross-stream ordering (a single queue is ordered; multi-queue is rare in compute work), copy engines (no transfers exist), and stream priorities. Timestamps you do get: GPUStartTime/GPUEndTime on a completed command buffer are the platform's only programmatic GPU timing. Measure at command-buffer granularity, one more reason to make command buffers meaningful units of work.