metal-linalg

Performance headroom on an Apple M5 Pro

Could the eigensolver, the SVD and QR be made faster than they are, by more elaborate kernels or by using the CPU and the GPU at the same time? This study measures where the time goes today and tests each idea against measured ceilings. The experiments and their raw output are in performance-headroom/, built by performance-headroom/build.sh.

What came of it (2.9.0). Items 1 and 3 of the table below were built: the CPU paths now spread a batch over every core, and the tridiagonal-QL kernel is a fourth eigensolver backend, ql (eigh.md). The routing was re-measured with both (run 20261003-2d2c19), which also needed three new routing terms, because the faster CPU changed the shape of the boundaries: QR got a clause for large matrices (the GPU still wins one 2048×2048 by 1.9x while losing every mid-size batch), and the serial tridiag and bidiag backends got batch caps. Over the shapes of section 2, routed calls are now 1.5-8x faster for eigh, 1.65-4.2x for the SVD and up to 2x for QR than with 2.8’s routing, one QR shape 7% slower. The re-tune also showed the SVD’s Jacobi kernels losing to the CPU almost everywhere, as the eigensolver’s did before ql; the SVD counterpart of ql (section 4) was the open item.

And in 2.10.0 it was built: golub_kahan, Householder bidiagonalization and implicit bidiagonal QR in one threadgroup per matrix (svd.md), 1.5-2.9x faster than the Jacobi kernels and 1.2-1.8x faster than the 18-core CPU for large batches up to 48×48 (run 20261003-106b6c). It, too, needed a routing term: the CPU’s QR-first path wins tall matrices however small k is, so the SVD’s GPU-or-CPU rule caps the long side (gpu_max_l), as QR’s now has a lower bound on k (gpu_min_k) for the smallest matrices, which the parallel CPU wins at any batch.

And in 2.11.0 items 2 and 4a were built, and 4b and 4c were not:

Two things learned while building the ql kernel correct this study:

The sections below are the study as written before either change.

Machine: MacBook Pro (16-inch, M5 Pro, Mac17,8), 20 GPU cores, 18 CPU cores (6 super, 12 performance), 48 GB, macOS 27.0.1, on mains, 2026-10-03. A desktop session was running (load average about 2.7 before the runs), so treat the numbers as indicative rather than tuning-grade. Each time is the minimum of at least 7 runs after 250 ms of warm-up.

The answer

Yes, and by a lot in places. The largest single gain, though, is on the CPU side of the routing, which every GPU-against-CPU comparison depends on.

  1. The CPU path runs a batch on one core. eigh_cpu, svd_cpu and qr_cpu call LAPACK on one matrix after another. Spreading the batch over the 18 cores makes them 7.5 to 15x faster for small and mid-size matrices (3 to 7x for the largest batched shapes). Against that CPU, the GPU kernels the M5 Pro policy picks today lose or tie in 19 of the 22 batched shapes measured. Parallelising the CPU path and re-measuring the routing would make the eigh and SVD calls in that grid from 32×32 up 1.7 to 8.5x faster than today, and QR’s up to 2x, without a new kernel.
  2. The CPU and the GPU at the same time pays across a batch, not within a matrix. Giving the GPU part of a batch and the cores the rest was 1.03 to 1.7x faster than the better of the two alone wherever their speeds are within about 3x. Unified memory means nothing is copied: both read the same input and write disjoint slices of the output. Within one matrix, the steps form a dependency chain. The parts that could overlap are either matrix products, where CPU and GPU together reach only 1.13 to 1.38x of the GPU, or bandwidth-bound, where both share the same 280 GB/s.
  3. More elaborate kernels: one clear case, measured. A prototype batched eigensolver that uses LAPACK’s method on the GPU (tridiagonalization, then implicit QL) instead of Jacobi is correct to the same 1e-6. With its compact layout, at N = 24 to 32 and batches of 2048 or more, it is 3 to 4x faster than today’s GPU kernel and 2.1 to 2.3x faster than the 18-core CPU. At no N up to 32 is it slower than either. At N = 48 to 64 it is 2.2 to 3.4x faster than today’s kernel but still 0.66 to 0.86x of the CPU. The profile shows why, and what would fix it.
  4. Large single matrices have moderate headroom. In the tridiag and bidiag backends the GPU reduction runs at 1.4 to 3.4x its memory-bandwidth floor. LAPACK’s divide and conquer, 27 to 43% of the time, runs on one CPU core while 17 cores and the GPU wait. Batches of large matrices could pipeline the two (estimated 1.4 to 1.7x).
  5. What does not help, measured: the M5’s per-core neural accelerators for FP32 work (strict FP32 runs at the same 7.5 TFLOP/s as MPS), FP32 emulation from FP16 products, sharing one matrix’s products between CPU and GPU (under 3% overall), LAPACK’s MRRR solver (sstemr) instead of divide and conquer, and bisection on all cores instead of ssterf.

