Quantization

June 6, 2026 · View on GitHub

transcribe-quantize is the C++ binary that produces every lossy quantized GGUF in this project. It reads an input GGUF (typically an F32, F16, or BF16 conversion output), walks every tensor, and writes a new GGUF where each tensor's dtype is chosen by a per-preset, per-bucket policy.

Source: tools/transcribe-quantize/. CLI lives in main.cpp, the classification / preset policy in policy.cpp. Built as build/bin/transcribe-quantize.

This is the only place lossy quantization happens in the project. Python's convert-<family>.py never emits Q8_0, Q4_K_M, or any other block-quantized type. See conversion.md for the separation.

transcribe-quantize links libggml directly and calls ggml_quantize_chunk — the same reference quantizers llama-quantize uses. Everything the model might be quantized to in the future is reachable without a Python gap.

CLI

build/bin/transcribe-quantize INPUT.gguf OUTPUT.gguf --quant PRESET

Presets: F16, Q4_0, Q4_1, Q5_0, Q5_1, Q8_0, Q6_K, Q5_K_M, Q4_K_M. Names match llama.cpp's llama-quantize exactly.

Example:

build/bin/transcribe-quantize \
  models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3-F32.gguf \
  models/parakeet-tdt-0.6b-v3/parakeet-tdt-0.6b-v3-Q4_K_M.gguf \
  --quant Q4_K_M

Output naming

<slug>-<preset>.gguf with the preset uppercase, matching llama.cpp:

parakeet-tdt-0.6b-v3-Q4_K_M.gguf
parakeet-tdt-0.6b-v3-Q5_K_M.gguf
cohere-transcribe-03-2026-Q6_K.gguf

How it works

  input GGUF  ──▶  load into ggml_context (all tensors resident)


                   for each tensor:
                     classify(name) → Bucket
                     resolve(Bucket, preset) → target ggml_type
                     if src.type == target:   memcpy
                     else:                    dequant → fp32 → ggml_quantize_chunk


                   copy all KV (tokenizer, hparams, frontend) unchanged
                   override general.file_type with the new preset's tag
                   write output GGUF in one pass

Tensor classification lives here, not in the Python converters. The converters only write source/reference-dtype GGUFs; this tool owns the per-preset bucket policy for every canonical tensor name.

Buckets

BucketWhat's in itQuant behavior
Linearggml_mul_mat operands: encoder FF, attention projections, predictor LSTM gates, joint projections, headBlock-quantized (Q4_K / Q5_K / Q6_K / Q8_0)
EmbedDecoder token embedding (dec.embed.token.weight, dec.token_embd.weight)Bumped to Q6_K in _M presets where shape-compatible, else linear_fallback
`ConvPw$1 \times 1 \text{pointwise} \text{conv} \text{kernels} \text{in} \text{conformer} \text{blocks}\text{F16} (\text{im2col}+\text{matmul} \text{supports} \text{F16} \text{matmul})
$Conv`Non-pointwise conv kernels: 2D pre-encode, depthwiseF32 or F16 (no quantized im2col in ggml)
NormBiases, LayerNorm/BatchNorm/RMSNorm weights, per-head q_norm/k_norm, positional bias/encoding, frontend buffersF32 (precision-sensitive, tiny)

Classification rules live in classify_tensor() in tools/transcribe-quantize/policy.cpp. They are substring-based on the canonical tensor name (e.g. norm_*, *.bias, *pointwise*.weight, *.q_norm.weight, *.ln_pre.weight).

Preset table

From tools/transcribe-quantize/policy.cpp:

PresetLinear mainLinear fallbackattn outputEmbedConvConvPwNorm
F16F16F16F16F32F16F32
Q4_0Q4_0F16Q4_0F32F16F32
Q4_1Q4_1F16Q4_1F32F16F32
Q5_0Q5_0F16Q5_0F32F16F32
Q5_1Q5_1F16Q5_1F32F16F32
Q8_0Q8_0F16Q8_0F32F16F32
Q6_KQ6_KQ8_0Q6_KF32F16F32
Q5_K_MQ5_KQ8_0Q8_0Q6_KF32F16F32
Q4_K_MQ4_KQ8_0Q8_0Q6_KF32F16F32

linear_fallback is the type used when a tensor's inner dim doesn't divide the target quant's block size. Every K preset (Q6_K, Q5_K_M, Q4_K_M) falls back to Q8_0. Legacy block quants (block size 32) set it to F16 as a tripwire — it should never fire in practice, since every sensible ne0 divides 32.

Q8_0 is the universal K-preset fallback for two reasons:

  1. Size. Falling back to F16 makes K presets larger than Q8_0 on families with 384/640/896-wide matrices (Whisper-tiny's d_model=384, Parakeet's predictor/joint at ne0=640, Qwen3-ASR's encoder at ne0=896). That inverts the size ordering between presets and defeats the point of offering a K preset at all.
  2. Quality. The tensors that trip the fallback are the same shape-awkward ones the model author chose — predictor outputs, small attention blocks, tied vocab heads. Falling back to a scaled-down legacy quant (Q4_KQ4_1) would penalize them twice. Q8_0 is effectively lossless vs F16 and keeps the fallback strictly higher quality than linear_main.

The cost vs a tighter Q4_1 / Q5_1 fallback is a few percent on file size, paid only on tensors that couldn't be K-quantized anyway. This deviates from llama.cpp's llama_tensor_get_type (Q4_KQ4_1, Q5_KQ5_1, Q6_KQ8_0), which is tuned for LLMs where fallback rarely triggers. For our ASR models the fallback path is the dominant case on several families, so a uniform conservative floor beats a size-matched legacy scale.

attn output gets bumped in _M presets (to Q8_0), in the spirit of llama.cpp's _M presets — which also keep a few precision-sensitive tensors above the base type. The specific tensor choice differs: llama.cpp's Q4_K_M / Q5_K_M bump attn_v, attn_qkv, and ffn_down (to Q6_K, on select layers via use_more_bits) and leave attn_output at the base type. Those categories aren't named separately in transcribe's policy. The bump matches three tensor suffixes — attn.linear_out.weight (Cohere, Parakeet), attn.out.weight (Qwen3-ASR encoder), and attn.o.weight (Qwen3-ASR decoder) — so all three families land in the same rule.

Embed slot is (no override) for uniform presets. Those fall back to linear_main. _M presets try to use Q6_K for decoder token embeddings; if the embedding inner dim does not divide 256, the embed tensor routes through the same linear_fallback as everything else (Q8_0). Legacy block quants (Q4_0/1, Q5_0/1) deliberately leave both attn output and Embed at linear_main — they are uniform accuracy/size tradeoffs, not mixed recipes.

Per-family policy overrides

Decoder token embeddings route into the Embed bucket so _M presets can keep them above the base type:

  • Cohere: dec.embed.token.weight
  • Qwen3-ASR and Whisper: dec.token_embd.weight (llama.cpp-style name)

Parakeet does not have a tied embedding. pred.embed.weight is a small predictor-only embedding and rides the Linear bucket.

Each family override lives as an explicit name check in classify_tensor(), not generalized into a "*embed*" → bump rule. Generalizing invites false-positive bumps on Parakeet's predictor embedding and any future family's small auxiliary embeddings. Revisit when 3+ families share the same tensor pattern and the same policy.

Small auxiliary tensors a loader reads through an F32-only GET_F32 slot (sinusoidal/positional tables, learned frontend scalars, RoPE-style inverse-frequency tables) route into the Norm bucket so every preset keeps them F32 — quantizing them would fail the loader's type check for negligible size savings. Current examples: Whisper enc.pos_emb.weight / dec.pos_emb.weight, Cohere *.pos_enc, Moonshine-streaming enc.embedder.comp.log_k, and Voxtral-Realtime dec.time_embed.inv_freq (the ada-norm time-embedding inverse-frequency table).

Loader allowlist

Every family's loader (src/arch/<family>/weights.cpp) declares which ggml types it will accept for each bucket. Post-unification, these are shared constants:

  • transcribe::weights::kQuantLinearTypes — F32, F16, BF16, Q4_0/1, Q5_0/1, Q8_0, Q4_K, Q5_K, Q6_K.
  • transcribe::weights::kQuantConvTypes — F32, F16.

Every family must accept the full allowlist. A family that can't (e.g. because a specific op is missing on a backend) is a bug, not a policy difference.

Presets roadmap

Today's preset table covers F16, the legacy block quants (Q4_0, Q4_1, Q5_0, Q5_1), Q8_0, Q6_K, Q5_K_M, and Q4_K_M. Planned additions:

  • Q3_K_S, IQ2_XXS, IQ4_XS — require imatrix support (not yet wired).

Running in bulk

Quantized GGUF acceptance is not tensor/numeric comparison. For shipped presets, first verify each file loads and emits valid transcribe-cli output, then use WER for user-facing quality. scripts/quant_accuracy.py is only an optional diagnostic for inspecting activation drift against an F32 baseline.