Summary
MLXNN.RoPE (which dispatches into MLXFast.RoPE) on a [B, H, 1, D] input where row 0 == row 1 produces row-asymmetric output. The same op on [B, H, T, D] with T > 1 is row-symmetric. The same op with B = 1 is deterministic and correct.
This blocks any straightforward [B,1,H] batched autoregressive decode in mlx-swift — every decoder layer's attention applies RoPE per step at T=1, and the resulting per-row drift compounds through the layers fast enough that argmax tokens diverge within a few decode steps.
Minimal reproducer (no model, no quantization)
import MLX
import MLXNN
import MLXRandom
MLXRandom.seed(42)
let rope = MLXNN.RoPE(dimensions: 128, traditional: false, base: 1_000_000)
// Row-symmetric input: row 0 byte-equal to row 1.
let baseRow = MLXRandom.normal([1, 8, 1, 128]) // [1, H, T=1, D]
let input = concatenated([baseRow, baseRow], axis: 0) // [2, H, T=1, D]
// Sanity: input rows are identical.
let i0 = input[0..<1, 0..., 0..., 0...].asArray(Float.self)
let i1 = input[1..<2, 0..., 0..., 0...].asArray(Float.self)
let inputDiff = zip(i0, i1).map { abs($0 - $1) }.max() ?? 0
assert(inputDiff == 0)
let output = rope(input, offset: 0)
let r0 = output[0..<1, 0..., 0..., 0...].asArray(Float.self)
let r1 = output[1..<2, 0..., 0..., 0...].asArray(Float.self)
let rowDiff = zip(r0, r1).map { abs($0 - $1) }.max() ?? 0
print("RoPE B=2 T=1 offset=0 row-diff: \(rowDiff)")
// Expected: 0.0
// Actual: 5.379... (varies by seed but always non-zero)
Diagnostic matrix
| Shape |
Op |
Offset |
row 0 vs row 1 maxDiff |
[2, 1, 1024] |
quantizedMatmul (4-bit, group 64) |
— |
0.0 ✓ |
[2, 148, 1024] |
quantizedMatmul (4-bit, group 64) |
— |
0.0 ✓ |
[2, 8, 1, 128] |
qProj → reshape → RMSNorm → transpose |
— |
0.0 ✓ |
[2, 8, 1, 128] |
RoPE |
0 |
5.38 ✗ |
[2, 8, 1, 128] |
RoPE |
148 |
7.52 ✗ |
[2, 8, 2, 128] |
RoPE |
148 |
0.0 ✓ (T>1 control) |
[1, 8, 1, 128] |
RoPE (called twice) |
148 |
0.0 ✓ (B=1 deterministic) |
The trigger is B > 1 AND T = 1 — independent of offset, independent of preceding ops (the bug shows up on a freshly constructed tensor that never went through quantizedMatmul or RMSNorm).
Why it matters
This silently corrupts any batched autoregressive decode loop in mlx-swift, because:
- Decoder forward pass at decode step has shape
[B, H, T=1, D] (one new query per row).
- RoPE is applied to every layer's Q and K at
offset = current_cache_length.
- The per-row drift introduced by RoPE compounds through ~28 layers per step.
- Argmax over the LM head picks different tokens for different rows within a few steps even when rows started identical.
Surface symptom: feeding two identical chunks through a batched decode loop produces correct output for row 0 and a truncated / different output for row 1.
We hit this in soniqo/speech-swift PR ml-explore/mlx-swift-examples#453's experimental [B,1,H] batched decoder for Qwen3-ASR. With B=2 and two identical 10s audio chunks, row 0 transcribes the full sentence and row 1 stops after the first few tokens. Cache layout (per-step concatenated vs. mlx-lm-style pre-allocated BatchKVCache) does not change the symptom — both fail in the same way because the upstream cause is RoPE itself.
What we ruled out
- ✅ Determinism: same op, same input, run twice → byte-identical (so it's not a kernel-level race).
- ✅ Cache layout: mlx-lm-style
BatchKVCache (pre-allocated, indexed-slice writes) does not fix it.
- ✅ Quantization: triggers without any quantized op — pure RoPE on a freshly constructed tensor reproduces.
- ✅ Position offset:
offset = 0 reproduces (~5.4 row diff), so it's not specific to non-zero positions.
- ✅
T > 1 is fine: same op at T = 2 is row-symmetric.
- ✅
B = 1 is fine: same op at B = 1 is deterministic and matches per-row B=2 row 0.
Probable cause
A T = 1 fast path in MLXFast.RoPE's Metal kernel that doesn't broadcast / iterate the batch dim correctly. Apple's mlx core changelog mentioned "No copy batch rope" recently — possibly related.
Workaround we ship
Per-row B=1 decoder forwards (no kernel-level B amortisation). Acceptable for our 11% throughput win on token-sync batching, but blocks the larger speedup that true [B,1,H] would unlock.
Environment
- macOS 26.4 (Tahoe), arm64, Apple M5 Pro
- Xcode 17 stable, Swift 6.2
- mlx-swift
0.31.3 (revision 61b9e01)
- Reproducible against
MLXNN.RoPE and MLXFast.RoPE (RoPE wraps Fast)
Cross-references
- mlx-lm
BatchKVCache — Python pattern for batched generation. Likely also affected upstream once mlx-swift-lm wires up batched decode with quantized models.
- "The Hidden Problem With MLX" — documented batch invariance failures in floating-point Metal kernels; this finding extends the picture: also
RoPE at T=1, also affects models with quantized weights when surrounded by bf16 RoPE.
Summary
MLXNN.RoPE(which dispatches intoMLXFast.RoPE) on a[B, H, 1, D]input where row 0 == row 1 produces row-asymmetric output. The same op on[B, H, T, D]withT > 1is row-symmetric. The same op withB = 1is deterministic and correct.This blocks any straightforward
[B,1,H]batched autoregressive decode in mlx-swift — every decoder layer's attention applies RoPE per step atT=1, and the resulting per-row drift compounds through the layers fast enough that argmax tokens diverge within a few decode steps.Minimal reproducer (no model, no quantization)
Diagnostic matrix
[2, 1, 1024]quantizedMatmul(4-bit, group 64)[2, 148, 1024]quantizedMatmul(4-bit, group 64)[2, 8, 1, 128]qProj → reshape → RMSNorm → transpose[2, 8, 1, 128]RoPE[2, 8, 1, 128]RoPE[2, 8, 2, 128]RoPE[1, 8, 1, 128]RoPE(called twice)The trigger is
B > 1ANDT = 1— independent ofoffset, independent of preceding ops (the bug shows up on a freshly constructed tensor that never went through quantizedMatmul or RMSNorm).Why it matters
This silently corrupts any batched autoregressive decode loop in mlx-swift, because:
[B, H, T=1, D](one new query per row).offset = current_cache_length.Surface symptom: feeding two identical chunks through a batched decode loop produces correct output for row 0 and a truncated / different output for row 1.
We hit this in soniqo/speech-swift PR ml-explore/mlx-swift-examples#453's experimental
[B,1,H]batched decoder for Qwen3-ASR. WithB=2and two identical 10s audio chunks, row 0 transcribes the full sentence and row 1 stops after the first few tokens. Cache layout (per-stepconcatenatedvs. mlx-lm-style pre-allocatedBatchKVCache) does not change the symptom — both fail in the same way because the upstream cause isRoPEitself.What we ruled out
BatchKVCache(pre-allocated, indexed-slice writes) does not fix it.offset = 0reproduces (~5.4 row diff), so it's not specific to non-zero positions.T > 1is fine: same op atT = 2is row-symmetric.B = 1is fine: same op atB = 1is deterministic and matches per-row B=2 row 0.Probable cause
A
T = 1fast path inMLXFast.RoPE's Metal kernel that doesn't broadcast / iterate the batch dim correctly. Apple's mlx core changelog mentioned"No copy batch rope"recently — possibly related.Workaround we ship
Per-row
B=1decoder forwards (no kernel-level B amortisation). Acceptable for our 11% throughput win on token-sync batching, but blocks the larger speedup that true[B,1,H]would unlock.Environment
0.31.3(revision61b9e01)MLXNN.RoPEandMLXFast.RoPE(RoPE wraps Fast)Cross-references
BatchKVCache— Python pattern for batched generation. Likely also affected upstream once mlx-swift-lm wires up batched decode with quantized models.RoPEatT=1, also affects models with quantized weights when surrounded by bf16 RoPE.