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.
build/bin/transcribe-quantize INPUT.gguf OUTPUT.gguf --quant PRESETPresets: 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<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
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.
| Bucket | What's in it | Quant behavior |
|---|---|---|
Linear |
ggml_mul_mat operands: encoder FF, attention projections, predictor LSTM gates, joint projections, head |
Block-quantized (Q4_K / Q5_K / Q6_K / Q8_0) |
Embed |
Decoder 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×1 pointwise conv kernels in conformer blocks | F16 (im2col+matmul supports F16 matmul) |
Conv |
Non-pointwise conv kernels: 2D pre-encode, depthwise | F32 or F16 (no quantized im2col in ggml) |
Norm |
Biases, LayerNorm/BatchNorm/RMSNorm weights, per-head q_norm/k_norm, positional bias/encoding, frontend buffers | F32 (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).
From tools/transcribe-quantize/policy.cpp:
| Preset | Linear main | Linear fallback | attn output | Embed | Conv | ConvPw | Norm |
|---|---|---|---|---|---|---|---|
F16 |
F16 | F16 | F16 | — | F32 | F16 | F32 |
Q4_0 |
Q4_0 | F16 | Q4_0 | — | F32 | F16 | F32 |
Q4_1 |
Q4_1 | F16 | Q4_1 | — | F32 | F16 | F32 |
Q5_0 |
Q5_0 | F16 | Q5_0 | — | F32 | F16 | F32 |
Q5_1 |
Q5_1 | F16 | Q5_1 | — | F32 | F16 | F32 |
Q8_0 |
Q8_0 | F16 | Q8_0 | — | F32 | F16 | F32 |
Q6_K |
Q6_K | Q8_0 | Q6_K | — | F32 | F16 | F32 |
Q5_K_M |
Q5_K | Q8_0 | Q8_0 | Q6_K | F32 | F16 | F32 |
Q4_K_M |
Q4_K | Q8_0 | Q8_0 | Q6_K | F32 | F16 | F32 |
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:
- Size. Falling back to
F16makes K presets larger thanQ8_0on families with 384/640/896-wide matrices (Whisper-tiny'sd_model=384, Parakeet's predictor/joint atne0=640, Qwen3-ASR's encoder atne0=896). That inverts the size ordering between presets and defeats the point of offering a K preset at all. - 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_K→Q4_1) would penalize them twice.Q8_0is effectively lossless vsF16and keeps the fallback strictly higher quality thanlinear_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_K → Q4_1,
Q5_K → Q5_1, Q6_K → Q8_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.
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).
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.
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).
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.