Skip to content

Metal batched Cholesky - #3825

Closed
kapellirohith wants to merge 1 commit into
ml-explore:mainfrom
kapellirohith:gpu-cholesky
Closed

kapellirohith wants to merge 1 commit into
ml-explore:mainfrom
kapellirohith:gpu-cholesky

Conversation

@kapellirohith

Copy link
Copy Markdown
Contributor

Draft to check scope before polishing: is the shape-based dispatch in linalg::cholesky (GPU for batches where it measurably wins, CPU for everything else) the right mechanism, or would you prefer the GPU kernels behind an explicit opt-in, or as part of a broader linalg-on-GPU plan? The same structure extends to LU next. Happy to adjust.

Adds a Metal implementation of Cholesky::eval_gpu for batched float32 inputs, following the batched-MAGMA design (Dong, Haidar, Tomov & Dongarra): one matrix per simdgroup in registers for n <= 32, one matrix per threadgroup in threadgroup memory up to n = 64, one per threadgroup in device memory up to n = 128, and a blocked right-looking factorization above that, where the host encodes one batched panel dispatch (POTF2 + TRTRI + TRSM as a simdgroup-matrix GEMM) and one batched SYRK dispatch over 32x32 lower-triangle tiles per 32-column panel. Panels are processed in pairs so the big trailing update runs at rank 64, halving the read-modify-write traffic on the trailing matrix.

The GPU only wins for large enough batches, so linalg::cholesky consults Cholesky::use_fallback (same pattern as ScaledDotProductAttention) at graph construction and keeps every other shape on the CPU implementation. Nothing gets slower: single or small-batch factorizations run exactly the CPU path they run today. This also means cholesky now works for any shape and dtype on a GPU stream instead of raising. Minimum batch for GPU dispatch, measured on an M3 Pro at both ends of each size range: 1000 for n in [13, 31], 500 for n <= 64, 250 for n <= 128, 128 for n <= 192, 100 for n <= 256, 80 for n <= 512, 48 for n <= 1024, 16 for n <= 2048, and always CPU for n <= 12 or n > 2048.

Motivated by #1216, which stalled because a single-matrix GPU Cholesky loses to the CPU. Batching is where the GPU win is, and the fallback keeps the default path from regressing anywhere.

Also fixes a division by zero in the CPU implementation for inputs with n = 0 (cholesky(zeros((3, 0, 0))) previously aborted the process).

Benchmarks

M3 Pro, macOS 26.5, float32, best of 30 (ms). CPU is the existing LAPACK/Accelerate path.

batch n CPU GPU speedup
1000 16 0.66 0.26 2.6x
1000 32 1.62 0.68 2.4x
500 65 2.39 1.12 2.1x
1000 90 6.78 3.37 2.0x
1000 128 9.69 7.06 1.4x
1000 160 17.02 9.14 1.9x
1000 192 20.67 12.59 1.6x
1000 256 33.87 21.96 1.5x
500 384 37.73 27.63 1.4x
256 512 35.63 28.21 1.3x
48 1024 43.47 28.19 1.5x
16 2048 76.54 61.35 1.2x
100 32 0.18 -> CPU unchanged
1 1024 0.90 -> CPU unchanged
1 5000 73.2 -> CPU unchanged

Every GPU-dispatched shape measures >= 1.1x at the dispatch thresholds, checked at both ends of each size range; shapes just below a threshold run the CPU path unchanged.

Testing

Verified against the CPU implementation over n = 1..3000 and batch = 1..1000 (kernel-path boundaries, sizes that are not multiples of 8/32/64, narrow last panels, nested batch dims, upper and lower): worst relative reconstruction error ~2e-7, opposite triangle exactly zero. float64 and small batches fall back to the CPU and match it bitwise; float16/bfloat16 raise as before; zero-size inputs work on both streams. For non positive-definite inputs (documented as undefined behaviour) the GPU produces NaNs where the CPU returns a partial factorization. test_cholesky_gpu covers each kernel path, the CPU fallbacks, and zero-size inputs. Both the default and MLX_METAL_JIT=ON builds pass.

Run cholesky on the GPU for batches of float32 matrices where it is
faster than the CPU: one matrix per simdgroup for n <= 32, one per
threadgroup up to n = 128, and a blocked right-looking factorization
(batched panel + simdgroup-matrix SYRK dispatches) above that.

Shapes below the measured batch thresholds keep using the CPU
implementation via Cholesky::use_fallback, so no shape is slower than
before. cholesky now also works on any shape and dtype on a GPU stream
instead of raising. Also fixes a division by zero in the CPU
implementation for inputs with n = 0.

@zcbenz zcbenz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't silently fallback to cpu for gpu ops, the fallback is only used when fused ops being slower than unfused ones.

On the MPS cholesky kernel being slower than cpu kernel I'm almost certain that it was because of the overhead of creating the MPS graph. On the batched kernel while it is 2x faster than CPU but that is mostly because the CPU implementation is doing computation sequentially, and it would be a lot faster after we merge #3019 and use its cpu kernel thread pool for cholesky.

@kapellirohith

Copy link
Copy Markdown
Contributor Author

Makes sense on the fallback, I'll drop it and just run on the gpu stream.

Agree the 2x is mostly the serial cpu path and #3019 will close most of it. Is a gpu version worth it in any regime after that (large batches of small matrices), or better to keep these on cpu? I can hold and re-benchmark against the threaded cpu once it lands, otherwise I'll close it.

@zcbenz

zcbenz commented Jul 10, 2026

Copy link
Copy Markdown
Member

I think for small matrices the cpu route should be enough, what is needed is a GPU kernel that can efficiently handle large matrices. The MPS cholesky kernel should work with proper cache added like what we do with cuDNN kernels in CUDA backend.

@kapellirohith

Copy link
Copy Markdown
Contributor Author

Makes sense, small on cpu is fine. I'll close this. I'll look at whether a cached MPS kernel can beat cpu on large matrices, along the lines of the cuDNN caching.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants