Title
RotaryPositionalEncoding::forward corrupts values for length-1 sequences with non-zero offset (incremental decode)
Body
Summary
nn::RotaryPositionalEncoding::forward produces incorrect (not just imprecise — qualitatively wrong) output when called on a 4D [B, heads, L, head_dim] tensor with L == 1 and a non-zero offset, i.e. exactly the shape used by every single-token step of incremental/streaming decoding after the first. Full-sequence calls (L > 1, offset == 0, e.g. prefill) are unaffected.
Root cause
The forward impl does:
fn forward(&mut self, input: Input) -> Result<Self::Output, Self::Error> {
let RopeInput { x, offset } = input.into();
let shape = x.shape();
let x = x.reshape(&[-1, x.dim(-2), x.dim(-1)])?; // collapse [B, heads, L, head_dim] -> 3D
let x = crate::fast::rope(x, self.dimensions, self.traditional, self.base, self.scale, offset, None)?;
x.reshape(shape) // restore original shape
}
When x is the result of .reshape(...).transpose_axes(&[0, 2, 1, 3]) (the standard "split heads" pattern — non-contiguous in memory), this collapse-then-restore reshape silently scrambles values specifically when L == 1 and offset != 0. Calling fast::rope directly on the original 4D tensor (no reshape) produces correct output.
I don't have a minimal Metal-kernel-level explanation for why the reshape breaks specifically at L=1, offset≠0 (rather than erroring, or breaking uniformly) — happy to help narrow it down further if useful, but wanted to file this now since it's a silent-corruption bug (no panic, no shape mismatch, plausible-looking output) that's easy to hit and hard to detect for anyone doing GQA/MQA incremental decoding with a growing KV cache (i.e. any autoregressive transformer decoder).
How I found it
Porting a Voxtral speech model, I hit a bug where the model got stuck predicting one fixed token forever regardless of audio content — every intermediate tensor (encoder output, embeddings, prefill logits) matched a Python/MLX reference almost exactly, but generation diverged starting at the second decode step. Bisecting by dumping tensor stats (mean/min/max) at each stage in both implementations isolated it to immediately before/after the RoPE call in the decoder's self-attention: pre-rope Q/K matched the reference almost exactly, post-rope did not.
Repro sketch
use mlx_rs::nn::{RopeBuilder, Module};
use mlx_rs::fast;
let head_dim = 128;
let mut rope = RopeBuilder::new(head_dim).traditional(true).base(1_000_000.0).build()?;
// q: [1, n_heads, 1, head_dim] — the shape used at any decode step after the first
let offset = 39; // some non-zero position
let via_wrapper = rope.forward(mlx_rs::nn::RopeInputBuilder::new(&q).offset(offset).build()?)?;
let via_raw = fast::rope(&q, head_dim, true, 1_000_000.0, 1.0, offset, None)?;
// via_wrapper and via_raw differ significantly (not float-rounding-level) for this shape;
// they agree when L > 1 or offset == 0.
Workaround
Bypass the wrapper, call the raw op directly on the un-reshaped 4D tensor:
let q = mlx_rs::fast::rope(&q, head_dim, /* traditional */ true, rope_theta, /* scale */ 1.0, offset, None)?;
Environment
mlx-rs git rev f4aa309c (main branch)
- macOS 26.6, Apple M-series (Metal backend)
- Reproduced consistently across multiple test audio files, not a one-off flake
Happy to provide a smaller standalone repro / write a regression test if useful.
Title
RotaryPositionalEncoding::forwardcorrupts values for length-1 sequences with non-zero offset (incremental decode)Body
Summary
nn::RotaryPositionalEncoding::forwardproduces incorrect (not just imprecise — qualitatively wrong) output when called on a 4D[B, heads, L, head_dim]tensor withL == 1and a non-zerooffset, i.e. exactly the shape used by every single-token step of incremental/streaming decoding after the first. Full-sequence calls (L > 1,offset == 0, e.g. prefill) are unaffected.Root cause
The
forwardimpl does:When
xis the result of.reshape(...).transpose_axes(&[0, 2, 1, 3])(the standard "split heads" pattern — non-contiguous in memory), this collapse-then-restore reshape silently scrambles values specifically whenL == 1andoffset != 0. Callingfast::ropedirectly on the original 4D tensor (no reshape) produces correct output.I don't have a minimal Metal-kernel-level explanation for why the reshape breaks specifically at
L=1, offset≠0(rather than erroring, or breaking uniformly) — happy to help narrow it down further if useful, but wanted to file this now since it's a silent-corruption bug (no panic, no shape mismatch, plausible-looking output) that's easy to hit and hard to detect for anyone doing GQA/MQA incremental decoding with a growing KV cache (i.e. any autoregressive transformer decoder).How I found it
Porting a Voxtral speech model, I hit a bug where the model got stuck predicting one fixed token forever regardless of audio content — every intermediate tensor (encoder output, embeddings, prefill logits) matched a Python/MLX reference almost exactly, but generation diverged starting at the second decode step. Bisecting by dumping tensor stats (mean/min/max) at each stage in both implementations isolated it to immediately before/after the RoPE call in the decoder's self-attention: pre-rope Q/K matched the reference almost exactly, post-rope did not.
Repro sketch
Workaround
Bypass the wrapper, call the raw op directly on the un-reshaped 4D tensor:
Environment
mlx-rsgit revf4aa309c(main branch)Happy to provide a smaller standalone repro / write a regression test if useful.