Skip to content

VoxtralRealtime: load checkpoints with a quantized tied embedding - #232

Merged
lucasnewman merged 1 commit into
Blaizzy:mainfrom
T0mSIlver:fix/load-quantized-tied-embedding
Jul 22, 2026
Merged

VoxtralRealtime: load checkpoints with a quantized tied embedding#232
lucasnewman merged 1 commit into
Blaizzy:mainfrom
T0mSIlver:fix/load-quantized-tied-embedding

Conversation

@T0mSIlver

Copy link
Copy Markdown
Contributor

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:

  • fp16: a single tok_embeddings.weight tensor. This is what the current mlx-community conversions ship, and it loads fine.
  • quantized: three tensors — a bit-packed weight plus scales and biases. 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/Embedding into its quantized counterpart when the checkpoint has scales for it — but shouldQuantize hard-excludes tok_embeddings, so the module always stays a plain Embedding that expects exactly one fp16 tensor. Weight loading with verify: .all then rejects the checkpoint's tok_embeddings.scales/.biases with an UpdateError — a plain Embedding has 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):

  • LM-head projection: ~30 ms → ~3 ms per decoded token (quantized-matmul fast path)
  • end-to-end 100 ms-cadence streaming step: 92–104 ms → 64–74 ms
  • resident weight memory: ≈ −530 MiB (768 MiB fp16 → ≈ 240 MiB packed weight + scales/biases)
  • transcription quality held level on a 156-case English/French dictation eval with heavy technical vocabulary: 50/156 hard cases fully passing vs 49/156 for the fp16-embedding checkpoint, mean word accuracy 0.73 vs 0.72 on its stress subset

(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 .scales gate both date from the original model port (#52), and it looks deliberate:

  1. It matches what the reference conversions leave unquantized — norms, conv stem, adapter projection, embeddings.
  2. More importantly, two decode paths read the raw weight tensor directly: embedToken gathers a row with weight[tokenId], and logits computes matmul(h, weight.T). On a QuantizedEmbedding the weight property 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):

  1. embedToken gathers through the module call instead of indexing weight — on a QuantizedEmbedding that dequantizes the row, on a plain Embedding it is the same gather.
  2. logits projects through Embedding.asLinear — the same contraction for a plain embedding, the quantized-matmul path for a quantized one.
  3. shouldQuantize no longer excludes tok_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:

  • Plain embedding: module-routed embedToken/logits match the raw-weight formulas they replaced (gather exactly; projection within kernel rounding).
  • Quantized embedding (via the standard quantize(model:filter:) machinery): embedToken returns 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).

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.
@T0mSIlver

Copy link
Copy Markdown
Contributor Author

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 nn.quantize path already produces quantized embeddings for other model families, any Voxtral checkpoint converted that way just works once this loads.

T0mSIlver added a commit to T0mSIlver/localvoxtral that referenced this pull request Jul 21, 2026
…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.
T0mSIlver added a commit to T0mSIlver/localvoxtral that referenced this pull request Jul 21, 2026
…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.
@lucasnewman
lucasnewman merged commit 8ed8188 into Blaizzy:main Jul 22, 2026
1 check passed
T0mSIlver added a commit to T0mSIlver/localvoxtral that referenced this pull request Jul 23, 2026
…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.
@T0mSIlver
T0mSIlver deleted the fix/load-quantized-tied-embedding branch July 23, 2026 16:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants