Skip to content

RotaryPositionalEncoding::forward corrupts values for length-1 sequences with non-zero offset (incremental decode) #359

Description

@max-lt

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions