SoC Evolution

Apple appears to follow a tick-tock cadence between the CPU + ANE subsystem1 and the GPU subsystem.

Generation CPU ANE GPU CPU / GPU tick-tock
M1 / A14 New platform 16 cores / 11 TOPS New Apple7 platform New baseline
M2 / A15–A16 Minor update 15.8 TOPS Minor Apple8 update Overall refinement
M3 / A17 Pro Medium update Minor / medium update Major Apple9 update GPU update
M4 / A18 Major CPU update Major increase to 38 TOPS Apple9 refinement CPU/ANE update
M5 / A19 Medium / major update M5 Max: 25 FP16 TFLOPS / 50 INT8 TOPS (measured)2; A19: refinement Major Apple10 update GPU update
M6 / A20 Pro 12-core M6 CPU / 6-core A20 Pro CPU Dual 16-core Neural Engine 12-core M6 GPU / 7-core A20 Pro GPU System-wide update3

GPU Evolution

The measured values are summarized in the chart caption.
Measured Geekbench 6 Metal compute. A20 Pro reaches 63,572, replacing the earlier forecast. Geekbench scores are workload composites, not TFLOPS.

The A20 Pro point uses a public Geekbench 6.7.2 result from September 21, 2026.4

GPU family Representative SoCs Peak SIMD-group FP16 TFLOPS (total / per core) Peak NAX FP16 TFLOPS (total / per core) Most important change
Apple7 A14 / M1 A14: 0.65 / 0.16
M1: 2.62 / 0.33
— TBDR + SIMD32 + UMA; starting point of the M-series GPU
Apple8 A15 / A16 / M2 A15: 1.71 / 0.34
A16: ~1.79 / ~0.36
M2: 3.58 / 0.36
— Cache, bandwidth, and efficiency improvements; no fundamental shader-core redesign
Apple9 A17 / A18 / M3 / M4 A17 Pro: 2.06 / 0.34
A18 Pro: 2.26 / 0.38
M3: 3.43 / 0.34
M4: 4.04 / 0.40
— New shader core, Dynamic Caching, hardware ray tracing, and dynamic occupancy5
Apple10 A19 / M5 / M6 A19 Pro: 4.98 / 0.82
M5: 8.30 / 0.82
M6: 8.46 / 0.71 measured
A19 Pro: ~9.96 / ~1.64
M5: ~16.60 / ~1.64
M6: 17–19.6 / 1.42–1.63 measured
A Neural Accelerator in every GPU core, 2× FP16 versus FP32 throughput, and Dynamic Caching Gen267
Apple11 (reported) A20 Pro / A20 A20 Pro: ~5.6 / ~0.8
A20: —
A20 Pro: —
A20: —
A20 Pro moves to 7 GPU cores and raises measured FP32 compute to about 2.8 TFLOPS; its SIMD FP16 value here applies the 2× FP16 rule.8

Each entry reads total / per GPU core for the highest-core base A- or M-series configuration. For Apple7–9, this table treats peak FP16 throughput as equal to FP32. For Apple10 and the reported Apple11 result, SIMD-group FP16 is 2× FP32. Values marked measured come from sustained peak microbenchmarks; ~ values are estimates. A20 remains blank because no reliable public measurement is available. Except for Apple’s published 2.6-TFLOPS M1 figure, earlier values come from reported GPU width and clock rate and are rounded to two decimals. Pro, Max, and Ultra variants are not included unless named.9

Basic architecture

GPU subsystem:

GPU subsystem → L2 cache → GPU core → 4× Shader Core → 32 SIMD lanes → ALU pipes

Apple GPU subsystem diagram from shared cache to GPU and shader cores.
GPU subsystem. The high-level organization from cache to GPU cores and shader cores.

Shader Core (Streaming Multiprocessor / SM Core):

Apple shader-core diagram showing SIMD lanes and execution resources.
Shader core. The execution block that schedules SIMDgroups across the core's pipelines.