Ranked by value for effort:

# change gain evidence effort
1 CPU path parallel over the batch, then re-measure the routing eigh 1.7-8.5x, SVD 1.7-4.3x, QR up to 2x over today on batched shapes from 32×32; 7.5-15x on batched shapes already on the CPU measured small
2 split a batch between CPU and GPU when their speeds are close 1.03-1.7x over the better device measured (coarse split search); built in 2.11.0 (1.4-1.7x) moderate
3 batched tridiagonal-QL eigensolver kernel, then its SVD counterpart at N = 24-32, 3-4x over today’s GPU kernel and up to 2.3x over the 18-core CPU (1.1-1.8x at N = 8-16); N = 48-64 needs a register-resident layout prototype measured at N ≤ 64; both built (2.9.0, 2.10.0) substantial
4a pipeline batches of large matrices (CPU solve of one, GPU reduction of the next) 1.4-1.7x for batches estimated from measured steps; built in 2.11.0 (1.48x, 1.66x) small-moderate
4b fewer dispatches per column in the GPU reductions 1.25-1.6x at N = 2048-4096, and a lower tridiag/bidiag crossover estimated moderate
4c parallel divide and conquer (Accelerate exports slaed*, slasd*) 1.3-1.45x at N = 4096 estimated high
4d two-stage reduction with the first stage on the GPU 1.4-1.6x for eigvalsh at large N; svdvals by analogy estimated from measured CPU steps (symmetric case only) high

1. Ceilings

What this machine can do at most (exp_gemm):

  throughput error
GPU read bandwidth 282 GB/s  
CPU read bandwidth, 18 threads 230 GB/s  
MPS FP32 GEMM, 4096³ 7.08 TFLOP/s 7.0e-08
Metal 4 matmul2d, FP32, strict 7.50-7.70 TFLOP/s 7-10e-08
matmul2d, FP32, relaxed_precision 11.6 TFLOP/s 3.3e-05
matmul2d, FP16 inputs, FP32 accumulate 22.9 TFLOP/s 1.4e-05
matmul2d, BF16 inputs 22.8 TFLOP/s 1.2e-04
Accelerate cblas_sgemm on the CPU 2.53 TFLOP/s 8.8e-08
MPS on the GPU and sgemm on the CPU at once 9.74 TFLOP/s combined (7.37 + 2.36)  

The error is the largest of 256 sampled entries of $|c_{ij} - \hat c_{ij}| / (|a_i| |b_j|)$, against float64.

The neural accelerators speed up only reduced-precision inputs. Strict FP32 matmul2d runs at MPS’s speed. Emulating FP32 from three FP16 or BF16 products (hi·hi + hi·lo + lo·hi) would net about 7.6 TFLOP/s, no better than FP32 itself. FP16 would also need per-block scaling for its exponent range. So for decompositions accurate to FP32 the tensor units offer nothing on this chip. They would matter only for a GEMM-bound solver run at reduced precision, or followed by FP32 refinement, and none of the current backends is GEMM-bound (section 5).

2. The CPU path runs a batch on one core

exp_batch runs each shape four ways: the library’s CPU path as shipped (one matrix at a time); the same LAPACK calls with the batch split over the 18 cores (dispatch_apply, each worker single-threaded via BLASSetThreading); the GPU backend the M5 Pro policy picks (exp_routes confirms that it picks the GPU for every shape here); and the batch split between the GPU and the cores at once. All times are in ms.

