metalworkingGitHub
/mlx/lazy-evaluation

Lazy Evaluation

MLX operations don't compute; they record. mx.matmul(a, b) returns instantly with a graph node, and actual GPU work happens when something forces evaluation. That deferral is what lets MLX batch an entire model into few command buffers.

CUDA equivalent: closest to PyTorch's async dispatch, but stronger. PyTorch enqueues each op eagerly and hides latency with stream asynchrony; MLX doesn't even decide anything until eval, so it can see a whole subgraph and schedule it as a unit. Less like CUDA graphs than it looks (no capture/replay lifecycle), but the payoff is similar: per-op CPU overhead and per-launch GPU overhead get amortized, which matters double on a platform where the sync cost is the story.

Evaluation is forced by mx.eval(arrays...), or implicitly by anything that needs values: printing, .item(), converting to NumPy, or a control-flow branch on an array's contents. At that point the scheduler walks the graph and calls each primitive's eval_gpu (next page), encoding kernel after kernel into a shared command buffer, committing when a batch quantum fills:

void eval(array& arr) {
  auto pool = metal::new_scoped_memory_pool();
  auto s = arr.primitive().stream();
  auto& encoder = metal::get_command_encoder(s);
  ...
  arr.primitive().eval_gpu(arr.inputs(), outputs);
  ...
  if (encoder.needs_commit()) {
    encoder.commit(...);

mlx/backend/metal/eval.cpp:29-62, abridged

RECORD · ops return instantly mx.matmul mx.silu mx.add mx.matmul no GPU work yet: each call appends a node to the graph mx.eval (or .item, print, numpy, if-on-value) scheduler walks the graph · eval_gpu per primitive kernels encoded back-to-back into a shared command buffer, committed in batches THE RULE · one eval per step right: record whole decode step eval record next step (GPU still busy) eval one sync boundary per token wrong: half the step eval drain if logit > x: eval drain rest of step every accidental eval = commit, wait, wake, re-encode streams: ops run on mx.gpu or mx.cpu (arrays have no device, so a CPU-stream op costs no transfer); cross-stream dependencies are fenced automatically the DFlash war story's measured win: consolidating two evals per decode step into one

Recording is free; evaluation is the sync boundary. The bottom lanes show the same loop written well and badly: the dashed drains are what an accidental mid-loop eval costs on every single token.

The performance rule that falls out: evaluate once per step. Every mx.eval (explicit or accidental) is a synchronization boundary: commit, wait, CPU wakes, re-encode. The DFlash war story measured a real speedup from consolidating two evals per decode step into one; the classic accidental version is a Python if on a logit value mid-loop, which forces a pipeline drain per token. Structure loops so exactly one eval happens per iteration, at the end.

Streams are where operations run: mx.gpu and mx.cpu are default streams on their devices, mx.new_stream(device) makes more, and every op takes a stream= argument. Because arrays have no device, sending an op to the CPU stream is free of transfer cost, so heterogeneous pipelines (CPU pre/post-processing around GPU compute) are idiomatic rather than heroic. Cross-stream dependencies are fenced automatically by the scheduler.

Laziness also enables mx.compile: since the graph exists anyway, it can be traced, fused, and cached.