Fatal error: [broadcast_shapes] Shapes (1,2,335,256) and (1,2,292,256) cannot be broadcast.
Two MLXArrays with incompatible sequence dimensions (335 vs 292) combined in the computation graph. The 292 is a stale pre-trim array that should have been replaced.
| Scenario | Requests Before Crash | Time | Crash Shapes |
|---|---|---|---|
| Stress test (7 users) | 59 | ~10 min | (1,2,695,256) vs (1,2,524,256) |
| Reproducer run 1 | 107 | ~2 min | (1,2,43,256) vs (1,2,35,256) |
| Reproducer run 2 | 12 | ~15 sec | (1,2,332,256) vs (1,2,325,256) |
| Reproducer run 3 | 7 | ~9 sec | (1,2,335,256) vs (1,2,292,256) |
KVCacheSimple.trim() only decrements offset — it does NOT physically slice self.keys or self.values:
// KVCache.swift lines 429-433
public override func trim(_ n: Int) -> Int {
let trimmed = min(offset, n)
offset -= trimmed // Only this changes. self.keys untouched.
return trimmed
}
After restoring a cached state with 292 tokens and trimming to effectivePrefix=97:
During the forward pass, update() writes new suffix tokens starting at position 97. Since 97 + suffix_len ≤ 292 (capacity sufficient), no reallocation happens. The in-place subscript assignment creates a new lazy MLX graph node that depends on the original shape-292 array. When MLX's scheduler runs operations that reference this stale shape alongside the expected shape (97 + suffix), the shapes don't match → broadcast_shapes crash.
Round-trip through the state getter (slices to offset) and setter (replaces self.keys/self.values with the sliced result):
// After trim, physically truncate arrays
for i in 0..<generationCache.count {
if generationCache[i].isTrimmable && generationCache[i].offset > 0 {
generationCache[i].state = generationCache[i].state
}
}
This replaces self.keys (shape [1,2,292,256]) with self.keys[..., 0..<97, ...] (shape [1,2,97,256]). The stale pre-trim array is fully dereferenced from the computation graph.
The crash required a specific chain of conditions to all be true simultaneously. Any condition failing meant the request succeeded normally:
effectivePrefix. The radix cache stores state after full generation (prompt + output tokens), so stored_len = prompt + generated (e.g., 97 + 195 = 292). This was almost always true.KVCacheSimple.update() uses a 2x growth strategy. When effectivePrefix + suffix_len > stored_len, it allocates a fresh array and copies only valid data — the stale pre-trim array is dereferenced and the bug doesn't manifest.max_tokens=64 → fast cycling, more requests per minute, more chances to triggerisTrimmable guard — skip MambaCache restore
arr.eval() after trim — materialize lazy slices
arr.eval() on the state getter's lazy slices materializes those slices, but doesn't replace the underlying self.keys/self.values that update() uses directly. The stale full-size arrays persist in the graph.layer.state = layer.state — round-trip getter → setter
self.keys to [..., 0..<offset, ...]. Setter assigns the sliced copy back as self.keys and derives offset from its shape. Stale pre-trim array fully dereferenced from computation graph.| Test Run | Requests | Errors | Duration | RPS | Config |
|---|---|---|---|---|---|
| Fix 3 — run 1 | 200 | 0 | 116s | 1.72 | isTrimmable guard + state round-trip |
| Fix 3 — run 2 | 500 | 0 | 291s | 1.72 | isTrimmable guard + state round-trip |
| Fix 3 — run 3 (guard removed) | 500 | 0 | 299s | 1.67 | state round-trip only (final fix) |
Run 3 confirmed the isTrimmable guard (fix 1) was unnecessary — the state round-trip alone is sufficient. Removing the guard restores MambaCache prefix caching for 30/40 layers.
None measurable. The state round-trip only runs on cache restore (not every request). It replaces one set of arrays with sliced copies — a lightweight operation (<1ms) compared to the model forward pass (500-700ms). No graph recompilation is triggered because the operation uses the same getter/setter code path that MLX already optimizes for.
| File | Change |
|---|---|
Sources/MacLocalAPI/Models/MLXModelService.swift |
Add layer.state = layer.state after trim in both streaming (~line 841) and non-streaming (~line 482) restore paths |
Generated 2026-03-11 | maclocal-api | Reproducer: Scripts/repro-prefix-crash.py | Stress test: Scripts/stress-test-7users.py