Preliminary — Occupancy

Higher occupancy leads to higher throughput: more of the available TFLOPS can be achieved when more SIMDgroups are ready to use the execution pipelines.5

Occupancy diagram illustrating more concurrent SIMDgroups filling execution slots.
Occupancy. More resident SIMDgroups create more opportunities to hide latency and keep execution pipelines busy.

Dynamic Caching

  1. Dynamic register allocation
  2. Dynamic on-chip memory
  3. Occupancy Manage (Not covered in this post): decides when to schedule more SIMDgroups and when to schedule fewer to prevent register spilling5
Before Dynamic Caching, registers are allocated for peak SIMDgroup demand.
Before. Registers are pre-allocated before a SIMDgroup can be scheduled, so occupancy is limited by peak register pressure.
With Dynamic Caching, registers are allocated on demand and SIMDgroups overlap.
After. Registers are allocated on demand. Two SIMDgroups can overlap in this example, doubling occupancy and exposing more parallelism.
Separate register file, threadgroup memory, and buffer-stack cache before unified on-chip memory.
Before. Register files, threadgroup memory, and buffer-stack cache are independent regions. Occupancy is constrained by all three, and an imbalanced workload can leave capacity unused.
Unified on-chip cache dynamically partitioned among registers, threadgroup memory, and buffers.
After. The resources share a unified cache that is dynamically partitioned and allocated for different workloads.
Animation of registers, threadgroup memory, and buffer cache changing their shares of unified on-chip memory.
Dynamic partitioning. Registers, threadgroup memory, and buffer storage adjust their shares of the same on-chip capacity as workload demand changes.

Neural Accelerator (NAX)

The Tensor Core era of the Apple GPU

M5 and A19 place dedicated matrix-multiplication hardware inside every shader core.6

Apple GPU core diagram with a Neural Accelerator alongside conventional shader pipelines.
Neural Accelerator. Dedicated matrix-multiplication hardware sits alongside the conventional shader pipelines.

Speedup from NAX

NAX changes prompt processing far more than token generation.

The cleanest complete comparison in the llama.cpp reports uses the same 40-core M5 Max and Llama 2 7B workload: pp512, tg128, and full GPU offload. The legacy result is the mean of two independent 8e672ef submissions, which agree within 1.4%; the NAX result is the accepted c1d0e7a Tensor API submission.10

Prefill throughput

Prompt processing, batch size 512

The exact throughput values are available in the table below.

Decode throughput

Token generation, batch size 1

The exact throughput values are available in the table below.
M5 Max llama.cpp F16 throughput. NAX raises compute-bound prefill by 3.08×, while memory-bound single-token decode is effectively unchanged. Labels above the green bars show the with-NAX / without-NAX ratio.
Phase Format Without NAX, legacy Metal (t/s) With NAX, Tensor API (t/s) Speedup
Prefill (pp512) F16 1,025.24 3,158.49 3.08x
Decode (tg128) F16 37.82 37.11 0.98x

NAX makes prefill 3.08× faster. Decode is effectively unchanged because single-token generation remains dominated by moving model weights through memory.

GPU AI Software Stack

TensorOps

  • MatMul + Conv
  • Same API across Apple GPU generations
  • Uses NAX on supported GPUs
  • Can be mixed with other programmable Metal shader code6
Apple TensorOps software stack from frameworks to Metal shaders and Neural Accelerators.
TensorOps stack. The same API falls back to optimized shaders on older GPUs and uses Neural Accelerators on M5 and A19.

Important features:

Version Feature
26.1 BF16 support
26.3 Cooperative tensors as inputs, enabling a user-defined prologue for custom dequantization
26.4 4-bit / 8-bit integer support
27 Micro-scaling MXFP4 / MXFP8 / FP8 / 2-bit integer support

MLX Metal GEMM: from dispatch to NAX

Source snapshot: 3a62199. The following blocks are source excerpts, not standalone programs; omitted arguments use ....11

