Skip to content

Metal: no TQ1_0 kernels, and supports_op claims the type anyway #141

Description

@bri-prism

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:

  1. 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.
  2. kernels/quantize.metal: instantiate kernel_get_rows_tq1_0 and the cpy
    variants, alongside the TQ2_0 instantiations at lines 307, 152 and 163.
  3. 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.
  4. kernels/mul_mm.metal: add the four mul_mm and mul_mm_id instantiations,
    following lines 767, 794, 830 and 857.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions