VoxtralRealtime: load checkpoints with a quantized tied embedding - #232
Conversation
The tied embedding doubles as the LM head, but a checkpoint that ships it quantized (packed weight + scales/biases alongside the fp16 rest) currently fails strict weight loading: the loader's quantize-structure pass skips tok_embeddings unconditionally, so the scales/biases arrive as unexpected keys. The skip appears deliberate rather than accidental — it mirrors exactly what the reference conversions leave unquantized (norms, conv stem, adapter, embeddings), and it guarded two decode paths that read the raw weight tensor directly (embedToken's row gather and the logits matmul over weight.T): had a quantized embedding ever been structured, those reads would have consumed bit-packed data and produced silent garbage, so refusing to load was the safer failure. This change fixes the underlying paths instead, at which point the skip only blocks legitimate checkpoints: - embedToken gathers through the module - a dequantizing gather for a QuantizedEmbedding, bit-identical to the raw row gather for a plain one. - logits projects through Embedding.asLinear - the same contraction as the raw matmul over weight.T for a plain embedding (up to kernel dispatch), the quantized-matmul path for a quantized one. - shouldQuantize no longer skips tok_embeddings; the quantize pass is already gated on the checkpoint carrying '<path>.scales', so checkpoints with an fp16 embedding are structured exactly as before. Tests pin both properties: the module-routed gather is exactly the raw-row gather and the projection matches the raw contraction within kernel rounding for a plain embedding; for a quantized tied embedding the gather returns exactly the dequantized row and the projection matches the dequantized-weight contraction within the quantized kernel's accumulation error.
|
For anyone wanting to try this: I've published a ready-made checkpoint with the 4-bit/g64-quantized tied embedding — https://huggingface.co/T0mSIlver/Voxtral-Mini-4B-Realtime-2602-4bit-qhead (otherwise the same conversion as mlx-community's 4-bit). Worth underlining that this is a big realtime win for quantized-model users, not just a compatibility fix: the tied head is the decode loop's single largest phase, and quantizing it takes the LM-head projection from ~30 ms to ~3 ms per decoded token on an M1 Pro (~30 ms off every 100 ms streaming step, ~530 MiB less resident memory, transcription quality level on our dictation eval). And since MLX's standard |
…talog switch Pin SpeechHelper's mlx-audio-swift to fork branch fix/load-quantized-tied-embedding (791ab87 = upstream main 6ea59e5, which already contains the former e1-e2 perf commits as upstream #229/#230/#231, plus the quantized-tied-embedding loader fix staged upstream as Blaizzy/mlx-audio-swift#232), and switch SpeechModelCatalog to T0mSIlver/Voxtral-Mini-4B-Realtime-2602-4bit-qhead @ 247f2ee — the same 4-bit conversion with a 4-bit/g64-quantized tied embedding/LM head, the decode loop's dominant per-token cost.
…talog switch (#169) Pin SpeechHelper's mlx-audio-swift to fork branch fix/load-quantized-tied-embedding (791ab87 = upstream main 6ea59e5, which already contains the former e1-e2 perf commits as upstream #229/#230/#231, plus the quantized-tied-embedding loader fix staged upstream as Blaizzy/mlx-audio-swift#232), and switch SpeechModelCatalog to T0mSIlver/Voxtral-Mini-4B-Realtime-2602-4bit-qhead @ 247f2ee — the same 4-bit conversion with a 4-bit/g64-quantized tied embedding/LM head, the decode loop's dominant per-token cost.
…ged) (#186) The quantized-tied-embedding loader fix staged on the temporary T0mSIlver/mlx-audio-swift fork merged upstream as Blaizzy/mlx-audio-swift#232 (merge commit 8ed8188). Repoint the SpeechHelper pin at upstream main at that revision, per the switchback plan in DEPENDENCY.md. The merged fix is a review-evolved variant of the fork commit (module-routed embedToken/logits plus upstream regression tests), so the live speechd integration gate was re-run against the real qhead checkpoint. Upstream's resolved graph at the new SHA matches all four exact graph pins; nothing else changes.
Allows loading Voxtral Realtime models whose tied embedding is quantized. Today such models fail to load — which leaves the most expensive per-token operation of the decode loop stuck in fp16 (see the numbers below).
Problem
Voxtral Realtime has no separate LM-head weight: the decoder reuses the token-embedding matrix (
tok_embeddings) both to embed input tokens and to project hidden states to logits ("tied embedding").A converted checkpoint can store this matrix in one of two forms:
tok_embeddings.weighttensor. This is what the current mlx-community conversions ship, and it loads fine.weightplusscalesandbiases. This is the form MLX's standard quantization path (nn.quantize, which includes embeddings) produces, so converted checkpoints commonly ship it for other model families.The quantized form cannot be loaded. During model construction the loader runs a quantize pass that turns each
Linear/Embeddinginto its quantized counterpart when the checkpoint hasscalesfor it — butshouldQuantizehard-excludestok_embeddings, so the module always stays a plainEmbeddingthat expects exactly one fp16 tensor. Weight loading withverify: .allthen rejects the checkpoint'stok_embeddings.scales/.biaseswith anUpdateError— a plainEmbeddinghas no such parameters.Why it matters
For the 4B model the tied head is a 131072×3072 matrix: 768 MiB in fp16, read in full for every decoded token's logits row. In live streaming on an M1 Pro that fp16 gemv is the single largest phase of the decode loop. With a 4-bit/group-64 quantized tied embedding (measured, same machine, same audio):
(The streaming numbers are from one realtime workload on one machine; the per-token LM-head saving applies to any decode loop over this model.)
Why the exclusion existed
The exclusion and the
.scalesgate both date from the original model port (#52), and it looks deliberate:weighttensor directly:embedTokengathers a row withweight[tokenId], andlogitscomputesmatmul(h, weight.T). On aQuantizedEmbeddingtheweightproperty is the bit-packed tensor, so if a quantized embedding had ever been structured, both paths would have consumed packed bits and produced garbage tokens with no error. Refusing to load was the safer failure mode.This PR fixes the two paths, at which point the exclusion only blocks legitimate checkpoints.
Change
Three pieces. For fp16-embedding checkpoints the gather is bit-identical and the projection is the same contraction (within kernel rounding — the tests pin both):
embedTokengathers through the module call instead of indexingweight— on aQuantizedEmbeddingthat dequantizes the row, on a plainEmbeddingit is the same gather.logitsprojects throughEmbedding.asLinear— the same contraction for a plain embedding, the quantized-matmul path for a quantized one.shouldQuantizeno longer excludestok_embeddings. The quantize pass is still gated on the checkpoint shipping<path>.scales, so fp16-embedding checkpoints are structured exactly as before; nothing is quantized that the checkpoint didn't quantize.Tests
Tests/VoxtralRealtimeTiedEmbeddingTests.swift:embedToken/logitsmatch the raw-weight formulas they replaced (gather exactly; projection within kernel rounding).quantize(model:filter:)machinery):embedTokenreturns exactly the dequantized row, and the tied projection matches the dequantized-weight contraction within the quantized kernel's accumulation error.Also validated end-to-end on device: a Voxtral-Mini-4B checkpoint with a 4-bit/g64-quantized tied embedding loads and transcribes real audio correctly through the streaming session (the eval above).