Current behavior
GGML_TYPE_TQ1_0 has no Metal kernels at all. A grep for tq1_0 across
ggml/src/ggml-metal/ returns zero matches, while its sibling TQ2_0 is fully
supported there (dequantize helper, mul_mv, mul_mm, mul_mm_id, mul_mv_id,
get_rows, cpy, set_rows).
Worse than absence, the backend actively claims the type. ggml_metal_device_supports_op
gates these ops by rejecting a single type rather than by accepting a known list:
ggml/src/ggml-metal/ggml-metal-device.m:1719 for GGML_OP_MUL_MAT and
GGML_OP_MUL_MAT_ID returns has_simdgroup_reduction && type != GGML_TYPE_NVFP4.
ggml/src/ggml-metal/ggml-metal-device.m:1786 for GGML_OP_GET_ROWS returns
type != GGML_TYPE_NVFP4.
So TQ1_0 passes the capability check, the scheduler places the op on Metal, the
pipeline lookup asks for a kernel name that was never compiled, and
ggml_metal_library_compile_pipeline aborts the process. This does not show up
today only because TQ1_0 is commented out of the op-test type lists at
tests/test-backend-ops.cpp:8347 and tests/test-backend-ops.cpp:8377. Enable it
and a Metal op-test run aborts.
This is the third instance of the same pattern found in this backend recently. The
other two were a flash-attention variant that aborts on a missing kernel for an
unsupported KV type, and a low-bit matmul that silently took a generic kernel
instead of its tuned one. A permissive supports_op turns a missing kernel into
either a crash or a silent slowdown, never a clean fallback.
Why support the type here
TQ1_0 is the format the wider ecosystem uses for ternary weights, at 1.6875 bpw
against TQ2_0's 2.0625, so it is the smaller of the two ternary options on disk and
in memory. Apple hardware is a primary target for low-bit inference, and a unified
memory device is exactly where the smaller footprint pays. Right now a ternary
model in this format runs its matmuls on the CPU on Apple silicon, or aborts.
Proposed approach
The type's block layout is block_tq1_0 in ggml/src/ggml-common.h:288-294: 5
trits packed per byte in base 3 for the bulk of the block, plus a qh tail at 4
elements per byte, plus one half-precision scale per 256 elements. That base-3
packing is what makes it different from every byte-aligned type in the backend, and
it is the part to get right first.
Follow the existing TQ2_0 code path file by file, since the plumbing is identical
and only the unpack differs:
kernels/dequantize.h: add dequantize_tq1_0, modelled on dequantize_tq2_0
at line 752. Expand each packed byte into all five of its trits in place rather
than indexing by element and deriving the byte, so every byte is read once.
Handle the qh tail separately.
kernels/quantize.metal: instantiate kernel_get_rows_tq1_0 and the cpy
variants, alongside the TQ2_0 instantiations at lines 307, 152 and 163.
kernels/mul_mv.metal: add a kernel_mul_mv_tq1_0_f32_impl plus the mul_mv_id
instantiation, following the TQ2_0 versions at lines 3089 and 3360.
kernels/mul_mm.metal: add the four mul_mm and mul_mm_id instantiations,
following lines 767, 794, 830 and 857.
- Remove the TQ1_0 rejections from
supports_op as each op gains a kernel, and
narrow those two gates to accept-lists so the next missing kernel produces a
fallback rather than an abort.
Prior art worth reading before writing the unpack: the Vulkan TQ1_0 work in
ggml-org#27765 (open, approved) is the only existing GPU solution to this
layout. It loads one packed byte per thread, expands all five digits in place, and
packs the powers of three into a single word so they extract with a shift and a
mask. The same structure applies directly to a Metal mat-vec kernel.
Acceptance criteria
- TQ1_0 uncommented in
all_types and other_types in tests/test-backend-ops.cpp,
so the run is not vacuous.
test-backend-ops -b Metal -o GET_ROWS passes for tq1_0.
test-backend-ops -b Metal -o MUL_MAT passes for tq1_0 at both single-token and
batched shapes, so the mat-vec and mat-mul paths are both covered.
test-backend-ops -b Metal -o MUL_MAT_ID passes for tq1_0, including at least
one single-token shape with more than one block per row, since a wrong per-expert
base offset is invisible when a row is a single block.
- A real ternary model in this format generates coherent output on Metal with layers
offloaded, not only op tests.
llama-bench on the same model: TQ1_0 decode and prefill within 25 percent of
TQ2_0 on the same device. Slower than the CPU fallback is an automatic fail.
- The two
supports_op gates converted to accept-lists, and a note in the commit
message pointing at the two other bugs that the permissive form caused.
Interim state
Until kernels exist, the type must be declined rather than claimed. Upstream
already carries that fix as part of ggml-org#27765, and it has been cherry-picked onto this
fork's TQ1_0 branch. Anyone implementing the kernels should remove those rejections
op by op as each kernel lands.
Current behavior
GGML_TYPE_TQ1_0has no Metal kernels at all. A grep fortq1_0acrossggml/src/ggml-metal/returns zero matches, while its sibling TQ2_0 is fullysupported there (dequantize helper,
mul_mv,mul_mm,mul_mm_id,mul_mv_id,get_rows,cpy,set_rows).Worse than absence, the backend actively claims the type.
ggml_metal_device_supports_opgates these ops by rejecting a single type rather than by accepting a known list:
ggml/src/ggml-metal/ggml-metal-device.m:1719forGGML_OP_MUL_MATandGGML_OP_MUL_MAT_IDreturnshas_simdgroup_reduction && type != GGML_TYPE_NVFP4.ggml/src/ggml-metal/ggml-metal-device.m:1786forGGML_OP_GET_ROWSreturnstype != GGML_TYPE_NVFP4.So TQ1_0 passes the capability check, the scheduler places the op on Metal, the
pipeline lookup asks for a kernel name that was never compiled, and
ggml_metal_library_compile_pipelineaborts the process. This does not show uptoday only because TQ1_0 is commented out of the op-test type lists at
tests/test-backend-ops.cpp:8347andtests/test-backend-ops.cpp:8377. Enable itand a Metal op-test run aborts.
This is the third instance of the same pattern found in this backend recently. The
other two were a flash-attention variant that aborts on a missing kernel for an
unsupported KV type, and a low-bit matmul that silently took a generic kernel
instead of its tuned one. A permissive
supports_opturns a missing kernel intoeither a crash or a silent slowdown, never a clean fallback.
Why support the type here
TQ1_0 is the format the wider ecosystem uses for ternary weights, at 1.6875 bpw
against TQ2_0's 2.0625, so it is the smaller of the two ternary options on disk and
in memory. Apple hardware is a primary target for low-bit inference, and a unified
memory device is exactly where the smaller footprint pays. Right now a ternary
model in this format runs its matmuls on the CPU on Apple silicon, or aborts.
Proposed approach
The type's block layout is
block_tq1_0inggml/src/ggml-common.h:288-294: 5trits packed per byte in base 3 for the bulk of the block, plus a
qhtail at 4elements per byte, plus one half-precision scale per 256 elements. That base-3
packing is what makes it different from every byte-aligned type in the backend, and
it is the part to get right first.
Follow the existing TQ2_0 code path file by file, since the plumbing is identical
and only the unpack differs:
kernels/dequantize.h: adddequantize_tq1_0, modelled ondequantize_tq2_0at line 752. Expand each packed byte into all five of its trits in place rather
than indexing by element and deriving the byte, so every byte is read once.
Handle the
qhtail separately.kernels/quantize.metal: instantiatekernel_get_rows_tq1_0and thecpyvariants, alongside the TQ2_0 instantiations at lines 307, 152 and 163.
kernels/mul_mv.metal: add akernel_mul_mv_tq1_0_f32_implplus themul_mv_idinstantiation, following the TQ2_0 versions at lines 3089 and 3360.
kernels/mul_mm.metal: add the fourmul_mmandmul_mm_idinstantiations,following lines 767, 794, 830 and 857.
supports_opas each op gains a kernel, andnarrow those two gates to accept-lists so the next missing kernel produces a
fallback rather than an abort.
Prior art worth reading before writing the unpack: the Vulkan TQ1_0 work in
ggml-org#27765 (open, approved) is the only existing GPU solution to this
layout. It loads one packed byte per thread, expands all five digits in place, and
packs the powers of three into a single word so they extract with a shift and a
mask. The same structure applies directly to a Metal mat-vec kernel.
Acceptance criteria
all_typesandother_typesintests/test-backend-ops.cpp,so the run is not vacuous.
test-backend-ops -b Metal -o GET_ROWSpasses fortq1_0.test-backend-ops -b Metal -o MUL_MATpasses fortq1_0at both single-token andbatched shapes, so the mat-vec and mat-mul paths are both covered.
test-backend-ops -b Metal -o MUL_MAT_IDpasses fortq1_0, including at leastone single-token shape with more than one block per row, since a wrong per-expert
base offset is invisible when a row is a single block.
offloaded, not only op tests.
llama-benchon the same model: TQ1_0 decode and prefill within 25 percent ofTQ2_0 on the same device. Slower than the CPU fallback is an automatic fail.
supports_opgates converted to accept-lists, and a note in the commitmessage pointing at the two other bugs that the permissive form caused.
Interim state
Until kernels exist, the type must be declined rather than claimed. Upstream
already carries that fix as part of ggml-org#27765, and it has been cherry-picked onto this
fork's TQ1_0 branch. Anyone implementing the kernels should remove those rejections
op by op as each kernel lands.