Skip to content

Commit bdd3d9e

Browse files
eaglstunclaude
andcommitted
Phase M3: native gemm_4bit -- chunked dequant + MPSMatrixMultiplication, one command buffer
Option A from the M1 decision: bnb_mps_gemm_4bit (csrc/mps_ops.mm) encodes, on ONE command buffer with ONE commit + ONE blocking wait, (1) a chunked 4-bit dequant kernel (dequantize_4bit_chunked_fp32/fp16: one thread per 32-element uint4 chunk, writing (T)(code[nib]*absmax) into a growable private scratch MTLBuffer -- the same rounding as the oracle's B_dq.to(dtype)), (2) a shape-cached MPSMatrixMultiplication computing A[M,K] . B_dq[N,K]^T (row-major, transposeRight), and (3) an optional bias epilogue kernel out[m,n] += bias[n]. The single sync is the structural win: the previous dequant + F.linear tail paid the ~0.15-0.25ms cross-queue sync twice per call. bf16 is excluded by the Python router: MPSMatrixMultiplication hard-asserts on anything but fp32/fp16/int8/int16 (probed on macOS 26.4.1), so bf16 keeps the existing dequant + F.linear fallback verbatim. Other guards mirror the fused gemv (K % 32 == 0, power-of-two blocksize >= 32, packed-size check, hasattr guard for stale dylibs); nested absmax is still unpacked to plain fp32 absmax before routing, unchanged. Parity: tests/test_mps_parity.py -k "gemm_4bit or gemv" = 69 passed under BNB_MPS_REQUIRE_NATIVE=1. Native path asserted via spy (fp32/fp16 x nf4/fp4 x +/-bias x +/-nested absmax); bf16 and K%32!=0 fallbacks asserted; graceful fallback with the native handle off covered. fp32 vs MPSMatMul accumulation stays within the documented 1e-5 atol at the calibrated K <= 256 (the one trip found was in the PURE-TORCH fallback composition at K=256/M=4; the fallback test pins K=64 like the main gemm test -- the documented tolerance is unchanged). Speedup vs the dequant+F.linear fallback (nf4/bs64, N=K=4096, 30 iters, benchmarks_wip/bench_gemm_baseline.py): fp16 2.5x (M=8), 1.5x (M=64/512), 1.08x (M=2048); fp32 1.6x/1.5x (M=8/64), 1.1x (M=512), ~1.0x (M=2048). Win = one sync + a much faster chunked dequant at small/medium M; flat at M=2048 where the GEMM dominates and MPSMatMul ~= F.linear. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d913182 commit bdd3d9e

7 files changed

Lines changed: 517 additions & 14 deletions

File tree

benchmarks_wip/bench_gemm_baseline.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
"""Phase M1 baseline, gemm side: as M grows, does the GEMM cost overtake dequant?
1+
"""Phase M1 baseline + Phase M3 comparison for gemm_4bit.
22
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.
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.
610
"""
711

812
import time
913

1014
import torch
1115

16+
import bitsandbytes.backends.mps.ops as mps_ops
1217
import bitsandbytes.functional as F
1318

1419
DEV = "mps"
@@ -36,29 +41,36 @@ def bench(M, N, K, dtype, quant_type="nf4", blocksize=64):
3641
B = torch.randn(N, K, dtype=dtype, device=DEV)
3742
B_q, qs = F.quantize_4bit(B, blocksize=blocksize, quant_type=quant_type)
3843

39-
def full():
44+
def native():
45+
# Routes through bnb_mps_gemm_4bit when the native library is built (fp32/fp16).
4046
return torch.ops.bitsandbytes.gemm_4bit(A, B_q, list(B.shape), qs.absmax, blocksize, quant_type)
4147

4248
def dequant_only():
4349
return torch.ops.bitsandbytes.dequantize_4bit(B_q, qs.absmax, blocksize, quant_type, list(B.shape), dtype)
4450

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+
4556
B_dq = dequant_only()
4657

4758
def linear_only():
4859
return torch.nn.functional.linear(A, B_dq)
4960

50-
t_full, t_deq, t_lin = timed(full), timed(dequant_only), timed(linear_only)
61+
t_nat, t_fb, t_deq, t_lin = timed(native), timed(fallback), timed(dequant_only), timed(linear_only)
5162
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)"
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}]"
5566
)
5667

5768

5869
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.','')} ===")
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.', '')} ===")
6274
for M in (8, 64, 512, 2048):
6375
bench(M, 4096, 4096, dtype)
6476
print()

bitsandbytes/backends/mps/ops.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,65 @@ def _(
477477
out.copy_(result)
478478

479479

480+
def _gemm_4bit_native(
481+
A: torch.Tensor,
482+
B: torch.Tensor,
483+
shapeB: Sequence[int],
484+
absmax: torch.Tensor,
485+
blocksize: int,
486+
quant_type: str,
487+
bias: Optional[torch.Tensor],
488+
) -> torch.Tensor:
489+
"""Route gemm_4bit (general M) through the native Metal entry point.
490+
491+
One command buffer, one commit, one blocking wait: a chunked Metal kernel dequantizes
492+
packed B into a private scratch MTLBuffer in A's dtype (reproducing the reference's
493+
B_dq.to(dtype) rounding), MPSMatrixMultiplication computes A[M,K] . B_dq[N,K]^T, and an
494+
optional bias epilogue adds bias[N] -- all on the same command buffer. Compared with the
495+
dequant + F.linear fallback this removes the torch round-trip and its second sync (the
496+
per-call cross-queue sync is what dominates wall-clock at small/medium M -- see the
497+
Phase M2 finding in MPS_STATUS.md).
498+
499+
`absmax` must already be the plain per-block fp32 scale: nested/compressed absmax is
500+
unpacked by the caller BEFORE this function. Preconditions (checked by the caller):
501+
A.dtype is fp32/fp16 (MPSMatrixMultiplication asserts on bf16), K % 32 == 0, and
502+
power-of-two blocksize >= 32.
503+
"""
504+
N, K = int(shapeB[0]), int(shapeB[-1])
505+
M = A.numel() // K
506+
507+
B_flat = B if B.dtype == torch.uint8 else B.view(torch.uint8)
508+
B_flat = _ensure_native_buffer(B_flat.reshape(-1))
509+
A_flat = _ensure_native_buffer(A.reshape(-1))
510+
code_f = _ensure_native_buffer(_get_4bit_code(quant_type, A.device).to(torch.float32))
511+
absmax_f = _ensure_native_buffer(absmax.to(torch.float32))
512+
bias_ptr = None
513+
if bias is not None:
514+
bias_f = _ensure_native_buffer(bias.reshape(-1))
515+
bias_ptr = bias_f.data_ptr()
516+
517+
out = torch.empty(M * N, dtype=A.dtype, device=A.device)
518+
dtype_flag = {torch.float32: 0, torch.float16: 1}[A.dtype]
519+
bs_shift = blocksize.bit_length() - 1
520+
521+
torch.mps.synchronize()
522+
_mps_native.bnb_mps_gemm_4bit(
523+
code_f.data_ptr(),
524+
B_flat.data_ptr(),
525+
absmax_f.data_ptr(),
526+
A_flat.data_ptr(),
527+
bias_ptr,
528+
out.data_ptr(),
529+
M,
530+
K,
531+
N,
532+
bs_shift,
533+
dtype_flag,
534+
)
535+
536+
return out.reshape(*A.shape[:-1], N)
537+
538+
480539
@register_kernel("bitsandbytes::gemm_4bit", "mps")
481540
def _(
482541
A: torch.Tensor,
@@ -502,6 +561,26 @@ def _(
502561
+ absmax_offset
503562
)
504563

564+
# Native Metal path (dequant -> scratch -> MPSMatrixMultiplication -> bias, one command
565+
# buffer / one sync). Guards: fp32/fp16 only (MPSMatrixMultiplication hard-asserts on
566+
# bf16 -- verified on macOS 26.4.1 -- so bf16 keeps the dequant + F.linear fallback),
567+
# 2-D shapeB matching A's K, K % 32 == 0 (uint4 loads in the chunked dequant kernel),
568+
# power-of-two blocksize (absmax indexed with a shift), a bias matching out's dtype and
569+
# width, and packed B of the expected size.
570+
if (
571+
_native_available()
572+
and hasattr(_mps_native._lib, "bnb_mps_gemm_4bit") # stale dylibs predate the native gemm
573+
and A.dtype in (torch.float32, torch.float16)
574+
and len(shapeB) == 2
575+
and shapeB[-1] == K
576+
and K % 32 == 0
577+
and blocksize >= 32
578+
and (blocksize & (blocksize - 1)) == 0
579+
and (bias is None or (bias.dtype == A.dtype and bias.numel() == N))
580+
and B.numel() * B.element_size() == (N * K) // 2
581+
):
582+
return _gemm_4bit_native(A, B, shapeB, absmax, blocksize, quant_type, bias)
583+
505584
# Use HF Hub kernel when supported for GEMV.
506585
if M == 1 and blocksize in (64, 128, 256) and (k := _get_kernel()) is not None:
507586
if B.dtype != torch.uint8:

bitsandbytes/cextension.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,22 @@ def __init__(self, lib: ct.CDLL, metallib_path: Path):
197197
ct.c_int64, # dtype_flag (0=fp32, 1=fp16, 2=bf16)
198198
]
199199

200+
if hasattr(lib, "bnb_mps_gemm_4bit"):
201+
lib.bnb_mps_gemm_4bit.restype = None
202+
lib.bnb_mps_gemm_4bit.argtypes = [
203+
ct.c_void_p, # code (float32[16])
204+
ct.c_void_p, # B (uint8 packed, N*K/2 bytes)
205+
ct.c_void_p, # absmax (float32[blocks])
206+
ct.c_void_p, # A (activation dtype [M*K])
207+
ct.c_void_p, # bias (activation dtype [N]; None when absent)
208+
ct.c_void_p, # out (activation dtype [M*N])
209+
ct.c_int64, # M
210+
ct.c_int64, # K
211+
ct.c_int64, # N
212+
ct.c_int64, # bs_shift = log2(blocksize)
213+
ct.c_int64, # dtype_flag (0=fp32, 1=fp16; MPSMatrixMultiplication has no bf16)
214+
]
215+
200216
def verify_buffer_contract(self) -> None:
201217
"""Verify the undocumented torch contract that an MPS tensor's data_ptr() is its
202218
id<MTLBuffer>. Raises RuntimeError if a future torch has broken it, so callers can