solver shape batch today (GPU) CPU, 1 core CPU, 18 cores CPU + GPU best vs today
eigh 8×8 4096 0.50 9.01 0.64 0.47 1.06x
eigh 16×16 4096 2.17 32.97 2.16 1.28 1.70x
eigh 32×32 256 1.50 8.19 0.57 0.52 2.9x
eigh 32×32 4096 16.07 130.92 9.42 6.43 2.5x
eigh 64×64 256 9.83 35.39 2.46 2.20 4.5x
eigh 64×64 2048 75.90 283.93 18.72 16.34 4.6x
eigh 128×128 256 42.05 122.59 9.82 9.79 4.3x
eigh 256×256 64 72.61 123.44 12.08 15.71 6.0x
eigh 512×512 16 129.46 132.80 15.31 29.63 8.5x
svd 8×8 4096 1.23 18.42 1.26 0.94 1.31x
svd 32×32 4096 24.82 210.33 14.98 10.64 2.3x
svd 64×64 256 8.45 45.67 3.52 2.87 2.9x
svd 128×128 64 13.87 63.73 5.73 4.99 2.8x
svd 256×256 16 24.25 53.79 5.60 13.15 4.3x
svd 512×512 4 48.55 62.58 18.14 33.42 2.7x
svd 1024×64 64 10.30 33.36 4.91 4.58 2.2x
svd 2048×256 16 38.72 129.85 18.91 22.52 2.0x
qr 16×16 10000 3.80 21.33 2.04 1.83 2.1x
qr 64×64 1000 4.46 26.98 2.26 2.19 2.0x
qr 256×128 1000 35.30 294.89 37.40 22.90 1.54x
qr 512×512 32 16.86 93.79 12.43 10.03 1.68x
qr 1024×512 16 18.08 91.58 15.40 12.09 1.50x

Two consequences:

The change is small: chunk the batch loop over dispatch_apply with per-worker scratch, and set BLASSetThreading to single-threaded in each worker on macOS 15 and later. The routing then has to be re-measured, since every GPU/CPU boundary moves. Two caveats. A caller that already runs its own threads wants a way to cap the cores used. And on battery the GPU may be the better choice for energy even where it is slower. The M1, with 4 performance and 4 efficiency cores, would gain less (unmeasured).

3. The CPU and the GPU at the same time

Across a batch. The CPU + GPU column runs the GPU on the first g matrices from its own thread while the cores take the rest, with g the best of five fractions around the throughput-balanced split. It wins wherever the two devices are within about 3x of each other, by 1.03 to 1.70x over the better one. It gets 60-92% of the throughput the two would have with no interference (for example eigh 32×32 ×4096: 6.43 ms measured, 5.94 ms ideal): 73-92% for the longer eigh and SVD calls, 68-79% for QR, which moves more data per flop, and as little as 60% for sub-millisecond calls, where starting the threads costs. Some of the shortfall is the coarse split search rather than contention. Unified memory is what makes this cheap: the GPU reads the caller’s buffer in place and writes its slice of the output, and the CPU workers write theirs. A production version needs the split fraction per device and shape class, which the tuning harness could fit from the two timings it already takes.

Within one large matrix. The large-matrix backends (section 5) run reduction on the GPU, then the tridiagonal or bidiagonal solve on the CPU, then the back-transformation on the GPU. Each step needs the previous one’s result. What could overlap:

Across a batch of large matrices the chain can be pipelined. The CPU solves matrix b while the GPU reduces matrix b + 1, and the steady state becomes the slower of the two devices instead of their sum. From the measured steps below, that gives eigh at N = 4096 about 346 ms per matrix instead of 521 (1.5x), the SVD at k = 4096 about 1.04 s instead of 1.78 (1.7x), and eigh at N = 8192 1.36x.

4. A better kernel for batched small and mid-size matrices

The GPU loses to the parallel CPU because of the algorithm, not the hardware. Cyclic Jacobi does several times the flops of LAPACK’s method (tridiagonalization, then implicit QL or divide and conquer, then the back-transformation): by a rough count about $9N^3$ per sweep over 7 sweeps at N = 64, against $5$-$10N^3$ in all. eigh.md gives the reason it was not used: on a GPU the QL phase “applies roughly N² dependent Givens rotations, each of which would be a barrier”. That holds if the rotations are applied as they are made. It stops holding if one thread runs the QL iteration on $(d, e)$, which never reads $Z$, and records a sweep’s rotations, and then each thread applies the whole recorded sequence to its own row of $Z$. Rows are independent, so a sweep costs two barriers instead of one per rotation.

The prototype (tdql.metal) is one threadgroup per matrix, N ≤ 64, with thread i owning row i. Its steps are a Householder tridiagonalization (ssytd2), Q formed in place (sorg2r’s backward accumulation), implicit QL with the recorded sweeps, then a rank sort. Over 64 matrices per shape it is as accurate as the existing kernels: residual and orthogonality ≤ 1.5e-6 relative to $|A|_F$, eigenvalues within 7.5e-7 of LAPACK, every matrix converged. It does not yet scale magnitudes, handle NaN input or write the info word.

