Skip to content

Commit ed2ea2e

Browse files
eaglstunclaude
andcommitted
Merge MPS 4-bit matmul sub-phase (M1-M4): native gemv + gemm on Metal
Brings the native Metal 4-bit matmul path onto main, following the earlier quantize/dequantize phases. - M1: benchmarked the unfused baseline; decided the design fork with numbers (gemv is dequant-bound -> fuse; large-M gemm -> MPSMatrixMultiplication). - M2: fused native gemv_4bit MSL kernel (dequant in registers, no B_dq), 3.5-7x. - M3: native gemm_4bit = chunked dequant + MPSMatrixMultiplication on one command buffer (bf16 falls back -- MPSMatMul has no bf16 on macOS 26.4.1). - M4: measured the per-call sync tax (~0.15ms, architectural -- documented, not removed; queue-sharing rejected with evidence), verified the offset-0 clone is load-bearing, and updated docs honestly (README QLoRA-4bit row -> checkmark with a footnote on the bf16/large-M caveats). 328 parity tests green under BNB_MPS_REQUIRE_NATIVE=1; native paths asserted via spy tests; graceful fallback preserved. LLM.int8 and 8-bit optimizers remain out of scope on mps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 642ecb1 + 0c9db70 commit ed2ea2e

13 files changed

Lines changed: 1368 additions & 46 deletions

File tree

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,18 @@ bitsandbytes has the following minimum requirements for all platforms:
182182
<td>⬜ Metal <br><code>mps</code></td>
183183
<td>Apple M1+</td>
184184
<td>❌</td>
185-
<td>🐢</td>
185+
<td>✅ <sup>1</sup></td>
186186
<td>❌</td>
187187
</tr>
188188
</tbody>
189189
</table>
190190

191+
<small><sup>1</sup> On <code>mps</code>, 4-bit matmul runs on native Metal kernels: a fused gemv for
192+
inference (M=1) and an <code>MPSMatrixMultiplication</code>-backed GEMM for fp16/fp32 batches. bf16
193+
batched matmul and other unsupported shapes use a slower dequantize+matmul fallback, and large-batch
194+
GEMM performs on par with dequantize+matmul. Details:
195+
<a href="./docs/apple_silicon/README.md">docs/apple_silicon</a>.</small>
196+
191197
## :book: Documentation
192198

193199
- [Official Documentation](https://huggingface.co/docs/bitsandbytes/main)

_typos.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ extend-ignore-re = [
1515
extend-ignore-identifiers-re = [
1616
".*arange.*",
1717
".*ARANGE.*",
18+
"numer", # mach_timebase_info_data_t.numer (csrc/mps_ops.mm)
1819
]
1920

2021
[type.py.extend-words]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Phase M1 baseline + Phase M3 comparison for gemm_4bit.
2+
3+
M1 question: as M grows, does the GEMM cost overtake dequant? (Answer: crossover near
4+
M~512; below it gemm is dequant-bound.)
5+
6+
M3 question: what does the native path (chunked dequant -> scratch -> MPSMatrixMultiplication
7+
-> bias, ONE command buffer / ONE sync) buy over the dequant + F.linear fallback (native
8+
dequant wait + torch GEMM + second sync)? `gemm_4bit` routes native automatically when built,
9+
so `native` here is just the op; `fallback` reproduces the old tail verbatim.
10+
"""
11+
12+
import time
13+
14+
import torch
15+
16+
import bitsandbytes.backends.mps.ops as mps_ops
17+
import bitsandbytes.functional as F
18+
19+
DEV = "mps"
20+
ITERS = 30
21+
WARMUP = 8
22+
23+
24+
def sync():
25+
torch.mps.synchronize()
26+
27+
28+
def timed(fn):
29+
for _ in range(WARMUP):
30+
fn()
31+
sync()
32+
t0 = time.perf_counter()
33+
for _ in range(ITERS):
34+
fn()
35+
sync()
36+
return (time.perf_counter() - t0) / ITERS * 1e3
37+
38+
39+
def bench(M, N, K, dtype, quant_type="nf4", blocksize=64):
40+
A = torch.randn(1, M, K, dtype=dtype, device=DEV)
41+
B = torch.randn(N, K, dtype=dtype, device=DEV)
42+
B_q, qs = F.quantize_4bit(B, blocksize=blocksize, quant_type=quant_type)
43+
44+
def native():
45+
# Routes through bnb_mps_gemm_4bit when the native library is built (fp32/fp16).
46+
return torch.ops.bitsandbytes.gemm_4bit(A, B_q, list(B.shape), qs.absmax, blocksize, quant_type)
47+
48+
def dequant_only():
49+
return torch.ops.bitsandbytes.dequantize_4bit(B_q, qs.absmax, blocksize, quant_type, list(B.shape), dtype)
50+
51+
def fallback():
52+
# The pre-M3 tail: native dequant (its own sync) + torch F.linear (torch's queue).
53+
B_dq = mps_ops._dequantize_4bit_impl(B_q, qs.absmax, blocksize, quant_type, list(B.shape), dtype)
54+
return torch.nn.functional.linear(A, B_dq)
55+
56+
B_dq = dequant_only()
57+
58+
def linear_only():
59+
return torch.nn.functional.linear(A, B_dq)
60+
61+
t_nat, t_fb, t_deq, t_lin = timed(native), timed(fallback), timed(dequant_only), timed(linear_only)
62+
print(
63+
f" M={M:>4} N={N:>5} K={K:>5} {str(dtype).replace('torch.', ''):>8} "
64+
f"native={t_nat:7.3f}ms fallback={t_fb:7.3f}ms ({t_fb / t_nat:4.2f}x) "
65+
f"[fallback = dequant {t_deq:6.3f} + linear {t_lin:6.3f}]"
66+
)
67+
68+
69+
if __name__ == "__main__":
70+
native = "native" if mps_ops._native_available() else "FALLBACK-ONLY (no native build)"
71+
print(f"iters={ITERS} warmup={WARMUP} device={DEV} lib={native}\n")
72+
for dtype in (torch.float16, torch.float32):
73+
print(f"=== gemm_4bit, N=K=4096, {str(dtype).replace('torch.', '')} ===")
74+
for M in (8, 64, 512, 2048):
75+
bench(M, 4096, 4096, dtype)
76+
print()

benchmarks_wip/bench_gemv_fused.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Phase M2: fused native gemv_4bit vs the Phase-M1 baseline (dequant -> F.linear).
2+
3+
Per shape, times:
4+
- fused torch.ops.bitsandbytes.gemv_4bit (routes to the fused Metal kernel)
5+
- baseline native dequant of B + F.linear (what gemv_4bit did before Phase M2)
6+
and reports ms/iter plus the fused kernel's effective read bandwidth
7+
(packed B + absmax + A, i.e. the memory the fused kernel actually touches).
8+
"""
9+
10+
import time
11+
12+
import torch
13+
14+
from bitsandbytes.backends.mps import ops as mps_ops
15+
import bitsandbytes.functional as F
16+
17+
DEV = "mps"
18+
ITERS = 50
19+
WARMUP = 10
20+
21+
assert mps_ops._native_available(), "native MPS library required for this benchmark"
22+
assert hasattr(mps_ops._mps_native._lib, "bnb_mps_gemv_4bit"), "fused gemv kernel missing"
23+
24+
25+
def sync():
26+
torch.mps.synchronize()
27+
28+
29+
def timed(fn):
30+
for _ in range(WARMUP):
31+
fn()
32+
sync()
33+
t0 = time.perf_counter()
34+
for _ in range(ITERS):
35+
fn()
36+
sync()
37+
return (time.perf_counter() - t0) / ITERS * 1e3 # ms/iter
38+
39+
40+
def bench(N, K, dtype, quant_type="nf4", blocksize=64):
41+
A = torch.randn(1, 1, K, dtype=dtype, device=DEV)
42+
B = torch.randn(N, K, dtype=dtype, device=DEV)
43+
B_q, absmax = torch.ops.bitsandbytes.quantize_4bit(B, blocksize, quant_type, torch.uint8)
44+
code = F.get_4bit_type(quant_type, device=DEV, blocksize=blocksize)
45+
46+
def fused():
47+
return torch.ops.bitsandbytes.gemv_4bit(A, B_q, B.shape, absmax, code, blocksize)
48+
49+
def baseline():
50+
B_dq = torch.ops.bitsandbytes.dequantize_4bit(B_q, absmax, blocksize, quant_type, list(B.shape), dtype)
51+
return torch.nn.functional.linear(A, B_dq)
52+
53+
# Sanity: fused output matches the baseline it replaces.
54+
ref = baseline()
55+
got = fused()
56+
max_err = (got.float() - ref.float()).abs().max().item()
57+
58+
t_fused = timed(fused)
59+
t_base = timed(baseline)
60+
61+
# Memory the fused kernel reads: packed B (N*K/2 bytes) + absmax (N*K/blocksize fp32)
62+
# + A (K fp32); writes out (N fp32).
63+
bytes_moved = N * K // 2 + (N * K // blocksize) * 4 + K * 4 + N * 4
64+
gbps = bytes_moved / (t_fused * 1e-3) / 1e9
65+
66+
print(
67+
f" N={N:>6} K={K:>6} {str(dtype).replace('torch.', ''):>8} "
68+
f"fused={t_fused:7.3f}ms baseline={t_base:7.3f}ms "
69+
f"speedup={t_base / t_fused:5.1f}x fused-read={gbps:6.1f} GB/s max|err|={max_err:.2e}"
70+
)
71+
72+
73+
if __name__ == "__main__":
74+
print(f"iters={ITERS} warmup={WARMUP} device={DEV}\n")
75+
for dtype in (torch.float16, torch.bfloat16, torch.float32):
76+
print(f"=== gemv_4bit (M=1), {str(dtype).replace('torch.', '')} ===")
77+
for N, K in [(4096, 4096), (11008, 4096), (4096, 11008)]:
78+
bench(N, K, dtype)
79+
print()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Phase M1 baseline: how slow is today's unfused gemv_4bit (dequant -> F.linear) on MPS?
2+
3+
Times, per shape:
4+
- total gemv_4bit (native dequant of B + torch F.linear)
5+
- dequant-only (native _dequantize_4bit_impl of B) -> the isolable cost
6+
- F.linear on an already-materialized B_dq -> the GEMM cost
7+
so we can see where the wall-clock actually goes and whether fusing dequant into
8+
the matmul (Option B) or just moving the GEMM on-device (Option A) is the win.
9+
"""
10+
11+
import time
12+
13+
import torch
14+
15+
import bitsandbytes.functional as F
16+
17+
DEV = "mps"
18+
ITERS = 50
19+
WARMUP = 10
20+
21+
22+
def sync():
23+
torch.mps.synchronize()
24+
25+
26+
def timed(fn):
27+
for _ in range(WARMUP):
28+
fn()
29+
sync()
30+
t0 = time.perf_counter()
31+
for _ in range(ITERS):
32+
fn()
33+
sync()
34+
return (time.perf_counter() - t0) / ITERS * 1e3 # ms/iter
35+
36+
37+
def bench(N, K, dtype, quant_type="nf4", blocksize=64):
38+
A = torch.randn(1, 1, K, dtype=dtype, device=DEV)
39+
B = torch.randn(N, K, dtype=dtype, device=DEV)
40+
B_q, absmax = torch.ops.bitsandbytes.quantize_4bit(B, blocksize, quant_type, torch.uint8)
41+
code = F.get_4bit_type(quant_type, device=DEV, blocksize=blocksize)
42+
43+
def full():
44+
return torch.ops.bitsandbytes.gemv_4bit(A, B_q, B.shape, absmax, code, blocksize)
45+
46+
def dequant_only():
47+
return torch.ops.bitsandbytes.dequantize_4bit(B_q, absmax, blocksize, quant_type, list(B.shape), dtype)
48+
49+
B_dq = dequant_only()
50+
51+
def linear_only():
52+
return torch.nn.functional.linear(A, B_dq)
53+
54+
t_full = timed(full)
55+
t_deq = timed(dequant_only)
56+
t_lin = timed(linear_only)
57+
print(
58+
f" N={N:>6} K={K:>6} {str(dtype).replace('torch.', ''):>8} "
59+
f"total={t_full:7.3f}ms dequant={t_deq:7.3f}ms linear={t_lin:7.3f}ms "
60+
f"(dequant is {100 * t_deq / t_full:4.1f}% of total)"
61+
)
62+
63+
64+
if __name__ == "__main__":
65+
print(f"iters={ITERS} warmup={WARMUP} device={DEV}\n")
66+
for dtype in (torch.float16, torch.bfloat16):
67+
print(f"=== gemv_4bit (M=1), {str(dtype).replace('torch.', '')} ===")
68+
for N, K in [(4096, 4096), (11008, 4096), (4096, 11008)]:
69+
bench(N, K, dtype)
70+
print()

0 commit comments

Comments
 (0)