Validated benchmarks and failure modes from testing 40+ repositories on M4 Pro (48GB), macOS 26.2, PyTorch 2.10.0
This document contains raw benchmark data and technical details backing the claims in the README.
- Operations That Fail
- FP8 Deep Dive
- MPS vs CPU Crossover Points
- LoRA Performance (Corrected)
- Elementwise Fusion
- Dtype Conversion
- INT8 Dequantization
- MPSGraph Fusion
- Quantization Comparison
- Weight Operation Costs
- Memory Allocation
- Transformer Building Blocks
Tested with PYTORCH_ENABLE_MPS_FALLBACK=0 to expose real failures (not silent CPU fallbacks).
| Operation | Error Message | Affected Models |
|---|---|---|
tensor.to(torch.float8_e4m3fn) |
"does not have support for that dtype" | FLUX, SD3.5, any FP8 quantized model |
tensor.to(torch.float8_e5m2) |
"does not have support for that dtype" | Training with FP8 gradients |
torch._scaled_mm(fp8, fp8) |
"not implemented for MPS" | FP8 inference pipeline |
torch.quantize_per_tensor(...) |
"QuantizedMPS backend not implemented" | PyTorch native quantization |
torch.quantize_per_channel(...) |
"QuantizedMPS backend not implemented" | Per-channel quantization |
torch.linalg.eigh(...) |
"not implemented for MPS" | PCA, spectral methods |
torch.linalg.qr(...) |
"not implemented for MPS" | QR decomposition |
| Operation | Behavior |
|---|---|
torch.linalg.svd(...) |
Prints warning, runs on CPU, returns to MPS |
Any op with PYTORCH_ENABLE_MPS_FALLBACK=1 |
Silently runs on CPU |
All standard diffusion/transformer operations pass:
F.scaled_dot_product_attention(all mask types, GQA with 32Q/8KV heads)conv2d,conv3d,conv_transpose2dF.group_norm,F.layer_norm,F.instance_normF.interpolate(nearest, bilinear, bicubic)scatter_add,gather,index_select,embeddingmultinomial,topk(50000, 50),searchsorted,gumbel_softmaxtorch.fft.rfft2,torch.fft.irfft2,torch.fft.fftF.cross_entropy,einsum
Bit layout: [sign:1][exponent:4][mantissa:3]
Bias: 7
Range: ±448.0
Precision: 3 mantissa bits = 8 distinct mantissa values per exponent
Special: 0x7F and 0xFF = NaN (no infinity representation)
Comparison:
FP8 e4m3fn: 1 + 4 + 3 = 8 bits, range ±448, ~3 decimal digits
FP16: 1 + 5 + 10 = 16 bits, range ±65504, ~3.3 decimal digits
FP32: 1 + 8 + 23 = 32 bits, range ±3.4e38, ~7.2 decimal digits
FLUX (12B params): FP32 = 48GB, FP16 = 24GB, FP8 = 12GB. FP8 halves memory vs FP16 while maintaining acceptable accuracy for inference. NVIDIA added hardware FP8 tensor cores in Ada Lovelace (RTX 4090, 2022). The ecosystem moved fast.
import torch
# This works (empty storage, no compute):
t = torch.empty(4, dtype=torch.float8_e4m3fn, device="cpu")
t_mps = t.to("mps") # Raw bytes transfer — works
# This fails (actual compute):
x = torch.randn(4, device="mps")
x.to(torch.float8_e4m3fn) # RuntimeError: MPS doesn't support dtype
# This fails (the real blocker):
a = torch.empty(4, 4, dtype=torch.float8_e4m3fn, device="mps")
b = torch.empty(4, 4, dtype=torch.float8_e4m3fn, device="mps")
torch._scaled_mm(a, b, scale_a=..., scale_b=...) # NotImplementedErrorSince Metal has no native FP8 type, we store as uint8_t and decode per-element:
inline float fp8_e4m3fn_to_float(uint8_t bits) {
if ((bits & 0x7F) == 0x7F) return 0.0f; // NaN → 0
uint sign = (bits >> 7) & 1;
uint exp_bits = (bits >> 3) & 0xF;
uint mant_bits = bits & 0x7;
float value;
if (exp_bits == 0) {
// Subnormal: 2^(1-bias) * (0.mantissa) = 2^(-6) * mant/8
value = float(mant_bits) / 8.0f * (1.0f / 64.0f);
} else {
// Normal: 2^(exp-bias) * (1.mantissa)
value = (1.0f + float(mant_bits) / 8.0f) * exp2(float(int(exp_bits) - 7));
}
return sign ? -value : value;
}Accuracy: 0.0 error across all 256 possible uint8 values when compared to Python reference decoder.
| Path | Latency | vs FP16 Native | Notes |
|---|---|---|---|
| FP16 native matmul | 0.91 ms | 1.0x | Ideal baseline |
| Our fused Metal FP8 kernel | 0.66 ms | 0.7x | Yes, faster at M=1 due to vecmat SIMD |
| INT8 GPU dequant + matmul | 1.41 ms | 1.54x | Best existing workaround |
| INT8 per-channel dequant | 2.28 ms | 2.49x | Better accuracy, slower |
| FP8 CPU dequant + transfer + matmul | 3.42 ms | 3.74x | What ComfyUI does today |
| Our Metal FP8 kernel (K=14336) | 2.38 ms | — | vs 62.8ms CPU = 26x speedup |
MPS has higher kernel dispatch overhead than CPU. Small operations are faster on CPU (using Apple's AMX matrix accelerator).
| Matrix Size | CPU Time | MPS Time | MPS/CPU Ratio | Winner |
|---|---|---|---|---|
| 32×32 | 0.025 ms | 2.854 ms | 114x slower | CPU |
| 128×128 | 0.007 ms | 0.090 ms | 13x slower | CPU |
| 256×256 | 0.010 ms | 0.050 ms | 5x slower | CPU |
| 512×512 | 0.200 ms | 0.110 ms | 1.8x faster | MPS |
| 1024×1024 | 0.800 ms | 0.400 ms | 2.0x faster | MPS |
| 2048×2048 | 5.676 ms | 3.155 ms | 1.8x faster | MPS |
| 4096×4096 | 40+ ms | 6+ ms | 6x faster | MPS |
Rule of thumb: MPS wins at 512×512 and above. Below that, CPU is faster.
Important correction: Earlier community findings that "separate LoRA is faster on MPS" were wrong. The error was caused by accidentally re-merging weights every forward pass in the benchmark.
| Configuration | MPS Separate | MPS Merged | MPS Ratio | CPU Separate | CPU Merged | CPU Ratio |
|---|---|---|---|---|---|---|
| 4096→4096, r=16 | 1.60 ms | 0.95 ms | 1.67x faster merged | 2.84 ms | 2.21 ms | 1.28x |
| 4096→4096, r=64 | 1.62 ms | 0.97 ms | 1.68x faster merged | 2.94 ms | 2.21 ms | 1.33x |
| 4096→11008, r=16 | 2.29 ms | 2.12 ms | 1.08x faster merged | 7.07 ms | 5.79 ms | 1.22x |
Why MPS penalty is worse: MPS dispatch overhead is ~10% higher than CPU per kernel. Three small matmuls (base + A + B) pay 3x the dispatch tax.
| LoRA Rank | Adapter Size | Re-merge Time |
|---|---|---|
| r=8 | 13 MB | 39 ms |
| r=16 | 26 MB | 26 ms |
| r=64 | 104 MB | 37 ms |
Re-merging is cheap enough to do on every adapter switch.
MPS genuinely fuses elementwise operations after matmul at zero cost. This is a real Metal advantage over CPU.
| Operation Added After Matmul | MPS Overhead | CPU Overhead |
|---|---|---|
| + bias | -0.4% (free) | -1.7% |
| + residual add | -4.0% (free) | +11.4% |
| + bias + SiLU activation | -0.3% (free) | +8.9% |
Negative overhead = within noise = free. CPU pays 9-11% for the same chain. MPSGraph automatically fuses consecutive elementwise ops into a single kernel.
On NVIDIA, FP32→FP16 (downcast) is faster than FP16→FP32 (upcast). On MPS, both directions are identical speed.
| Direction | MPS Bandwidth | CPU Bandwidth | Ratio (up/down) |
|---|---|---|---|
| FP32 → FP16 | 90 GB/s | 54 GB/s | |
| FP16 → FP32 | 93 GB/s | 33 GB/s | |
| Asymmetry | 0.97x (symmetric) | 1.60x (asymmetric) |
This is because MPS uses unified memory (no host↔device transfer), while CPU's AMX accelerator has asymmetric cast instructions.
INT8 dequantization genuinely runs on the Metal GPU, not falling back to CPU.
| Operation | MPS | CPU | GPU Speedup |
|---|---|---|---|
| INT8 dequant (4096×4096) | 1.89 ms | 5.08 ms | 2.69x |
| INT8 dequant + matmul (4096×4096) | 2.47 ms | 7.00 ms | 2.84x |
| INT8 dequant + matmul (4096×11008) | 6.41 ms | 16.66 ms | 2.60x |
Consistent ~2.6x GPU acceleration for INT8 operations.
MPSGraph (PyTorch's MPS backend) automatically fuses some consecutive operations. The benefit is modest but real.
| Approach | Time | Speedup |
|---|---|---|
| RMSNorm fused (no sync barriers) | 1.46 ms | baseline |
| RMSNorm step-by-step (sync between each op) | 1.79 ms | |
| Fusion benefit | 1.22x |
For maximum performance, avoid torch.mps.synchronize() between operations in the forward pass. Let MPSGraph batch them.
| Method | MPS Time (1×4096 @ 4096×4096) | vs FP16 | Notes |
|---|---|---|---|
| FP16 native | 0.91 ms | 1.0x | Baseline |
| INT8 symmetric | 2.21 ms | 2.4x | Best speed/memory tradeoff |
| INT4 grouped (g=128) | 4.13 ms | 4.5x | Bit unpacking overhead |
| INT4/INT8 ratio | 1.87x | INT4 almost 2x slower than INT8 |
On NVIDIA, INT4 and INT8 are similar speed (hardware tensor cores handle both). On MPS, INT4 is ~2x slower because Metal has no native 4-bit type — every INT4 value needs bit shifting and masking to unpack.
| Op | Time | Notes |
|---|---|---|
| reshape | 0.00 ms | Same memory, different shape |
| chunk | 0.00 ms | Views into existing tensor |
| split | 0.00 ms | Views into existing tensor |
| Op | Time (4096×4096) | Notes |
|---|---|---|
| transpose | 1.06 ms | Real memory shuffle |
| cat (2 tensors) | 1.39 ms | New allocation + copy |
| clone | 0.75 ms | Full copy |
| Layer | Size | Time | Throughput |
|---|---|---|---|
| q_proj (4096×4096) | 64 MB | 9.8 ms | 6.4 GB/s |
| gate_proj (11008×4096) | 172 MB | 22.6 ms | 7.4 GB/s |
| Full transformer layer | ~676 MB | ~92 ms | ~7.2 GB/s |
| Allocation Size | Default Watermark | Watermark=0.0 |
|---|---|---|
| 8 GB | OK | OK |
| 16 GB | OK | OK |
| 24 GB | OK (max) | OK |
| 28 GB | FAIL | OK (max) |
| 32 GB | FAIL | FAIL (Metal hard cap) |
| 48 GB (6×8GB) | OK | OK |
Multi-buffer access confirmed: 6 × 8GB buffers = 48GB total, using full unified memory. The 32GB per-buffer cap is a Metal framework limit, not a hardware limit.
All benchmarks: M4 Pro, batch=1, hidden=4096 unless noted.
| Block | Config | Time |
|---|---|---|
| RMSNorm | seq=2048, hidden=4096 | 2.2 ms |
| SwiGLU FFN | hidden=4096, intermediate=10924 | 21.5 ms |
| RoPE | seq=2048, 32 heads, d=128 | 2.8 ms |
| SDPA Attention | seq=2048, 32 heads, d=128 | 31 ms |
| KV-Cache (cold start) | First step | ~11 ms |
| KV-Cache (steady state) | cache=256, per step | 0.25 ms |
| KV-Cache (256 steps total) | 65 ms |
- All MPS benchmarks use
torch.mps.synchronize()after each operation - Timing:
time.perf_counter(), warmup=2-5 runs, measurement=5-20 runs, median reported - Environment:
PYTORCH_ENABLE_MPS_FALLBACK=0for failure testing,=1for performance testing - CPU benchmarks use the same PyTorch operations without
.to("mps") - Memory measured via
torch.mps.current_allocated_memory()andtorch.mps.driver_allocated_memory()