N batch prototype today’s GPU kernel CPU, 18 cores vs today vs 18 cores
8 4096 0.46 0.50 0.64 1.09x 1.39x
16 4096 1.22 2.15 2.16 1.76x 1.77x
24 4096 2.40 7.10 5.53 2.96x 2.30x
32 256 0.53 1.49 0.57 2.81x 1.08x
32 2048 2.13 8.26 4.41 3.88x 2.07x
32 4096 4.04 16.26 9.18 4.02x 2.27x
48 2048 13.11 29.26 11.33 2.23x 0.86x
64 256 3.57 9.97 2.34 2.79x 0.66x
64 2048 22.26 75.54 18.54 3.39x 0.83x

The CPU column is the best of all runs of that shape, so the right-hand ratio is the conservative one. N ≤ 32 uses the compact layout (4.7 KB of threadgroup memory per matrix), N > 32 the full one (17.7 KB).

What limits it (exp_stage, batch 2048):

N layout tridiagonalize form Q QL, no Z updates QL with Z updates (whole kernel)
64 17.7 KB 4.2 +3.4 +10.6 +4.2 = 22.3
32 17.7 KB 1.7 +0.6   5.9
32 4.7 KB 0.33 +0.27 +0.99 +0.40 = 1.99

The QL iteration is two-thirds of the time, and most of that is the single thread’s chain of dependent operations. Shortening the chain (one rsqrt in place of a square root and two divides) changed it by only 5%. Cutting threadgroup memory from 17.7 to 4.7 KB per matrix made the same N = 32 kernel 3x faster, with the tridiagonalization alone 5x faster. The cost is latency, so it falls with the number of matrices resident on a core, and threadgroup memory sets that number.

Next step for N = 48-128: keep each thread’s row of $A$, and later of $Z$, in registers, with only the broadcast vectors in threadgroup memory (about 1.5 KB). The tridiagonalization’s products are row-local with broadcast operands, and the QL updates are row-local, so the only cross-thread work is the column dots when forming Q (simd_sum). The N ≤ 32 result suggests a similar 2-3x, which would put N = 64 at about 2x the 18-core CPU. That is an estimate, not a measurement. Above about 128 the registers run out and the block Jacobi backend, or the CPU, stays the answer for now.

The SVD counterpart follows the same pattern: Householder bidiagonalization, then implicit-shift bidiagonal QR (sbdsqr) with each thread owning a row of U and of V. It was built in 2.10.0 (golub_kahan, see the note at the top); keeping V in device memory rather than beside the matrix in threadgroup memory was what made it pay. QR already uses the efficient algorithm on the GPU, and its gains are those of sections 2 and 3.

5. Large single matrices

Where one large eigh with eigenvectors spends its time (exp_large). The reduction is the eigvalsh time less ssterf. The back-transformation is the remainder. The floor is reading the trailing lower triangle once per column, $N^3/6$ floats, at 282 GB/s.

N tridiag backend GPU reduction floor × floor sstedc, CPU back-transform CPU path (ssyevd)
2048 115 69 20 3.4x 42 4 234
4096 521 308 (59%) 162 1.9x 175 (34%) 38 (7%) 2457
8192 3056 2034 (67%) 1300 1.56x 812 (27%) 210 (7%) 18.7 s (eigh.md)

And one large SVD with vectors (exp_svdlarge). The floor is twice the symmetric one, since both $A v$ and $A^T u$ are read each column; sbdsdc is timed on the matrix’s own bidiagonal.

k bidiag backend GPU reduction floor × floor sbdsdc, CPU back-transform CPU path (sgesdd)
2048 315 148 81 1.8x 134 (43%) 33 456
4096 1783 937 (53%) 650 1.44x 742 (42%) 104 (6%) 3612

What each opportunity is worth:

Two ideas measured and dropped:

Method and limits

docs/studies/performance-headroom/build.sh build    # library build dir; binaries go to $TMPDIR
cd ${TMPDIR:-/tmp}/metal-linalg-headroom
./exp_batch 18 && ./exp_gemm && ./exp_large && ./exp_svdlarge && ./exp_tdql
./exp_stage tdql64.metallib 64 && ./exp_stage tdql32.metallib 32
./exp_mrrr && ./exp_bisect && ./exp_stedc_threads && ./exp_routes