Marc Sun commited on
Commit
0becb0a
·
1 Parent(s): fd5d3aa

Metal: cover every quant type upstream's metallib has a gemv for

Browse files

The type table was four entries; the metallib always held kernels for 22. The gap was host-side:
CUDA reaches upstream's `mul_mat_vec_q_switch_type`, one function that switches over every type,
while ggml's Metal type->pipeline mapping lives in ggml-metal-device.cpp, which pulls in the whole
backend and is not compiled here. So each type has to be registered by hand.

Transcribed from ggml_metal_library_get_pipeline_mul_mv: the legacy quants, the K quants, the IQ
quants and MXFP4 -- 20 types, parity with the CUDA port apart from NVFP4/Q1_0/Q2_0, which the Metal
metallib has no kernels for. The one field not available as a macro is the grid: ggml-metal-ops.cpp
dispatches the dense types and q8_0 on (ne01 + nr0 - 1)/nr0 and everything else divides by nsg too,
so among quantized types q8_0 stays the only `mv_reduce_across_sgs`.

`GEMV_TYPES` becomes a `gemv_types()` op each backend answers for itself. It was one hardcoded CUDA
list, which Metal inherited and over-claimed by 19 types -- a type routed into a gemv with no kernel
for it faults rather than falling back.

tests: run on MPS as well as CUDA and cover every supported type. Dequantize is compared against
the block scale rather than each element -- 17 of the 20 come back bit-exact, Q4_0/Q4_1/Q4_K land
within an ulp of f32, and asking a near-zero element to match to its own magnitude asks for an
exactness a different summation order breaks. MXFP4's scale is one E8M0 byte, not an fp16 field, so
the harness's mask left the reference itself overflowing to inf.

test_vendor_drift: the table transcribes constants out of vendor/, and `vendor.py --rev` replaces
those files. A renamed N_SG_*/N_R0_* fails the build, but a changed smem or grid rule compiles and
silently returns garbage. This parses upstream and fails loudly instead.

gguf_cuda/gguf_cuda.cu CHANGED
@@ -35,6 +35,13 @@ int dtype_code(at::ScalarType t) {
35
 
36
  } // namespace
37
 
 
 
 
 
 
 
 
38
  at::Tensor dequantize(const at::Tensor &blocks, int64_t ggml_type, int64_t rows, int64_t cols,
39
  at::ScalarType dtype) {
40
  TORCH_CHECK(blocks.is_cuda() && blocks.scalar_type() == at::kByte, "blocks must be cuda uint8");
 
35
 
36
  } // namespace
37
 
38
+ // The cases upstream's `mul_mat_vec_q_switch_type` dispatches (mmvq.cu): Q4_0/Q4_1/Q5_0/Q5_1/Q8_0,
39
+ // the K quants, the IQ quants, MXFP4/NVFP4 and Q1_0/Q2_0. Kept as a literal because that switch is
40
+ // static and exposes no way to enumerate it; a type added upstream shows up here when the pin moves.
41
+ std::vector<int64_t> gemv_types() {
42
+ return {2, 3, 6, 7, 8, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 29, 39, 40, 41, 42};
43
+ }
44
+
45
  at::Tensor dequantize(const at::Tensor &blocks, int64_t ggml_type, int64_t rows, int64_t cols,
46
  at::ScalarType dtype) {
47
  TORCH_CHECK(blocks.is_cuda() && blocks.scalar_type() == at::kByte, "blocks must be cuda uint8");
gguf_metal/common.h CHANGED
@@ -28,4 +28,8 @@ int gguf_metal_get_rows(void *blocks, size_t blocks_off, void *indices, size_t i
28
 
29
  // True when the type has a gemv/gemm kernel in the metallib.
30
  int gguf_metal_supports(int ggml_type);
 
 
 
 
31
  }
 
28
 
29
  // True when the type has a gemv/gemm kernel in the metallib.
30
  int gguf_metal_supports(int ggml_type);
31
+
32
+ // The ggml type ids above, written into `out` (up to `max`); returns how many there are. Called
33
+ // once at import to publish `GEMV_TYPES`, so the caller never has to guess this backend's coverage.
34
+ int gguf_metal_gemv_types(int *out, int max);
35
  }