1. Layer to dispatch

Linear.__call__ chooses addmm when bias is present and matmul otherwise:12

if "bias" in self:
    x = mx.addmm(self["bias"], x, self["weight"].T)
else:
    x = x @ self["weight"].T

In Matmul::eval_gpu, layout preparation is followed by shape-based routing:

if (M == 1 && N == 1 && batch_size_out == 1 &&
    a.flags().row_contiguous && b.flags().row_contiguous &&
    a.dtype() != complex64) {
  return dot_product(...);
}

if (!a_transposed && b_transposed && gemv_wide(...)) {
  return;
}

if (std::min(M, N) == 1) {
  return gemv(...);
}

return steel_matmul(...);

This separates dot products, narrow/wide GEMV cases, and Steel GEMM.

Within Steel, a long reduction dimension K can select a split-K kernel. The regular NAX path below is one branch, not the implementation for every matrix multiplication.

2. Select a Metal kernel

NAX eligibility checks both the platform and dtype:

bool use_nax = metal::is_nax_available() &&
    !issubdtype(a.dtype(), complexfloating) &&
    (env::enable_tf32() || a.dtype() != float32);

is_nax_available() checks build support, OS version (26.2 or newer), and GPU architecture. FP16 passes the dtype filter, but still needs a supported platform.13

For regular NAX GEMM, MLX chooses a threadgroup tile bm by bn, reduction block bk, and SIMD-group arrangement wm by wn:

int bm = 128, bn = 128, bk = 512;
int wm = 4, wn = 4;

char devc = d.get_architecture().back();
if (devc == 's' || devc == 'c' || devc == 'd') {
  bk = (K >= 8192 && K > (M + N)) ? 64 : 256;
  bm = 64;
  wm = 2;
}

kname << "steel_gemm_fused_nax_"
      << (transpose_a ? 't' : 'n')
      << (transpose_b ? 't' : 'n')
      << "_" << type_to_name(a)
      << "_" << type_to_name(out)
      << "_bm" << bm << "_bn" << bn << "_bk" << bk
      << "_wm" << wm << "_wn" << wn;

The base kernel name encodes transpose flags, dtypes, and tile sizes. Batch, alignment, and epilogue flags further specialize the cached pipeline.

Without NAX, regular Steel GEMM uses steel_gemm_fused_.... Its initial tile is bm = 64, bn = 64, bk = 16, with wm = wn = 2; GEMM_TPARAM_MACRO then applies device/shape tuning.14

3. Map tiles to threadgroups

The output tile counts are rounded up, then swizzled for launch. Shown here is regular NAX GEMM; the non-NAX variant uses get_steel_gemm_fused_kernel for pipeline lookup:

int tn = (N + bn - 1) / bn;
int tm = (M + bm - 1) / bm;

// GEMMParams retains the logical tile counts and matrix strides.
int tile = 1 << swizzle_log;
tm = (tm + tile - 1) / tile;
tn = tn * tile;

MTL::Size group_dims = MTL::Size(32, wn, wm);
MTL::Size grid_dims = MTL::Size(tn, tm, batch_size_out);

auto& compute_encoder = metal::get_command_encoder(s);
auto kernel = get_steel_gemm_fused_nax_kernel(...);
compute_encoder.set_compute_pipeline_state(kernel);
compute_encoder.set_input_array(a, 0);
compute_encoder.set_input_array(b, 1);
compute_encoder.set_output_array(out, 3);
compute_encoder.set_bytes(params, 4);
// Optional bias/epilogue and batch bindings omitted.
compute_encoder.dispatch_threadgroups(grid_dims, group_dims);

swizzle_log comes from a device/shape heuristic. Each threadgroup has 32 * wn * wm threads; GEMMParams carries sizes, strides, and the swizzle needed to map the launch back to output tiles. Alignment flags select handling for partial tiles.

