Skip to content

Commit d913182

Browse files
eaglstunclaude
andcommitted
Phase M2: fused native gemv_4bit Metal kernel (dequant + dot product, no B_dq)
One SIMD-group (32 threads) per output element n: threads stride the packed row in uint4 units (16 B = 32 nibbles), dequantize nf4/fp4 in registers (code[nib] * absmax, rounded to the activation dtype to match the oracle's B_dq.to(dtype)), and accumulate the dot product in fp32 with split accumulators + explicit fma, reduced via simd_sum. Per-dtype kernel variants (fp32/fp16/bf16) bind A and out in torch's own dtype, so the steady-state call launches zero torch cast kernels. The dequantized B is never written to device memory. - csrc/mps_kernels.metal: gemv_4bit_body<T> + fp32/fp16/bf16 kernels - csrc/mps_ops.mm: bnb_mps_gemv_4bit dispatch (own queue, blocking wait, per-dtype pipeline; BNB_MPS_PROFILE=1 logs kernel-only GPU time) - cextension.py: argtypes (guarded by hasattr so stale dylibs keep working) - backends/mps/ops.py: route _gemv_4bit_impl to native when available; guards (M==1, K % 32 == 0, pow2 blocksize, 16-entry code, packed-size check) fall back to the unchanged dequant + F.linear path - tests: fused-native spy test (asserts native is actually hit), unaligned-K fallback test, gemv added to the graceful-fallback matrix Parity: tests/test_mps_parity.py -k gemv all green under BNB_MPS_REQUIRE_NATIVE=1 (nf4+fp4, bs 64/256, fp32/fp16/bf16). Speedup vs dequant+F.linear baseline (nf4/bs64, M1 shapes): 3.4-6.2x wall-clock (fp16 4096x4096: 0.31ms vs 1.64ms). Kernel-only ~0.11ms on the ~25 MB shapes when clocked up (~230 GB/s read vs the standalone dequant kernel's ~54 GB/s); per-call cross-queue sync now dominates wall-clock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4598cd2 commit d913182

9 files changed

Lines changed: 540 additions & 2 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Phase M1 baseline, gemm side: as M grows, does the GEMM cost overtake dequant?
2+
3+
If dequant stays ~fixed (it's B-only, independent of M) while linear grows with M,
4+
there's a crossover M above which Option A (fast on-device GEMM) starts to matter.
5+
Below it, gemm is just as dequant-bound as gemv and Option B wins there too.
6+
"""
7+
8+
import time
9+
10+
import torch
11+
12+
import bitsandbytes.functional as F
13+
14+
DEV = "mps"
15+
ITERS = 30
16+
WARMUP = 8
17+
18+
19+
def sync():
20+
torch.mps.synchronize()
21+
22+
23+
def timed(fn):
24+
for _ in range(WARMUP):
25+
fn()
26+
sync()
27+
t0 = time.perf_counter()
28+
for _ in range(ITERS):
29+
fn()
30+
sync()
31+
return (time.perf_counter() - t0) / ITERS * 1e3
32+
33+
34+
def bench(M, N, K, dtype, quant_type="nf4", blocksize=64):
35+
A = torch.randn(1, M, K, dtype=dtype, device=DEV)
36+
B = torch.randn(N, K, dtype=dtype, device=DEV)
37+
B_q, qs = F.quantize_4bit(B, blocksize=blocksize, quant_type=quant_type)
38+
39+
def full():
40+
return torch.ops.bitsandbytes.gemm_4bit(A, B_q, list(B.shape), qs.absmax, blocksize, quant_type)
41+
42+
def dequant_only():
43+
return torch.ops.bitsandbytes.dequantize_4bit(B_q, qs.absmax, blocksize, quant_type, list(B.shape), dtype)
44+
45+
B_dq = dequant_only()
46+
47+
def linear_only():
48+
return torch.nn.functional.linear(A, B_dq)
49+
50+
t_full, t_deq, t_lin = timed(full), timed(dequant_only), timed(linear_only)
51+
print(
52+
f" M={M:>4} N={N:>5} K={K:>5} {str(dtype).replace('torch.',''):>8} "
53+
f"total={t_full:7.3f}ms dequant={t_deq:7.3f}ms linear={t_lin:7.3f}ms "
54+
f"(linear is {100*t_lin/t_full:4.1f}% of total)"
55+
)
56+
57+
58+
if __name__ == "__main__":
59+
print(f"iters={ITERS} warmup={WARMUP} device={DEV}\n")
60+
for dtype in (torch.float16,):
61+
print(f"=== gemm_4bit, N=K=4096, {str(dtype).replace('torch.','')} ===")
62+
for M in (8, 64, 512, 2048):
63+
bench(M, 4096, 4096, dtype)
64+
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()

bitsandbytes/backends/mps/ops.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,52 @@ def _(
365365
out.copy_(result)
366366

367367

368+
def _gemv_4bit_native(
369+
A: torch.Tensor,
370+
B: torch.Tensor,
371+
shapeB: Sequence[int],
372+
absmax: torch.Tensor,
373+
code: torch.Tensor,
374+
blocksize: int,
375+
) -> torch.Tensor:
376+
"""Route gemv_4bit (M == 1) through the fused hand-written Metal kernel.
377+
378+
The kernel reads packed 4-bit B + per-block absmax + the 16-entry code table and
379+
computes out[n] = sum_k A[k] * dequant(B[n, k]) directly -- the dequantized B is never
380+
materialized. Dequantized weights are rounded to A's dtype in-kernel (reproducing the
381+
reference's B_dq.to(dtype)); accumulation is fp32, so only accumulation order differs
382+
from the oracle. A and out bind in A's own dtype (per-dtype kernel variants), so the
383+
steady-state call launches no torch cast kernels. Preconditions (checked by the
384+
caller): K % 32 == 0 and power-of-two blocksize.
385+
"""
386+
N, K = int(shapeB[0]), int(shapeB[-1])
387+
388+
B_flat = B if B.dtype == torch.uint8 else B.view(torch.uint8)
389+
B_flat = _ensure_native_buffer(B_flat.reshape(-1))
390+
A_flat = _ensure_native_buffer(A.reshape(-1))
391+
code_f = _ensure_native_buffer(code.to(torch.float32))
392+
absmax_f = _ensure_native_buffer(absmax.to(torch.float32))
393+
394+
out = torch.empty(N, dtype=A.dtype, device=A.device)
395+
dtype_flag = {torch.float32: 0, torch.float16: 1, torch.bfloat16: 2}[A.dtype]
396+
bs_shift = blocksize.bit_length() - 1
397+
398+
torch.mps.synchronize()
399+
_mps_native.bnb_mps_gemv_4bit(
400+
code_f.data_ptr(),
401+
B_flat.data_ptr(),
402+
absmax_f.data_ptr(),
403+
A_flat.data_ptr(),
404+
out.data_ptr(),
405+
K,
406+
N,
407+
bs_shift,
408+
dtype_flag,
409+
)
410+
411+
return out.reshape(*A.shape[:-1], N)
412+
413+
368414
def _gemv_4bit_impl(
369415
A: torch.Tensor,
370416
B: torch.Tensor,
@@ -373,6 +419,24 @@ def _gemv_4bit_impl(
373419
code: torch.Tensor,
374420
blocksize: int,
375421
) -> torch.Tensor:
422+
# Fused native Metal kernel when available. Guards: true gemv (M == 1), 2-D shapeB
423+
# matching A's K, K % 32 == 0 (uint4 row loads need 16-byte-aligned rows), power-of-two
424+
# blocksize (the kernel indexes absmax with a shift), and a plain 16-entry code table
425+
# whose packed B has the expected size.
426+
if (
427+
_native_available()
428+
and hasattr(_mps_native._lib, "bnb_mps_gemv_4bit") # stale dylibs predate the fused kernel
429+
and A.numel() == A.shape[-1]
430+
and len(shapeB) == 2
431+
and shapeB[-1] == A.shape[-1]
432+
and shapeB[-1] % 32 == 0
433+
and blocksize >= 32
434+
and (blocksize & (blocksize - 1)) == 0
435+
and code.numel() == 16
436+
and B.numel() * B.element_size() == (shapeB[0] * shapeB[1]) // 2
437+
):
438+
return _gemv_4bit_native(A, B, shapeB, absmax, code, blocksize)
439+
376440
if blocksize in (64, 128, 256) and (k := _get_kernel()) is not None:
377441
if B.dtype != torch.uint8:
378442
B = B.view(torch.uint8)

bitsandbytes/cextension.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,22 @@ def __init__(self, lib: ct.CDLL, metallib_path: Path):
181181
ct.c_int64, # blocksize
182182
]
183183

184+
# Older native builds predate the fused matmul kernels; guard so a stale dylib
185+
# keeps the quant/dequant native path without breaking library load.
186+
if hasattr(lib, "bnb_mps_gemv_4bit"):
187+
lib.bnb_mps_gemv_4bit.restype = None
188+
lib.bnb_mps_gemv_4bit.argtypes = [
189+
ct.c_void_p, # code (float32[16])
190+
ct.c_void_p, # B (uint8 packed, N*K/2 bytes)
191+
ct.c_void_p, # absmax (float32[blocks])
192+
ct.c_void_p, # A (activation dtype [K])
193+
ct.c_void_p, # out (activation dtype [N])
194+
ct.c_int64, # K
195+
ct.c_int64, # N
196+
ct.c_int64, # bs_shift = log2(blocksize)
197+
ct.c_int64, # dtype_flag (0=fp32, 1=fp16, 2=bf16)
198+
]
199+
184200
def verify_buffer_contract(self) -> None:
185201
"""Verify the undocumented torch contract that an MPS tensor's data_ptr() is its
186202
id<MTLBuffer>. Raises RuntimeError if a future torch has broken it, so callers can

0 commit comments

Comments
 (0)