gguf_metal/ggml_dispatch.mm CHANGED
@@ -30,7 +30,10 @@ namespace {
30
 
31
  // ggml type ids, matching ggml.h
32
  enum { GGML_Q4_0 = 2, GGML_Q4_1 = 3, GGML_Q5_0 = 6, GGML_Q5_1 = 7, GGML_Q8_0 = 8,
33
- GGML_Q2_K = 10, GGML_Q3_K = 11, GGML_Q4_K = 12, GGML_Q5_K = 13, GGML_Q6_K = 14 };
 
 
 
34
 
35
  struct TypeInfo {
36
  const char *name; // the infix in kernel_mul_mv_<name>_f32
@@ -38,21 +41,47 @@ struct TypeInfo {
38
  int block_bytes;
39
  int mv_nsg; // N_SG_* from ggml-metal-impl.h
40
  int mv_nr0; // N_R0_*
41
- // Two gemv conventions upstream. The K-quants give each simdgroup its own rows
42
- // (first_row = (tgpig.x*NSG + sgitg)*nr0), so the grid is divided by nsg and nothing is shared.
43
- // q8_0 instead splits the K reduction across simdgroups (r0 = tgpig.x*NR0) and combines them
44
- // through threadgroup memory in helper_mv_reduce_and_write -- so its grid must NOT be divided by
45
- // nsg, and it needs smem. Getting either wrong silently returns garbage.
 
 
46
  bool mv_reduce_across_sgs;
 
 
47
  size_t mv_smem;
48
  };
49
 
 
 
 
50
  const std::unordered_map<int, TypeInfo> &type_table() {
51
  static const std::unordered_map<int, TypeInfo> table = {
 
 
 
 
 
52
  {GGML_Q8_0, {"q8_0", 32, 34, N_SG_Q8_0, N_R0_Q8_0, true, 32 * sizeof(float) * N_R0_Q8_0}},
 
 
 
53
  {GGML_Q4_K, {"q4_K", 256, 144, N_SG_Q4_K, N_R0_Q4_K, false, 0}},
54
  {GGML_Q5_K, {"q5_K", 256, 176, N_SG_Q5_K, N_R0_Q5_K, false, 0}},
55
  {GGML_Q6_K, {"q6_K", 256, 210, N_SG_Q6_K, N_R0_Q6_K, false, 0}},
 
 
 
 
 
 
 
 
 
 
 
56
  };
57
  return table;
58
  }
@@ -134,6 +163,17 @@ void set_bool(MTLFunctionConstantValues *cv, bool value, NSUInteger index) {
134
 
135
  extern "C" int gguf_metal_supports(int ggml_type) { return lookup(ggml_type) != nullptr; }
136
 
 
 
 
 
 
 
 
 
 
 
 
137
  extern "C" int gguf_metal_mul_mat(void *blocks, size_t blocks_off, void *x, size_t x_off, void *out,
138
  size_t out_off, int ggml_type, int64_t K, int64_t N, int64_t M) {
139
  const TypeInfo *info = lookup(ggml_type);
 
30
 
31
  // ggml type ids, matching ggml.h
32
  enum { GGML_Q4_0 = 2, GGML_Q4_1 = 3, GGML_Q5_0 = 6, GGML_Q5_1 = 7, GGML_Q8_0 = 8,
33
+ GGML_Q2_K = 10, GGML_Q3_K = 11, GGML_Q4_K = 12, GGML_Q5_K = 13, GGML_Q6_K = 14,
34
+ GGML_IQ2_XXS = 16, GGML_IQ2_XS = 17, GGML_IQ3_XXS = 18, GGML_IQ1_S = 19,
35
+ GGML_IQ4_NL = 20, GGML_IQ3_S = 21, GGML_IQ2_S = 22, GGML_IQ4_XS = 23,
36
+ GGML_IQ1_M = 29, GGML_MXFP4 = 39 };
37
 
38
  struct TypeInfo {
39
  const char *name; // the infix in kernel_mul_mv_<name>_f32
 
41
  int block_bytes;
42
  int mv_nsg; // N_SG_* from ggml-metal-impl.h
43
  int mv_nr0; // N_R0_*
44
+ // Two gemv grids upstream, chosen by type in ggml-metal-ops.cpp's mul_mv dispatch: the dense
45
+ // types and q8_0 take (ne01 + nr0 - 1)/nr0, everything else divides by nsg as well. q8_0 splits
46
+ // the K reduction across simdgroups (r0 = tgpig.x*NR0) and combines them through threadgroup
47
+ // memory in helper_mv_reduce_and_write, so dividing its grid by nsg would drop rows; the others
48
+ // give each simdgroup its own rows (first_row = (tgpig.x*NSG + sgitg)*nr0) and share nothing.
49
+ // Among quantized types q8_0 is the only one on the first grid. Getting it wrong returns garbage
50
+ // rather than failing.
51
  bool mv_reduce_across_sgs;
52
+ // ggml_metal_library_get_pipeline_mul_mv's `smem`, per type. Mostly 0; the IQ types cache their
53
+ // lookup grid in threadgroup memory and q8_0 needs a reduction buffer.
54
  size_t mv_smem;
55
  };
56
 
57
+ // Transcribed from ggml_metal_library_get_pipeline_mul_mv (ggml-metal-device.cpp): every type it
58
+ // implements, minus the dense ones (a GGUF weight this reaches is always quantized) and nvfp4,
59
+ // which upstream's Metal backend has no kernel for. Block sizes are ggml's GGML_QUANT_SIZES.
60
  const std::unordered_map<int, TypeInfo> &type_table() {
61
  static const std::unordered_map<int, TypeInfo> table = {
62
+ // legacy quants
63
+ {GGML_Q4_0, {"q4_0", 32, 18, N_SG_Q4_0, N_R0_Q4_0, false, 0}},
64
+ {GGML_Q4_1, {"q4_1", 32, 20, N_SG_Q4_1, N_R0_Q4_1, false, 0}},
65
+ {GGML_Q5_0, {"q5_0", 32, 22, N_SG_Q5_0, N_R0_Q5_0, false, 0}},
66
+ {GGML_Q5_1, {"q5_1", 32, 24, N_SG_Q5_1, N_R0_Q5_1, false, 0}},
67
  {GGML_Q8_0, {"q8_0", 32, 34, N_SG_Q8_0, N_R0_Q8_0, true, 32 * sizeof(float) * N_R0_Q8_0}},
68
+ // K quants
69
+ {GGML_Q2_K, {"q2_K", 256, 84, N_SG_Q2_K, N_R0_Q2_K, false, 0}},
70
+ {GGML_Q3_K, {"q3_K", 256, 110, N_SG_Q3_K, N_R0_Q3_K, false, 0}},
71
  {GGML_Q4_K, {"q4_K", 256, 144, N_SG_Q4_K, N_R0_Q4_K, false, 0}},
72
  {GGML_Q5_K, {"q5_K", 256, 176, N_SG_Q5_K, N_R0_Q5_K, false, 0}},
73
  {GGML_Q6_K, {"q6_K", 256, 210, N_SG_Q6_K, N_R0_Q6_K, false, 0}},
74
+ // IQ quants
75
+ {GGML_IQ2_XXS, {"iq2_xxs", 256, 66, N_SG_IQ2_XXS, N_R0_IQ2_XXS, false, 256 * 8 + 128}},
76
+ {GGML_IQ2_XS, {"iq2_xs", 256, 74, N_SG_IQ2_XS, N_R0_IQ2_XS, false, 512 * 8 + 128}},
77
+ {GGML_IQ3_XXS, {"iq3_xxs", 256, 98, N_SG_IQ3_XXS, N_R0_IQ3_XXS, false, 256 * 4 + 128}},
78
+ {GGML_IQ1_S, {"iq1_s", 256, 50, N_SG_IQ1_S, N_R0_IQ1_S, false, 0}},
79
+ {GGML_IQ4_NL, {"iq4_nl", 32, 18, N_SG_IQ4_NL, N_R0_IQ4_NL, false, 32 * sizeof(float)}},
80
+ {GGML_IQ3_S, {"iq3_s", 256, 110, N_SG_IQ3_S, N_R0_IQ3_S, false, 512 * 4}},
81
+ {GGML_IQ2_S, {"iq2_s", 256, 82, N_SG_IQ2_S, N_R0_IQ2_S, false, 0}},
82
+ {GGML_IQ4_XS, {"iq4_xs", 256, 136, N_SG_IQ4_XS, N_R0_IQ4_XS, false, 32 * sizeof(float)}},
83
+ {GGML_IQ1_M, {"iq1_m", 256, 56, N_SG_IQ1_M, N_R0_IQ1_M, false, 0}},
84
+ {GGML_MXFP4, {"mxfp4", 32, 17, N_SG_MXFP4, N_R0_MXFP4, false, 32 * sizeof(float)}},
85
  };
86
  return table;
87
  }
 
163
 
164
  extern "C" int gguf_metal_supports(int ggml_type) { return lookup(ggml_type) != nullptr; }
165
 
166
+ extern "C" int gguf_metal_gemv_types(int *out, int max) {
167
+ int n = 0;
168
+ for (const auto &entry : type_table()) {
169
+ if (n < max) {
170
+ out[n] = entry.first;
171
+ }
172
+ ++n;
173
+ }
174
+ return n;
175
+ }
176
+
177
  extern "C" int gguf_metal_mul_mat(void *blocks, size_t blocks_off, void *x, size_t x_off, void *out,
178
  size_t out_off, int ggml_type, int64_t K, int64_t N, int64_t M) {
179
  const TypeInfo *info = lookup(ggml_type);
gguf_metal/gguf_metal.cpp CHANGED
@@ -25,6 +25,13 @@ size_t byte_offset(const at::Tensor &t) {
25
 
26
  } // namespace
27
 
 
 
 
 
 
 
 
28
  at::Tensor dequantize(const at::Tensor &blocks, int64_t ggml_type, int64_t rows, int64_t cols,
29
  at::ScalarType dtype) {
30
  TORCH_CHECK(blocks.is_mps() && blocks.scalar_type() == at::kByte, "blocks must be mps uint8");
 
25
 
26
  } // namespace
27
 
28
+ std::vector<int64_t> gemv_types() {
29
+ int ids[64];
30
+ const int n = gguf_metal_gemv_types(ids, 64);
31
+ TORCH_CHECK(n <= 64, "gguf-kernels: the Metal type table outgrew the buffer here");
32
+ return std::vector<int64_t>(ids, ids + n);
33
+ }
34
+
35
  at::Tensor dequantize(const at::Tensor &blocks, int64_t ggml_type, int64_t rows, int64_t cols,
36
  at::ScalarType dtype) {
37
  TORCH_CHECK(blocks.is_mps() && blocks.scalar_type() == at::kByte, "blocks must be mps uint8");
tests/test_gguf_kernels.py CHANGED
@@ -10,57 +10,86 @@ import numpy as np
10
  import pytest
11
  import torch
12
 
13
- from gguf_kernels import MAX_GEMV_ROWS, dequantize, mul_mat_vec
14
 
15
 
16
  gguf = pytest.importorskip("gguf", reason="the reference unpacker comes from the `gguf` package")
17
 
18
- # name -> (ggml type id, values per block, bytes per block)
 
 
 
 
19
  QUANT_TYPES = {
20
- "Q4_K": (12, 256, 144),
21
- "Q5_K": (13, 256, 176),
22
- "Q6_K": (14, 256, 210),
23
- "Q8_0": (8, 32, 34),
 
 
 
 
 
 
 
24
  }
25
 
 
 
 
26
 
27
- def random_blocks(rows: int, cols: int, ggml_name: str, device="cuda"):
 
28
  """Random blocks and the values `gguf` reads out of them."""
29
  _, block_values, block_bytes = QUANT_TYPES[ggml_name]
30
  generator = np.random.default_rng(0)
31
  packed = generator.integers(0, 256, (rows, cols // block_values * block_bytes), dtype=np.uint8)
32
- # every fp16 scale sits at an even offset in its block, so clearing bit 6 of each odd byte keeps
33
- # every possible fp16 field finite whatever the rest of the pattern is
34
- packed[:, 1::2] &= 0xBF
 
 
 
 
 
 
35
 
36
  quant_type = getattr(gguf.GGMLQuantizationType, ggml_name)
37
  reference = gguf.quants.dequantize(packed.reshape(-1), quant_type).reshape(rows, cols)
 
38
  return torch.from_numpy(packed).to(device), torch.from_numpy(reference).to(device)
39
 
40
 
41
  @pytest.mark.kernels_ci
42
- @pytest.mark.parametrize("ggml_name", QUANT_TYPES)
43
  @pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
44
  def test_dequantize_matches_reference(ggml_name, dtype):
45
- ggml_type = QUANT_TYPES[ggml_name][0]
46
  rows, cols = 64, 512
47
  blocks, reference = random_blocks(rows, cols, ggml_name)
48
 
49
  out = dequantize(blocks, ggml_type, rows, cols, dtype)
50
 
51
  assert out.shape == (rows, cols) and out.dtype == dtype
52
- # the kernel writes `dtype` directly, so the tolerance is that dtype's own resolution
53
- torch.testing.assert_close(out.float(), reference, rtol=torch.finfo(dtype).eps * 4, atol=0)
 
 
 
 
 
 
 
54
 
55
 
56
  @pytest.mark.kernels_ci
57
- @pytest.mark.parametrize("ggml_name", QUANT_TYPES)
58
  @pytest.mark.parametrize("n_rows", [1, MAX_GEMV_ROWS])
59
  def test_mul_mat_vec_matches_matmul(ggml_name, n_rows):
60
- ggml_type = QUANT_TYPES[ggml_name][0]
61
  out_features, in_features = 128, 512
62
  blocks, reference = random_blocks(out_features, in_features, ggml_name)
63
- x = torch.randn(n_rows, in_features, dtype=torch.bfloat16, device="cuda")
64
 
65
  out = mul_mat_vec(blocks, x, ggml_type, out_features)
66
 
@@ -73,10 +102,10 @@ def test_mul_mat_vec_matches_matmul(ggml_name, n_rows):
73
  @pytest.mark.kernels_ci
74
  def test_gemv_is_compileable():
75
  """A graph break here would cost more than the kernel saves, so the fake has to be right."""
76
- ggml_type = QUANT_TYPES["Q4_K"][0]
77
  out_features, in_features = 128, 512
78
  blocks, reference = random_blocks(out_features, in_features, "Q4_K")
79
- x = torch.randn(1, in_features, dtype=torch.bfloat16, device="cuda")
80
 
81
  compiled = torch.compile(
82
  lambda t: mul_mat_vec(blocks, t, ggml_type, out_features), fullgraph=True
 
10
  import pytest
11
  import torch
12
 
13
+ from gguf_kernels import GEMV_TYPES, MAX_GEMV_ROWS, dequantize, mul_mat_vec
14
 
15
 
16
  gguf = pytest.importorskip("gguf", reason="the reference unpacker comes from the `gguf` package")
17
 
18
+ DEVICE = "cuda" if torch.cuda.is_available() else "mps"
19
+
20
+ # name -> (ggml type id, values per block, bytes per block), taken from gguf's own quant sizes so a
21
+ # block layout is never restated here. Q1_0/Q2_0/NVFP4 are missing: the `gguf` release this tests
22
+ # against cannot unpack them, so there is no reference to compare a kernel to.
23
  QUANT_TYPES = {
24
+ name: (int(t), *gguf.GGML_QUANT_SIZES[t])
25
+ for name, t in (
26
+ (n, getattr(gguf.GGMLQuantizationType, n, None))
27
+ for n in (
28
+ "Q4_0", "Q4_1", "Q5_0", "Q5_1", "Q8_0",
29
+ "Q2_K", "Q3_K", "Q4_K", "Q5_K", "Q6_K",
30
+ "IQ2_XXS", "IQ2_XS", "IQ3_XXS", "IQ1_S", "IQ4_NL",
31
+ "IQ3_S", "IQ2_S", "IQ4_XS", "IQ1_M", "MXFP4",
32
+ )
33
+ )
34
+ if t is not None
35
  }
36
 
37
+ # Which of those this build actually implements a gemv for. Asked rather than assumed: the two
38
+ # backends do not cover the same set, and a type routed into a gemv it has no kernel for faults.
39
+ SUPPORTED = {name: v for name, v in QUANT_TYPES.items() if v[0] in GEMV_TYPES}
40
 
41
+
42
+ def random_blocks(rows: int, cols: int, ggml_name: str, device=DEVICE):
43
  """Random blocks and the values `gguf` reads out of them."""
44
  _, block_values, block_bytes = QUANT_TYPES[ggml_name]
45
  generator = np.random.default_rng(0)
46
  packed = generator.integers(0, 256, (rows, cols // block_values * block_bytes), dtype=np.uint8)
47
+ if ggml_name == "MXFP4":
48
+ # The only type whose scale is not an fp16 field: one E8M0 byte per block, worth
49
+ # 2^(byte-127), so a random byte is worth up to 2^128 and the block overflows to inf in the
50
+ # reference before a kernel is even involved. Held near 2^0 instead.
51
+ packed[:, ::block_bytes] = generator.integers(120, 135, packed[:, ::block_bytes].shape)
52
+ else:
53
+ # every fp16 scale sits at an even offset in its block, so clearing bit 6 of each odd byte
54
+ # keeps every possible fp16 field finite whatever the rest of the pattern is
55
+ packed[:, 1::2] &= 0xBF
56
 
57
  quant_type = getattr(gguf.GGMLQuantizationType, ggml_name)
58
  reference = gguf.quants.dequantize(packed.reshape(-1), quant_type).reshape(rows, cols)
59
+ assert np.isfinite(reference).all(), f"the {ggml_name} reference is not finite"
60
  return torch.from_numpy(packed).to(device), torch.from_numpy(reference).to(device)
61
 
62
 
63
  @pytest.mark.kernels_ci
64
+ @pytest.mark.parametrize("ggml_name", SUPPORTED)
65
  @pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
66
  def test_dequantize_matches_reference(ggml_name, dtype):
67
+ ggml_type = SUPPORTED[ggml_name][0]
68
  rows, cols = 64, 512
69
  blocks, reference = random_blocks(rows, cols, ggml_name)
70
 
71
  out = dequantize(blocks, ggml_type, rows, cols, dtype)
72
 
73
  assert out.shape == (rows, cols) and out.dtype == dtype
74
+ # The kernel writes `dtype` directly, so the tolerance is that dtype's own resolution -- but
75
+ # measured against the scale of the values, not each element. Every value in a block shares one
76
+ # scale, so a dequantizer's error is a fraction of that scale; asking a near-zero element to
77
+ # match to its own magnitude asks for an exactness even a different summation order breaks.
78
+ # Most types do come back bit-exact; Q4_0/Q4_1/Q4_K land within an ulp or so of f32.
79
+ scale = reference.abs().max()
80
+ torch.testing.assert_close(
81
+ out.float(), reference, rtol=0, atol=torch.finfo(dtype).eps * 4 * scale
82
+ )
83
 
84
 
85
  @pytest.mark.kernels_ci
86
+ @pytest.mark.parametrize("ggml_name", SUPPORTED)
87
  @pytest.mark.parametrize("n_rows", [1, MAX_GEMV_ROWS])
88
  def test_mul_mat_vec_matches_matmul(ggml_name, n_rows):
89
+ ggml_type = SUPPORTED[ggml_name][0]
90
  out_features, in_features = 128, 512
91
  blocks, reference = random_blocks(out_features, in_features, ggml_name)
92
+ x = torch.randn(n_rows, in_features, dtype=torch.bfloat16, device=DEVICE)
93
 
94
  out = mul_mat_vec(blocks, x, ggml_type, out_features)
95
 
 
102
  @pytest.mark.kernels_ci
103
  def test_gemv_is_compileable():
104
  """A graph break here would cost more than the kernel saves, so the fake has to be right."""
105
+ ggml_type = SUPPORTED["Q4_K"][0]
106
  out_features, in_features = 128, 512
107
  blocks, reference = random_blocks(out_features, in_features, "Q4_K")
108
+ x = torch.randn(1, in_features, dtype=torch.bfloat16, device=DEVICE)
109
 
110
  compiled = torch.compile(
111
  lambda t: mul_mat_vec(blocks, t, ggml_type, out_features), fullgraph=True
tests/test_vendor_drift.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Metal type table against the upstream code it was transcribed from.
2
+
3
+ `gguf_metal/ggml_dispatch.mm` re-implements, for the two ops here, what ggml's Metal backend does in
4
+ `ggml_metal_library_get_pipeline_mul_mv` and `ggml_metal_op_mul_mat`: which types have a gemv, how
5
+ much threadgroup memory each wants, and which of the two grids it is dispatched on. Those files are
6
+ vendored, so `vendor.py --rev` replaces them, and a pin bump can change any of it.
7
+
8
+ Most of that drift is caught by the compiler -- the nsg/nr0 values are used through upstream's own
9
+ `N_SG_*`/`N_R0_*` macros, so a rename or removal fails the build. What is not caught is a changed
10
+ `smem` expression or a changed grid rule: both still compile and both silently return wrong numbers.
11
+ This parses upstream and fails loudly instead.
12
+
13
+ No device and no built extension needed, so it runs anywhere.
14
+ """
15
+
16
+ import re
17
+ from pathlib import Path
18
+
19
+ import pytest
20
+
21
+
22
+ ROOT = Path(__file__).resolve().parent.parent
23
+ DEVICE_CPP = ROOT / "vendor/src/ggml-metal/ggml-metal-device.cpp"
24
+ OPS_CPP = ROOT / "vendor/src/ggml-metal/ggml-metal-ops.cpp"
25
+ DISPATCH_MM = ROOT / "gguf_metal/ggml_dispatch.mm"
26
+
27
+ pytestmark = pytest.mark.skipif(not DEVICE_CPP.exists(), reason="vendor/ is not checked out")
28
+
29
+
30
+ def upstream_mul_mv_table():
31
+ """{type name: smem expression} from ggml_metal_library_get_pipeline_mul_mv's switch."""
32
+ body = DEVICE_CPP.read_text()
33
+ start = body.index("ggml_metal_library_get_pipeline_mul_mv(")
34
+ end = body.index("ggml_metal_library_get_pipeline_mul_mm_id_map0", start)
35
+ switch = body[start:end]
36
+
37
+ table = {}
38
+ for case in re.finditer(
39
+ r"case GGML_TYPE_(\w+):\s*\{(.*?)\}\s*break;", switch, re.S
40
+ ):
41
+ name, arm = case.group(1), case.group(2)
42
+ smem = re.search(r"smem\s*=\s*([^;]+);", arm)
43
+ table[name] = " ".join(smem.group(1).split()) if smem else "0"
44
+ return table
45
+
46
+
47
+ def ours():
48
+ """{type name: smem expression} from our table, keyed the way upstream names its types."""
49
+ body = DISPATCH_MM.read_text()
50
+ start = body.index("const std::unordered_map<int, TypeInfo> &type_table()")
51
+ end = body.index("const TypeInfo *lookup(", start)
52
+
53
+ table = {}
54
+ for entry in re.finditer(
55
+ r"\{GGML_(\w+),\s*\{\"[\w]+\",\s*\d+,\s*\d+,\s*N_SG_\w+,\s*N_R0_\w+,\s*(true|false),\s*([^}]+)\}\}",
56
+ body[start:end],
57
+ ):
58
+ name, reduce_flag, smem = entry.group(1), entry.group(2), entry.group(3)
59
+ table[name] = (" ".join(smem.split()).rstrip(","), reduce_flag == "true")
60
+ return table
61
+
62
+
63
+ def normalize(expr: str) -> str:
64
+ """`32 * sizeof(float) * N_R0_Q8_0` and `32*sizeof(float)*N_R0_Q8_0` are the same expression."""
65
+ return expr.replace(" ", "").replace("(float)", "(float)")
66
+
67
+
68
+ def test_every_type_we_claim_still_exists_upstream():
69
+ upstream = upstream_mul_mv_table()
70
+ missing = sorted(set(ours()) - set(upstream))
71
+ assert not missing, (
72
+ f"{missing} are in our table but no longer in upstream's mul_mv switch; the pin moved under "
73
+ "gguf_metal/ggml_dispatch.mm"
74
+ )
75
+
76
+
77
+ def test_threadgroup_memory_matches_upstream():
78
+ upstream = upstream_mul_mv_table()
79
+ mismatched = {
80
+ name: (smem, upstream[name])
81
+ for name, (smem, _) in ours().items()
82
+ if normalize(smem) != normalize(upstream[name])
83
+ }
84
+ assert not mismatched, (
85
+ "upstream changed the threadgroup memory for these types (ours, theirs): "
86
+ f"{mismatched}. Too little is a fault, too much silently costs occupancy."
87
+ )
88
+
89
+
90
+ def test_grid_rule_still_singles_out_q8_0():
91
+ """The one place a type's grid divisor is decided, and the one field we cannot get from a macro.
92
+
93
+ Upstream dispatches the dense types and q8_0 on `(ne01 + nr0 - 1)/nr0` and everything else on
94
+ `(ne01 + nr0*nsg - 1)/(nr0*nsg)`. Our `mv_reduce_across_sgs` is exactly that predicate, so if
95
+ upstream ever moves a quantized type across, this catches it.
96
+ """
97
+ body = OPS_CPP.read_text()
98
+ start = body.index("ggml_metal_library_get_pipeline_mul_mv(lib, op)")
99
+ branch = body[start : body.index("return 1;", start)]
100
+
101
+ condition = re.search(r"if \(op->src\[0\]->type ==(.*?)\) \{", branch, re.S)
102
+ assert condition, "upstream's mul_mv grid branch is no longer shaped as a type comparison"
103
+ quantized_on_nr0_grid = {
104
+ t for t in re.findall(r"GGML_TYPE_(\w+)", condition.group(1))
105
+ } - {"F32", "F16", "BF16"}
106
+
107
+ ours_on_nr0_grid = {name for name, (_, reduce_flag) in ours().items() if reduce_flag}
108
+ assert ours_on_nr0_grid == quantized_on_nr0_grid, (
109
+ f"upstream now puts {quantized_on_nr0_grid} on the undivided grid, we assume "
110
+ f"{ours_on_nr0_grid}; mv_reduce_across_sgs in gguf_metal/ggml_dispatch.mm has to follow"
111
+ )
torch-ext/gguf_kernels/__init__.py CHANGED
@@ -20,9 +20,16 @@ __all__ = ["GEMV_TYPES", "MAX_GEMV_ROWS", "dequantize", "mul_mat_vec"]
20
  # caller with more (prefill) dequantizes and uses an ordinary matmul.
21
  MAX_GEMV_ROWS = 8
22
 
23
- # ggml type ids `mul_mat_vec` implements: Q4_0/Q4_1/Q5_0/Q5_1/Q8_0, the K quants, and the IQ quants.
24
- # `dequantize` covers more, so check this one before choosing the fused path.
25
- GEMV_TYPES = frozenset({2, 3, 6, 7, 8, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 29, 39, 40, 41, 42})
 
 
 
 
 
 
 
26
 
27
 
28
  def dequantize(
 
20
  # caller with more (prefill) dequantizes and uses an ordinary matmul.
21
  MAX_GEMV_ROWS = 8
22
 
23
+ # ggml type ids `mul_mat_vec` implements, as this build reports them -- it is backend-specific, and
24
+ # a type routed into a gemv that has no kernel for it is a fault, not a fallback. CUDA covers
25
+ # Q4_0/Q4_1/Q5_0/Q5_1/Q8_0, the K quants, the IQ quants, MXFP4/NVFP4 and Q1_0/Q2_0; Metal is the
26
+ # same minus NVFP4/Q1_0/Q2_0, which its metallib has no kernels for. `dequantize` covers more, so
27
+ # check this before choosing the fused path.
28
+ try:
29
+ GEMV_TYPES = frozenset(ops.gemv_types())
30
+ except AttributeError:
31
+ # A build published before `gemv_types` existed; those are CUDA-only, so its list is theirs.
32
+ GEMV_TYPES = frozenset({2, 3, 6, 7, 8, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 29, 39, 40, 41, 42})
33
 
34
 
35
  def dequantize(
torch-ext/torch_binding.cpp CHANGED
@@ -6,6 +6,10 @@
6
  TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) {
7
  ops.def("dequantize(Tensor blocks, int ggml_type, int rows, int cols, ScalarType dtype) -> Tensor");
8
  ops.def("mul_mat_vec(Tensor blocks, Tensor x, int ggml_type, int out_features) -> Tensor");
 
 
 
 
9
 
10
  // The schema is the same for every backend; only the implementation differs.
11
  #if defined(CUDA_KERNEL) || defined(ROCM_KERNEL)
 
6
  TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) {
7
  ops.def("dequantize(Tensor blocks, int ggml_type, int rows, int cols, ScalarType dtype) -> Tensor");
8
  ops.def("mul_mat_vec(Tensor blocks, Tensor x, int ggml_type, int out_features) -> Tensor");
9
+ // Takes no tensor, so it has no device to dispatch on and is registered as a catch-all. Each
10
+ // backend's shared object is its own library namespace, so there is one implementation per build.
11
+ ops.def("gemv_types() -> int[]");
12
+ ops.impl("gemv_types", &gemv_types);
13
 
14
  // The schema is the same for every backend; only the implementation differs.
15
  #if defined(CUDA_KERNEL) || defined(ROCM_KERNEL)
torch-ext/torch_binding.h CHANGED
@@ -2,6 +2,14 @@
2
 
3
  #include <torch/torch.h>
4
 
 
 
 
 
 
 
 
 
5
  // The two entry points every backend implements. Both take a GGUF weight exactly as it is stored
6
  // in the file — `(rows, bytes_per_row)` uint8 blocks — so nothing has to be unpacked to use them.
7
 
 
2
 
3
  #include <torch/torch.h>
4
 
5
+ #include <vector>
6
+
7
+ // The ggml type ids this build's `mul_mat_vec` implements. Backend-specific: the CUDA port reaches
8
+ // everything upstream's `mul_mat_vec_q_switch_type` covers, while Metal's list is the type table in
9
+ // gguf_metal/ggml_dispatch.mm. A caller must ask rather than assume, or it will route a type into a
10
+ // gemv that has no kernel for it.
11
+ std::vector<int64_t> gemv_types();
12
+
13
  // The two entry points every backend implements. Both take a GGUF weight exactly as it is stored
14
  // in the file — `(rows, bytes_per_row)` uint8 blocks — so nothing has to be unpacked to use them.
15