The binding indices match the Metal kernel’s [[buffer(0)]] input A, [[buffer(1)]] input B, [[buffer(3)]] output D, and [[buffer(4)]] parameters. MLX’s dispatch wrapper inserts dependency barriers and records the native Metal dispatch:15

void CommandEncoder::dispatch_threadgroups(
    MTL::Size grid_dims, MTL::Size group_dims) {
  maybeInsertBarrier();
  buffer_ops_++;
  get_command_encoder()->dispatchThreadgroups(grid_dims, group_dims);
}

This encodes work into a command buffer. MLX submits it with buffer_->commit() when it flushes the stream; dispatch itself does not wait for GPU completion.

Inside the NAX kernel, wm/wn partition the output tile, not the reduction dimension. The JIT passes host values bm, bn, bk, wm, wn as template arguments BM, BN, BK, WM, WN. Metal supplies simd_group_id via [[simdgroup_index_in_threadgroup]]; the kernel uses it to select a sub-tile:16

// steel_gemm_fused_nax.h: after batch and threadgroup tile offsets.
constexpr short SM = BM / WM;
constexpr short SN = BN / WN;
constexpr short TM = SM / 16;
constexpr short TN = SN / 16;

const short tm = SM * (simd_group_id / WN);
const short tn = SN * (simd_group_id % WN);

A += transpose_a ? tm : (tm * params->lda);
B += transpose_b ? (tn * params->ldb) : tn;
D += tm * params->ldd + tn;

NAXTile<AccumType, TM, TN> Dtile;
// gemm_loop<T, SM, SN, ...> accumulates this tile across K.

For BM = BN = 128 and WM = WN = 4, one threadgroup has 16 SIMD-groups (512 threads). Each group computes a 32 x 32 output sub-tile: group 5 starts at (tm, tn) = (32, 32) within the threadgroup tile. With TM = TN = 2, its accumulator contains a 2 x 2 grid of 16 x 16 fragments. The inner (16, 32, 16) operation below is a compute step, not the whole group’s tile. Kernel-local tm/tn are element offsets, unlike the host’s launch tile counts.

4. Load, compute, write

Non-NAX: shared staging, SIMD-group MMA, global store. The kernel stages input tiles in threadgroup memory before computing:14

// steel_gemm_fused.h: aligned main loop; offsets and epilogue omitted.
threadgroup T As[gemm_kernel::tgp_mem_size_a];
threadgroup T Bs[gemm_kernel::tgp_mem_size_b];
thread mma_t mma_op(simd_group_id, simd_lane_id);
thread loader_a_t loader_a(A, params->lda, As, simd_group_id, simd_lane_id);
thread loader_b_t loader_b(B, params->ldb, Bs, simd_group_id, simd_lane_id);

for (int k = 0; k < gemm_k_iterations; k++) {
  threadgroup_barrier(mem_flags::mem_threadgroup);
  loader_a.load_unsafe();
  loader_b.load_unsafe();
  threadgroup_barrier(mem_flags::mem_threadgroup);
  mma_op.mma(As, Bs);
  loader_a.next();
  loader_b.next();
}
threadgroup_barrier(mem_flags::mem_none);
return mma_op.store_result(D, params->ldd);

mma_op.mma loads fragments from As/Bs and reaches this primitive in mma.h:

METAL_FUNC static constexpr void mma(
    thread mat_type& D, thread mat_type& A,
    thread mat_type& B, thread mat_type& C) {
  simdgroup_multiply_accumulate(D, A, B, C);
}

Here mat_type is metal::simdgroup_matrix<T, 8, 8>: each of 32 threads holds two elements per fragment. This is the non-NAX matrix primitive, distinct from the NAX cooperative-tensor operation below.

NAX: private tiles, cooperative-tensor MMA, global store.

Inside steel/gemm/nax.h, the inner operation uses a (16, 32, 16) descriptor at SIMD-group scope. Cooperative tensors hold operand and accumulator fragments distributed across the group:1718

A fragment is 16x16, so 32 threads will hold kElemsPerFrag = 16x16/32 = 8