csrc/mps_kernels.metal

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,72 @@ BNB_GEMV_4BIT_KERNEL(gemv_4bit_fp32, float)
234234
BNB_GEMV_4BIT_KERNEL(gemv_4bit_fp16, half)
235235
BNB_GEMV_4BIT_KERNEL(gemv_4bit_bf16, bfloat)
236236

237+
// ---- Phase M3: chunked 4-bit dequant into the ACTIVATION dtype (gemm_4bit scratch) ----
238+
// Fills the scratch B_dq consumed by MPSMatrixMultiplication in bnb_mps_gemm_4bit. One
239+
// thread per 32-element chunk (16 packed bytes, one uint4 load), writing
240+
// (T)(code[nib] * absmax) -- the same rounding as the reference's B_dq.to(dtype), so the
241+
// GEMM multiplies exactly the weights the oracle multiplies. Preconditions match the fused
242+
// gemv kernel (enforced by the Python router): K % 32 == 0 so rows are 16-byte aligned and
243+
// total elements are a multiple of 32; blocksize is a power of two >= 32 (bs_shift =
244+
// log2(blocksize)), so a chunk never straddles an absmax block.
245+
template <typename T>
246+
static inline void dequantize_4bit_chunked_body(
247+
device const float* code,
248+
device const uchar* B,
249+
device const float* absmax,
250+
device T* out,
251+
uint bs_shift,
252+
uint chunk) {
253+
const ulong base = (ulong)chunk << 5; // first element index of this chunk
254+
device const uint4* p = (device const uint4*)(B + (base >> 1));
255+
const uint4 packed = *p;
256+
const float am = absmax[base >> bs_shift];
257+
258+
#pragma unroll
259+
for (uint w = 0; w < 4; ++w) {
260+
const uint word = packed[w];
261+
// Little-endian: byte b of `word` is packed byte (base/2 + w*4 + b), holding
262+
// elements base + w*8 + 2b (high nibble) and base + w*8 + 2b + 1 (low nibble).
263+
#pragma unroll
264+
for (uint b = 0; b < 4; ++b) {
265+
const uint byte = (word >> (b << 3)) & 0xFFu;
266+
const ulong j = base + (ulong)((w << 3) | (b << 1));
267+
out[j] = (T)(code[byte >> 4] * am);
268+
out[j + 1] = (T)(code[byte & 0x0Fu] * am);
269+
}
270+
}
271+
}
272+
273+
#define BNB_DEQUANT_4BIT_CHUNKED_KERNEL(NAME, T) \
274+
kernel void NAME( \
275+
device const float* code [[buffer(0)]], /* 16-entry 4-bit code (NF4 or FP4) */ \
276+
device const uchar* B [[buffer(1)]], /* packed nibbles, row-major [N, K], N*K/2 bytes */ \
277+
device const float* absmax [[buffer(2)]], /* per-block scales over the flattened [N*K] index */ \
278+
device T* out [[buffer(3)]], /* N*K elements of T (the GEMM scratch) */ \
279+
constant uint& bs_shift [[buffer(4)]], /* log2(blocksize) */ \
280+
uint chunk [[thread_position_in_grid]]) { \
281+
dequantize_4bit_chunked_body<T>(code, B, absmax, out, bs_shift, chunk); \
282+
}
283+
284+
BNB_DEQUANT_4BIT_CHUNKED_KERNEL(dequantize_4bit_chunked_fp32, float)
285+
BNB_DEQUANT_4BIT_CHUNKED_KERNEL(dequantize_4bit_chunked_fp16, half)
286+
287+
// ---- Phase M3: bias epilogue for gemm_4bit ----
288+
// out[m, n] += bias[n], broadcast over rows, in the activation dtype (reproducing
289+
// F.linear's bias add on the T-typed matmul result). 2-D grid: x = n (column), y = m (row).
290+
#define BNB_GEMM_BIAS_ADD_KERNEL(NAME, T) \
291+
kernel void NAME( \
292+
device T* out [[buffer(0)]], /* [M, N] row-major */ \
293+
device const T* bias [[buffer(1)]], /* [N] */ \
294+
constant uint& N [[buffer(2)]], \
295+
uint2 gid [[thread_position_in_grid]]) { \
296+
const ulong idx = (ulong)gid.y * (ulong)N + (ulong)gid.x; \
297+
out[idx] = (T)(out[idx] + bias[gid.x]); \
298+
}
299+
300+
BNB_GEMM_BIAS_ADD_KERNEL(gemm_bias_add_fp32, float)
301+
BNB_GEMM_BIAS_ADD_KERNEL(gemm_bias_add_fp16, half)
302+
237303
// ---- 4-bit blockwise quantize (NF4/FP4): A (float32) -> packed out + absmax ----
238304
// `bounds` are the 15 midpoints of the SORTED 16-entry code; `order` maps the searchsorted
239305
// index back to the stored 4-bit index (identity for NF4, the argsort remap for FP4).

0 commit comments

Comments
 (0)