Metal: cover every quant type upstream's metallib has a gemv for
Browse filesThe 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 +7 -0
- gguf_metal/common.h +4 -0
- gguf_metal/ggml_dispatch.mm +46 -6
- gguf_metal/gguf_metal.cpp +7 -0
- tests/test_gguf_kernels.py +48 -19
- tests/test_vendor_drift.py +111 -0
- torch-ext/gguf_kernels/__init__.py +10 -3
- torch-ext/torch_binding.cpp +4 -0
- torch-ext/torch_binding.h +8 -0
|
@@ -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");
|
|
@@ -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 |
}
|
|
@@ -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
|
| 42 |
-
//
|
| 43 |
-
//
|
| 44 |
-
//
|
| 45 |
-
//
|
|
|
|
|
|
|
| 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);
|
|
@@ -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");
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
QUANT_TYPES = {
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
| 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 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 43 |
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
| 44 |
def test_dequantize_matches_reference(ggml_name, dtype):
|
| 45 |
-
ggml_type =
|
| 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 |
-
#
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
@pytest.mark.kernels_ci
|
| 57 |
-
@pytest.mark.parametrize("ggml_name",
|
| 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 =
|
| 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=
|
| 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 =
|
| 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=
|
| 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
|
|
@@ -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 |
+
)
|
|
@@ -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
|
| 24 |
-
#
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
@@ -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)
|
|
@@ -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 |
|