However this kernel is 16x32x16 (M, N, K), so it requires 1 A fragment(tile), 2 B fragments, 2 C fragments

constexpr auto desc = mpp::tensor_ops::matmul2d_descriptor(
    16, 32, 16, transpose_a, transpose_b, true,
    mpp::tensor_ops::matmul2d_descriptor::mode::multiply_accumulate);

mpp::tensor_ops::matmul2d<desc, metal::execution_simdgroup> gemm_op;

auto ct_a = gemm_op
    .template get_left_input_cooperative_tensor<AType, BType, CType>();
auto ct_b = gemm_op
    .template get_right_input_cooperative_tensor<AType, BType, CType>();
auto ct_c = gemm_op.template get_destination_cooperative_tensor<
    metal::remove_addrspace_t<decltype(ct_a)>,
    metal::remove_addrspace_t<decltype(ct_b)>,
    CType>();

for (short i = 0; i < kElemsPerFrag; i++) {
  ct_a[i] = A[i];
  ct_b[i] = Bn0[i];
  ct_b[kElemsPerFrag + i] = Bn1[i];
  ct_c[i] = Cn0[i];
  ct_c[kElemsPerFrag + i] = Cn1[i];
}

gemm_op.run(ct_a, ct_b, ct_c);

for (short i = 0; i < kElemsPerFrag; i++) {
  Cn0[i] = ct_c[i];
  Cn1[i] = ct_c[kElemsPerFrag + i];
}

run performs the multiply-accumulate using initialized fragments; the final loop copies the accumulator back to MLX’s fragment layout.

Cn0[i] = ct_c[i] copies a private fragment, not an output buffer. In this helper, Cn0/Cn1 are declared thread dtype_frag_t<CType>&; A and Bn0/Bn1 are const thread references. dtype_frag_t<U> is a vector of eight elements per thread.17

Storage in the source Example Meaning
thread Inner A, Bn0, Cn0 fragments Per-thread private values, normally register-resident
threadgroup Non-NAX As / Bs Shared scratchpad for threads in one threadgroup
device Outer kernel A / B / D pointers Global input/output buffers

The inner fragment named A is not the outer kernel’s device pointer named A. thread does not guarantee physical register allocation: the compiler can spill. Likewise, a device access can hit cache rather than DRAM.19

The regular NAX path loads from device buffers directly into private tiles, then stores the completed tile through the output device pointer. These excerpts show the missing input/output boundary:20

// gemm_nax.h: aligned input loads inside the reduction loop.
// A and B are const device T*.
Atile.load(A + A_offset, lda);
Btile.load(B + B_offset, ldb);
tile_matmad_nax(
    Dtile, Atile, metal::bool_constant<transpose_a>{},
    Btile, metal::bool_constant<transpose_b>{});

// steel_gemm_fused_nax.h: after gemm_loop and any epilogue.
// D is device T*; partial output tiles use store_safe instead.
Dtile.store(D, int(params->ldd));

So the regular non-NAX path explicitly stages through threadgroup memory, while this NAX path does not use that staging step. In both, fragment assignment and global-buffer storage are separate operations.

This is the GPU control surface visible in MLX: dispatch, tiling, launch geometry, and matrix primitives. The Metal compiler still owns instruction lowering.

Profiling & Debugging

Model level: Instruments

Similar to Nsight Systems

Instruments shows CPU, GPU, memory, display, command-buffer execution, and performance counters on a system timeline.6

Instruments Metal System Trace showing GPU work on a timeline.
Metal System Trace. A model-level view of GPU work on the system timeline.

Kernel level: Xcode Metal debugger

Similar to Nsight Compute

GPU trace replay isolates work for cost graphs, counters, register use, divergence, and instruction analysis.6

Xcode Metal debugger showing an isolated GPU capture and shader performance data.
Xcode Metal debugger. A kernel-level view of an isolated GPU capture and shader performance data.

