This page collects common numerical validation failures seen while
porting speech and audio-language models to transcribe.cpp. The main
validation contract is in
4-numerical-validation.md; this page is
for diagnosis once a tensor exceeds tolerance or appears with the wrong
shape.
Start with the first failing tensor. Later tensors usually inherit the earliest divergence, so large downstream errors are often symptoms, not the root cause. Error magnitude alone is not diagnostic: read both the reference implementation and the C++ graph at the divergent stage.
Mel spectrogram and raw-feature mismatches must be fixed before encoder work continues. Every downstream tensor depends on frame count, frame alignment, window values, and normalization.
Symptoms: mel or raw-feature output shape differs by one frame, or the shapes match but values are shifted by one hop.
Common causes: center=True vs center=False, reflect vs zero vs
constant padding, final partial frame kept vs dropped, frame count using
floor vs ceil, sample-rate mismatch, or a different trim/pad policy.
Fix: trace the STFT frame calculation in both implementations: input sample count, pad amount, window length, hop length, frame count, and final-frame handling. Do not compensate with wider encoder tolerances; fix the frontend contract.
Symptoms: shapes match, but mel values differ consistently across many frames or frequency bins.
Common causes: periodic vs symmetric Hann, Hamming vs Hann, Slaney vs HTK mel scale, different filterbank normalization, missing preemphasis, inference dither present on one side only, or biased vs unbiased normalization variance.
Fix: inspect the reference feature extractor source, not just the config. If the converter bakes windows or filterbanks into GGUF, bake the exact values used by the reference inference path.
Symptoms: one or a few final conv-subsampled frames have much larger diffs than the interior frames.
Common causes: boundary handling differences between framework conv implementations, for example ggml im2col versus PyTorch native conv.
Fix: verify the interior frames are tight and document the boundary
profile in tests/tolerances/<family>.json. A known final-frame artifact
can be tolerated, but a frame-count or frame-shift bug cannot.
Symptoms: every stage is close but nonzero, often with error that slowly accumulates through depth.
Common causes: comparing a bf16 or f16 GGUF against an f32 reference, or accidentally promoting/demoting weights during conversion.
Fix: the first accuracy GGUF should use the reference's natural inference dtype. If the production export intentionally uses lower precision, document that precision target in the tolerance file and keep output-adjacent gates tight enough to catch real bugs.
Symptoms: mean error is low, max error may be higher in matmul or conv-heavy regions, and normalized output gates become stable again.
Common causes: different GEMM, convolution, or attention kernels; bf16/f16 accumulation order; fused kernels versus unfused kernels.
Fix: classify this only after ruling out layout and op mismatches. Use observed CPU data to set tolerances and document where the drift attenuates.
Symptoms: errors are small enough to look numerical but persist systematically at a specific op.
Common causes: wrong norm epsilon, RMSNorm vs LayerNorm, biased vs unbiased variance, wrong log base, wrong activation approximation, or an unexpected fp32 upcast in the reference.
Fix: treat this as an implementation mismatch, not harmless precision noise. Match constants, formulas, and intermediate dtypes exactly.
Symptoms: multiple unrelated C++ dump tensors are byte-exact equal, early/middle layer tolerances are enormous while final output gates look tight, or an intermediate's activation range looks like a later tensor.
Common cause: the family graph builder saved intermediate tensor
pointers for dumping after graph_compute, but did not mark those
tensors as graph outputs. The ggml scheduler reused their buffers for
later operations.
Fix: call transcribe::debug::mark_tensor_for_dump() on every
contract dump tensor while building the graph, before scheduler graph
allocation. Keep this gated through the helper rather than calling
ggml_set_output() unconditionally, so normal inference does not carry
extra live tensors.
Symptoms: large errors with structure. Expected values may appear in the output but at wrong positions; rows, columns, channels, or heads look scrambled.
Common causes: transposed weight, wrong reshape axis, conv kernel layout mismatch, head-splitting axis flipped, matmul operand order wrong, or a converter-side permutation bug.
Fix: inspect the first tensor that consumes the affected weight. A layout error may live in the converter even when it first appears during C++ graph execution.
Symptoms: one dimension appears repeated, a bias is applied along the wrong axis, or the flat comparison has a repeating error pattern.
Common causes: different row-major interpretation, implicit broadcasting in PyTorch not mirrored in ggml, wrong view/contiguous assumption, or a shape encoded in the wrong slow-to-fast order.
Fix: dump shape metadata on both sides and verify the semantic axes, not only the element count.
Symptoms: upstream tensors are clean, then values become completely wrong at one stage.
Common causes: wrong ggml op, missing bias, missing scale factor, wrong activation, wrong division order, wrong residual source, or layer order swapped.
Fix: identify the exact reference op and map it to the equivalent ggml expression with matching parameters.
Symptoms: values are close but systematically offset, and error grows with depth.
Common causes: missing residual add, wrong conformer macaron 0.5
scale, residual applied before vs after norm, or the wrong tensor used as
the skip source.
Fix: trace every + in the reference block and verify the same
operands and scales in C++.
Symptoms: attention output diverges with head-specific structure: some heads may look clean while others are scrambled.
Common causes: Q/K/V projection layout mismatch, wrong head-splitting
axis, Q/K transpose order error, or wrong scale factor such as
1/sqrt(d_model) instead of 1/sqrt(d_head).
Fix: dump Q, K, V projections separately from attention scores and attention output.
Symptoms: encoder/decoder tensors upstream of MHSA match cleanly, but the very first attention block output diverges by a large constant factor (seen at ~16x on the first encoder block, growing exponentially with depth). Disabling flash-attention, head-dim padding, or tweaking the attention scale leaves the drift unchanged because the bug is orthogonal to those.
Common cause: wrong mode argument to ggml_rope_ext. ggml has two
incompatible rotation pairings:
GGML_ROPE_TYPE_NORMAL(=0) — interleaved / GPT-J style. Rotation pairs are(0, 1), (2, 3), (4, 5), .... Reference'srotate_halfslicesx[..., 0::2]/x[..., 1::2], andapply_rotary_pos_embexpands cos/sin viarepeat_interleave(2, dim=-1).GGML_ROPE_TYPE_NEOX(=2) — split-halves / GPT-NeoX style. Rotation pairs are(0, D/2), (1, D/2+1), .... Reference'srotate_halfslicesx[..., :D//2]/x[..., D//2:], and cos/sin are built viacat((emb, emb), dim=-1).
The two modes produce numerically completely different attention even though the rotation angles per position are identical.
Fix: before writing the C++ MHSA, open the reference's rotate_half
function (and the cos/sin assembly in apply_rotary_pos_emb) and pick
the matching mode. The rotate_half slicing pattern is the deciding
signal. src/arch/moonshine/encoder.cpp (interleaved → NORMAL) and
src/arch/qwen3_asr/decoder.cpp (split-halves → NEOX) are the
in-tree examples of the two modes. Different families inside the same
HF library make different choices — do not assume one based on another.
Symptoms: early decoder tensors are close, but attention output or logits diverge sharply; errors may appear only at padded positions or after a specific decode step.
Common causes: wrong causal mask, wrong cross-attention mask, incorrect encoder lengths after subsampling, off-by-one position IDs, RoPE offset error, relative-position bucket mismatch, or prompt length not included in cache positions.
Fix: dump masks, position IDs, and effective lengths when possible. For autoregressive models, compare prompt pass and first single-token step separately.
Symptoms: full prompt logits match, but incremental decoding diverges after the first generated token.
Common causes: cache stored in the wrong layout, cache position advanced incorrectly, prompt tokens reprocessed or skipped, cross-cache recomputed differently, or attention mask not adjusted for cached tokens.
Fix: validate the no-cache prompt path first, then dump one-step decode Q/K/V and cache slices.
Symptoms: embeddings are not near-exact, first decoder tensor is wrong, or transcripts differ despite clean encoder tensors.
Common causes: different special token IDs, missing language/task prompt, wrong BOS/EOS handling, tokenizer file drift, vocabulary order changed during export, or a tied embedding/head weight mismatch.
Fix: dump token IDs and token embeddings. Embedding lookups should be near-exact; if they are not, debug tokenizer and weight loading before decoder layers.
Symptoms: audio-LLM decoder layers diverge immediately after the fusion point.
Common causes: wrong placeholder token count, off-by-one audio length calculation, injection at wrong positions, wrong mask positions, or audio features inserted before the expected projection/norm.
Fix: dump the token embedding sequence before and after injection. The post-injection tensor should contain audio features exactly at placeholder positions and token embeddings everywhere else.
Symptoms: post-log-softmax tensors contain -inf or produce infinite
element-wise diffs, while raw logits are close.
Common causes: implementation computes log(softmax(x)) and softmax
underflows, masked positions are carried through as infinities, or small
raw-logit differences move a very low-probability token across the
underflow boundary.
Fix: use pre-softmax logits as the numerical gate. Post-softmax or log-prob tensors may be dumped for visibility, but if they use infinite tolerance, they must not be the only output-head gate.
Symptoms: tensor diffs are within tolerance, but the selected token or transcript differs.
Common causes: two logits are nearly tied, tie-breaking differs, temperature/sampling is enabled in one path, timestamps or blank tokens are handled differently, or beam/greedy settings differ.
Fix: compare raw logits at the decision step and record the top-k tokens. Validation should use deterministic decoding.
Symptoms: the C++ implementation faithfully mirrors the understood reference path, but the reference uses a family-specific behavior not covered by the current op mapping.
Common causes: custom positional encoding, non-standard masking, streaming-state behavior, hidden prompt construction, special timestamp rules, or framework hooks that alter inference behavior.
Fix: stop guessing. Capture the finding in the family note or porting log, then decide whether to extend the graph, converter, or validation contract.