Feat/mps gemm 4bit bf16 - #4
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Retargeted from an accidental upstream PR (bitsandbytes-foundation#2070) onto this fork. Same branch, same 23 commits.
Summary
Adds a native Metal (MPS) backend for Apple Silicon: hand-written Metal kernels for the
blockwise quantize/dequantize ops and both 4-bit matmuls, replacing the pure-PyTorch fallbacks
that MPS currently resolves to. Everything is gated behind
-DCOMPUTE_BACKEND=mpsand is inerton every other platform.
Important
This branch has picked up scope beyond its title, and I'd rather flag that than have a
reviewer discover it. See Scope at the bottom — there is an unrelated (and I think real)
Lion weight-decay bug fix in here that deserves its own PR, plus some local agent-tooling docs
that almost certainly should not land upstream. Happy to split before review.
What runs natively now
mps)quantize_blockwise/dequantize_blockwisequantize_4bit/dequantize_4bit(+.out)gemv_4bit(M == 1)F.lineargemm_4bit(general M)F.lineargemm_4bit_backward(new op)Each native path keeps its fallback and routes back to it whenever a guard fails (unsupported
blocksize,
K % 32 != 0, missing symbol in a stale dylib, and so on), so a partial or older builddegrades to today's behaviour rather than breaking.
BNB_MPS_REQUIRE_NATIVE=1turns those silentfallbacks into hard failures, which is what the test suite runs under.
bf16 needed a different GEMM.
MPSMatrixMultiplicationhard-asserts on anything butfp32/fp16/int8/int16, so bf16 originally kept the fallback — awkward, since bf16 is the dtype most
QLoRA fine-tuning actually uses.
MPSGraphdoes have a bf16 matmul, and it shares a commandbuffer with our own compute encoders (
MPSCommandBufferconforms toMTLCommandBuffer), so bf16now gets the same structure through a graph instead.
gemm_4bit_backwardis a new op.MatMul4Bit.backwardpreviously computedgrad_output @ dequantize_4bit(B)inline, which on MPS means a native dequant on one queue and amatmul on torch's — two cross-queue round trips per
Linear4bitper step, on roughly half of atraining step. The op's
defaultkernel is exactly that composition, so every other backend isunaffected and the fused MPS kernel has an oracle to be checked against.
Correctness
tests/test_mps_parity.py(new, ~1180 lines) checks every op against a CPU oracle with seededinputs, and asserts via spy that the native path is the one that actually ran — a parity test
that silently exercised the fallback would prove nothing.
quantize_4bit/dequantize_4bit/dequantize_blockwise: bit-exact across dtypes andblocksizes.
it is asserted as one — the bf16 forward reproduces the fallback bit-exactly without bias, and
the fused backward does so unconditionally (there is no bias epilogue in the backward to
double-round).
pre-dispatch
torch.mps.synchronize()no-op'd it fails 30/30 iterations.Known failures: 26, all
quantize_blockwise(int8), and they are not MPS bugs. The MPS kernelmatches an exact float64 reference on all 1,048,576 values; the CPU kernel misses 1365 because
it snaps the normalized value to a 65536-point LUT before the codebook lookup. A Python emulation
of that LUT reproduces the CPU kernel bit-for-bit. The test therefore asserts bit-exactness between
an exact kernel and an approximate oracle. I have deliberately not "fixed" this by loosening the
test — whether the CPU kernel or the test should change is a maintainer call, and I'd like
guidance. These do not affect non-macOS CI.
Performance
M4 Max, macOS 26.5. Every table was taken on an idle machine with a fixed-size
clone()controlread before and after; contention on this box has not merely added noise but reversed the winner
of an A/B, so anything measured under load was discarded rather than reported.
gemv_4bit(M == 1, the decode case): 3.4–6.2x over dequant +F.linearacrossfp16/bf16/fp32.
gemm_4bit: the win is the single sync, so it scales with how small the op is —bf16 2.09x at M=8, 1.94x at M=64, 1.09x at M=512, ~1.0x at M=2048, where the GEMM itself
dominates and MPS's GEMM ≈
F.linear's.gemm_4bit_backward: 1.14–1.31x at realistic training shapes, slightly better than theforward at the same shapes — no bias epilogue to encode and no transpose for the GEMM to absorb.
End to end on a real workload (CogView4-6B QLoRA, 512×512, batch 1), toggling the native paths in
place against one binary via env switches, n=6 per arm with half the reps in reversed arm
order:
The measurement carries its own error bar: arms that should not move a given stage move it by
1.8–3.9%, so that is the noise floor, and the effects above sit well clear of it.
Design decisions recorded, not just made
docs/apple_silicon/MPS_STATUS.mdis the op-by-op reference and also records what was tried andrejected, with the evidence — because the code cannot show that. Most relevant to review:
MTLCommandQueuewith two syncs per call, costing afixed ~0.15 ms. torch exposes no queue or stream handle; reaching
MPSStream's internals meansdlsym-ing mangled C++ inlines at header-derived offsets. Rejected as an ABI trap. The two
sanctioned alternatives (a libtorch-linked extension, or
torch.mps.compile_shader) are bothre-architectures and are written up rather than half-done.
data_ptr()is cloned rather than bound at an offset. A view'sdata_ptr()israw pointer arithmetic, not an
id<MTLBuffer>; objc-probing one SIGSEGVs uncatchably. Aload-time guard (
bnb_mps_check_buffer_contract) pins the undocumented torch contract that anMPS tensor's
data_ptr()is itsMTLBuffer, so a future torch that changes it fails loudlyinstead of casting garbage into a kernel.
Scope — please read before reviewing
Three separable things ended up on this branch:
csrc/mps_*,bitsandbytes/backends/mps/,cextension.py,_ops.py,autograd/_functions.py,backends/default/ops.py,tests/test_mps_parity.py,docs/apple_silicon/,benchmarks_wip/, and the build/packaging changes.kernel gates decoupled weight decay on
OPTIMIZER_ID == 2, which is Adagrad — so Lion gotcoupled decay (corrupting its sign update) and Adagrad got decoupled decay instead of the L2
fold it expects.
bitsandbytes/backends/triton/kernels_optim.py+ regression tests intests/test_optim.py. This affects CUDA/Triton users and has nothing to do with this branch..agents/**,agents/**,_typos.toml.