References

  1. Apple Neural Engine: A Complete Guide, Execution model: reverse-engineered ANE execution context; not an Apple specification. ↩

  2. Kaiyu Shi, local M5 Max ANE throughput measurement, September 9, 2026: 25 TFLOPS at FP16 and 50 TOPS at INT8; measured results rather than Apple-published peak specifications. ↩

  3. Apple, “Apple introduces M6 and M5 Ultra” and “Apple debuts iPhone 18 Pro and iPhone 18 Pro Max”, August-September 2026: M6 has a 12-core CPU, 12-core GPU, and Dual 16-core Neural Engine; A20 Pro has a 6-core CPU, 7-core GPU, and Dual 16-core Neural Engine. ↩

  4. Primate Labs, A20 Pro Geekbench 6.7.2 Metal result, September 21, 2026: 63,572 points on an iPhone19,3. The preceding points retain the measured values used in the original chart. ↩

  5. Apple Developer, Explore GPU advancements in M3 and A17 Pro, Tech Talks: Apple family 9 shader cores, Dynamic Caching, flexible on-chip memory, dynamic occupancy, hardware ray tracing, and mesh shading. ↩ ↩2 ↩3

  6. Apple Developer, Accelerate your machine learning workloads with the M5 and A19 GPUs, Tech Talks: per-shader-core Neural Accelerators, TensorOps, cooperative tensors, Metal System Trace, and Xcode Metal debugger. ↩ ↩2 ↩3 ↩4 ↩5

  7. Mohamed Amine Bergach, “Bandwidth, Not FLOPS: FFT Kernels, Matrix Units and SAR Imaging on Apple M6”, September 2026: a 12-core Apple10 GPU measured at 8.46 TFLOPS FP16 FMA; GPU matrix units reached 17-19.6 TFLOPS on 1024-2048 matrix products. ↩

  8. Apple reports a 7-core A20 Pro GPU with up to 40% higher graphics performance in its iPhone 18 Pro announcement. Geekerwan’s A20 Pro testing, summarized by Mobbang, reports Apple family 11 and about 2.8 TFLOPS FP32; the table derives approximately 5.6 TFLOPS SIMD FP16 from the 2x FP16 rule. ↩

  9. Apple, Apple unleashes M1, November 2020, reporting 2.6 TFLOPS for M1; Philip Turner, Apple GPU microarchitecture benchmarks, FP32 FLOPS, core-count, and clock tables through Apple8; HubWeb, A-series and M-series comparison tables, used for later GPU width and clock inputs. Later values are derived estimates, not Apple-published peak specifications. ↩

  10. ggml-org, llama.cpp Apple Silicon performance discussion: M5 Max 40-core legacy Metal reports by Hassan-A-K and CSendner, plus the accepted Tensor API result. All use Llama 2 7B, pp512, tg128, and full GPU offload; the legacy pair is averaged in the table. ↩

  11. MLX, Matmul::eval_gpu, Steel dispatch, and regular NAX kernel setup. ↩

  12. MLX, Linear.__call__. ↩

  13. MLX, is_nax_available(). ↩

  14. MLX, regular non-NAX dispatch, steel_gemm_fused.h aligned loop, and mma.h SIMD-group primitive. ↩ ↩2

  15. MLX, Metal dispatch, command-buffer submission, and stream finalization. ↩

  16. MLX, JIT template arguments and NAX SIMD-group tile mapping. ↩

  17. MLX, nax.h fragment types and cooperative-tensor multiply-accumulate. ↩ ↩2

  18. Apple, Metal Shading Language Specification, tensor operations; see also “Optimize custom machine learning operations with Metal tensors”. The linked specification is updated by Apple; MLX excerpts above are pinned to a commit. ↩

  19. Apple, “Learn performance best practices for Metal shaders”, address spaces and caching; Metal Shading Language Specification, address-space semantics. ↩

  20. MLX, gemm_loop device loads and NAX output store. ↩