You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
DeepSeek-V4 (Flash/Pro) on Apple Silicon: unbounded Metal residency growth during decode → [metal::malloc] Resource limit (499000) exceeded after ~11K tokens #1332
On Apple Silicon, the DeepSeek‑V4 port (#1192) aborts generation with RuntimeError: [metal::malloc] Resource limit (499000) exceeded after ~11,300 generated tokens, independent of prompt length. After the first failure the Metal command queue is wedged until a full process restart. This is the "looping / long‑context" failure several testers have hit in the #1192 thread. Root cause is an unbounded per‑decode‑step leak of live Metal buffers in the attention caches. A fix is in Blaizzy#25.
Apple M4 Max, 128 GB, macOS 26.3 (also reproduced on M3 by others in the thread, ~4000 tokens)
mlx-community/DeepSeek-V4-Flash-2bit-DQ
Reproduce
Server (mlx_lm.server --max-tokens 65536), then stream a long generation — it breaks at ~11,314 tokens:
importjson, urllib.requestbody= {"model": MODEL, "stream": True, "max_tokens": 20000, "temperature": 0.0,
"messages": [{"role":"user","content":"Count from 1 to 20000, one number word per line."}]}
# ... stream and count delta tokens; the connection drops at ~11,314 with the server-side OOM
In‑process (no server, converges in <1 min) — measures the leak rate directly:
importmlx.coreasmxfrommlx_lmimportloadfrommlx_lm.models.cacheimportmake_prompt_cachemodel, tok=load(MODEL); cache=make_prompt_cache(model); MB=1024**2y=mx.argmax(model(mx.array(tok.encode("Count upward:\n"))[None], cache=cache)[:, -1], -1); mx.eval(y)
base=mx.get_active_memory() /MBforsinrange(500):
y=mx.argmax(model(y[:, None], cache=cache)[:, -1], -1); mx.eval(y)
ifs%100==0: print(s, f"{mx.get_active_memory()/MB-base:+.1f} MB")
# active memory grows ~linearly, no plateau (~0.2 MB/step). Adding# `mx.eval([c.state for c in cache])` after the per-step eval flattens it to ~0.
Root cause
resource_limit (499000) is a count of live resident Metal buffers, not bytes (num_resources_ vs resource_limit_ in mlx/backend/metal/allocator.h, backed by the ResidencySet). No byte‑budget knob (set_memory_limit/set_wired_limit/set_cache_limit) affects it. At the OOM point only ~2–3 GB is leaked — it's the count that's exhausted.
The attention caches build un‑detached per‑step graphs.PoolingCache.update_and_fetch does self.pooled = mx.concatenate([self.pooled, px], axis=1) each step; pooled_N references pooled_{N-1} in its input graph, and the cache permanently holds the head, so every prior step's intermediate array — and its Metal buffer — stays resident. RotatingKVCache._update_in_place and the Batch* variants chain identically via sliced functional assignment. That's ~one retained buffer per layer per step → 499000 / 43 layers ≈ 11.3K steps, matching the observed failure point.
Realizing the network output (mx.eval(token)) does not detach those intermediate cache arrays; explicitly evaluating the cache state each step collapses the growth (~205 → ~7 KB/step). Standard in‑place KVCache writes don't hit this, which is why other models don't.
It is not a memory‑size problem, not a single‑forward‑pass / per‑command‑buffer problem, and not prompt‑cache‑length driven (a 58‑token prompt OOMs at the same step count).
Full analysis + the diagnostic path (ablation, force‑eval, ruling out mx.compile/RoPE/expert‑paging): full writeup. PR with the fix: Blaizzy#25.
ArraysCache.advance() leaks one Metal buffer handle per token per un-drained cache
mlx_lm.server dies with [metal::malloc] Resource limit (499000) exceeded during decode on
hybrid/linear-attention models. resource_limit is a COUNT of live Metal resources, not bytes —
memory is flat while it happens.
N is a Python int, so -= N materialises a fresh scalar mx.array operand eagerly, then
builds an unevaluated subtract node on top of it. The node keeps that operand reachable, so it is
never freed, and mx.clear_cache() cannot reclaim it (it drains the free pool, and these are
live). One retained 4-byte buffer per call.
Isolated without any model (mlx 0.31.2, 20,000 iterations):
pattern
bytes/step
x = x - 1, never evaluated
4.0
x = x - one (pre-evaluated array operand), never evaluated
0.0
x = x - 1, mx.eval(x) each step
0.0
Graph depth alone costs nothing; the retained eager operand is the leak. Both conditions are
necessary. An unevaluated x = x - 1 chain raises the byte-for-byte identical error at exactly
499,000 iterations.
Why only the batch/server path
left_padding is None unless something sets it, and both sites that set it are batch-only:
cache.py:604 — ArraysCache.__init__(size, left_padding=None) sets it when the caller
passes one; make_cache in the plain path never does
generate.py:848 — c.left_padding = mx.array(left_padding) in _make_cache, the batch
cache builder, applied to the ArraysCache instances the model returns
cache.py:709 — cache.left_padding = mx.array([0] * B) in ArraysCache.merge, on the
all-empty early-return branch, reached via GenerationBatch.__init__ → generate.py:1036 _merge_caches(...)
(The other left_padding = mx.array(...) sites at cache.py:936, 973, 1140, 1278 belong to BatchKVCache / BatchRotatingKVCache, which are not affected — see the asymmetry below.)
In stream_generate, make_cache builds ArraysCache(size=2) with no left_padding, so advance() is a total no-op. Measured: plain decode is flat over 16,000 tokens; batch decode
leaks from token one.
For a single-sequence request max_padding == 0, so finalize() — which would set left_padding = None (cache.py:681-683) — is skipped by the guard at generate.py:1150/1166,
and the array stays live for the whole completion.
Why only hybrid architectures
advance() is defined on ArraysCache and on no other cache class, and is invoked only from
linear/SSM layers (qwen3_5.py:198, inside GatedDeltaNet.__call__). A pure-attention model of
any depth constructs zero ArraysCache and executes zero advance() calls. Measured: a
48-layer pure-attention control at matched depth ran flat past 20,000 tokens.
Why the rate is 47, not 48
All 48 linear layers call advance(), but only oneleft_padding is ever read: qwen3_5.py:251 sets ssm_idx = 0, :269 builds the mask from cache[self.ssm_idx] alone, and :271-273 reuses that single mask for every linear layer. create_ssm_mask → make_mask
(cache.py:691-694) is the only reader. Cache 0's chain is therefore drained one step in arrears
through the logits; the other 47 accumulate.
47 handles/token against 499,000 less a ~2,100 baseline predicts a 10,572-token ceiling. Measured: 10,560, three runs (10,562 / 10,561 / 10,560). Agreement 0.12%.
Corroborating at a different layer count: Falcon-H1 with 24 ArraysCache crashed at 21,641
tokens; 499,000 / 23 = 21,696.
Why no existing eval catches it
ArraysCache.state (cache.py:624-626) returns self.cache only — left_padding and lengths
are outside it. So all four mx.eval([c.state for c in ...]) sites (generate.py:442, 584, 1161, 1169) miss them, and all four are prefill anyway. GenerationBatch._step
(generate.py:1320-1379) evaluates only mx.async_eval(self._next_tokens, self._next_logprobs, token_context) and mx.eval(inputs, self._current_logprobs). Nothing in the decode loop touches
cache metadata.
Note for anyone attempting the obvious fix: adding the eval of c.state to the decode loop does not work, precisely because left_padding is not in state. We measured that arm — it leaks
identically to the control.
The clearest view of it — to_batch_cache, generate.py:846-852
defto_batch_cache(c):
iftype(c) isKVCache:
returnBatchKVCache(left_padding) # purpose-built batch classelifisinstance(c, ArraysCache):
c.left_padding=mx.array(left_padding) # raw field assignment, returned as-isreturnc
A KVCache is CONVERTED to BatchKVCache, which includes left_padding in state and whose
rotating sibling pins it with mx.depends. An ArraysCache is not converted at all — it is
mutated in place and handed back. There is no BatchArraysCache. The class that acquires
per-step mutable batch metadata is the one class that never got a batch-aware implementation.
The asymmetry, stated as one sentence
ArraysCache is the only batch-capable cache class whose per-step mutable metadata is neither
included in state nor pinned with mx.depends. The sibling classes already have the guard:
BatchRotatingKVCache (cache.py:1203-1204 and 1256-1257) does self.keys = mx.depends(self.keys, (self.left_padding, self.offset)) under the literal comment # Make sure left_padding and offset are evaluated
Suggested fix
Any of, in order of locality — all inside cache.py:
Include left_padding and lengths in ArraysCache.state, and add a decode-loop flush,
since today the decode loop evaluates no cache state at all.
Mirror the mx.depends guard the batch KV caches already use, pinning the metadata into a
graph that is forced with the token.
mx.eval(self.left_padding) inside advance() — simplest, at the cost of one eval per layer
per step rather than one batched eval.
We deployed the equivalent of (3) as a batched per-step eval of left_padding/lengths in GenerationBatch._step. Measured over 6,000 tokens: headroom flat with no drop (control fell
450,000 → 150,000), and 22% faster wall-clock — the growing unevaluated graph costs time as
well as handles, which also explains the superlinear decode slowdown reported in these threads.
End-to-end, a single request now completes 14,000 tokens where 10,560 was previously fatal.
Scope
.advance( has call sites across models/ for: falcon_h1, granitemoehybrid, kimi_linear, lfm2,
lfm2_moe, mamba2, nemotron_h, plamo2, qwen3_next, qwen3_5. Every hybrid/SSM family served through
the batch path is exposed, scaled by its own linear-layer count. Fixing it in ArraysCache fixes
all of them at once.
Activity
docs(deepseek-v4): record upstream submission + add 300-req knowledge…
fix: materialize DeepSeek-V4 cache arrays
fix(M3): eval m3_idx_k after concat to break lazy-graph buffer leak
fp2s92d5pd-bit commentedon Aug 25, 2026
ArraysCache.advance()leaks one Metal buffer handle per token per un-drained cachemlx_lm.serverdies with[metal::malloc] Resource limit (499000) exceededduring decode onhybrid/linear-attention models.
resource_limitis a COUNT of live Metal resources, not bytes —memory is flat while it happens.
Environment. M5 Max, 128 GB, macOS 27.0, Python 3.14, mlx 0.31.2 (0.32.1 identical),
mlx-lm 0.31.3,
mlx-community/Qwen3.8-27B-{4,8}bit(qwen3_5, 64 layers, 48 linear).The mechanism
mlx_lm/models/cache.py:685-689:Nis a Pythonint, so-= Nmaterialises a fresh scalarmx.arrayoperand eagerly, thenbuilds an unevaluated subtract node on top of it. The node keeps that operand reachable, so it is
never freed, and
mx.clear_cache()cannot reclaim it (it drains the free pool, and these arelive). One retained 4-byte buffer per call.
Isolated without any model (mlx 0.31.2, 20,000 iterations):
x = x - 1, never evaluatedx = x - one(pre-evaluated array operand), never evaluatedx = x - 1,mx.eval(x)each stepGraph depth alone costs nothing; the retained eager operand is the leak. Both conditions are
necessary. An unevaluated
x = x - 1chain raises the byte-for-byte identical error at exactly499,000 iterations.
Why only the batch/server path
left_paddingisNoneunless something sets it, and both sites that set it are batch-only:cache.py:604—ArraysCache.__init__(size, left_padding=None)sets it when the callerpasses one;
make_cachein the plain path never doesgenerate.py:848—c.left_padding = mx.array(left_padding)in_make_cache, the batchcache builder, applied to the
ArraysCacheinstances the model returnscache.py:709—cache.left_padding = mx.array([0] * B)inArraysCache.merge, on theall-empty early-return branch, reached via
GenerationBatch.__init__→generate.py:1036 _merge_caches(...)(The other
left_padding = mx.array(...)sites atcache.py:936, 973, 1140, 1278belong toBatchKVCache/BatchRotatingKVCache, which are not affected — see the asymmetry below.)In
stream_generate,make_cachebuildsArraysCache(size=2)with noleft_padding, soadvance()is a total no-op. Measured: plain decode is flat over 16,000 tokens; batch decodeleaks from token one.
For a single-sequence request
max_padding == 0, sofinalize()— which would setleft_padding = None(cache.py:681-683) — is skipped by the guard atgenerate.py:1150/1166,and the array stays live for the whole completion.
Why only hybrid architectures
advance()is defined onArraysCacheand on no other cache class, and is invoked only fromlinear/SSM layers (
qwen3_5.py:198, insideGatedDeltaNet.__call__). A pure-attention model ofany depth constructs zero
ArraysCacheand executes zeroadvance()calls. Measured: a48-layer pure-attention control at matched depth ran flat past 20,000 tokens.
Why the rate is 47, not 48
All 48 linear layers call
advance(), but only oneleft_paddingis ever read:qwen3_5.py:251setsssm_idx = 0,:269builds the mask fromcache[self.ssm_idx]alone, and:271-273reuses that single mask for every linear layer.create_ssm_mask→make_mask(
cache.py:691-694) is the only reader. Cache 0's chain is therefore drained one step in arrearsthrough the logits; the other 47 accumulate.
47 handles/token against 499,000 less a ~2,100 baseline predicts a 10,572-token ceiling.
Measured: 10,560, three runs (10,562 / 10,561 / 10,560). Agreement 0.12%.
Corroborating at a different layer count: Falcon-H1 with 24
ArraysCachecrashed at 21,641tokens; 499,000 / 23 = 21,696.
Why no existing eval catches it
ArraysCache.state(cache.py:624-626) returnsself.cacheonly —left_paddingandlengthsare outside it. So all four
mx.eval([c.state for c in ...])sites (generate.py:442, 584, 1161, 1169) miss them, and all four are prefill anyway.GenerationBatch._step(
generate.py:1320-1379) evaluates onlymx.async_eval(self._next_tokens, self._next_logprobs, token_context)andmx.eval(inputs, self._current_logprobs). Nothing in the decode loop touchescache metadata.
Note for anyone attempting the obvious fix: adding the eval of
c.stateto the decode loop doesnot work, precisely because
left_paddingis not instate. We measured that arm — it leaksidentically to the control.
The clearest view of it —
to_batch_cache,generate.py:846-852A
KVCacheis CONVERTED toBatchKVCache, which includesleft_paddinginstateand whoserotating sibling pins it with
mx.depends. AnArraysCacheis not converted at all — it ismutated in place and handed back. There is no
BatchArraysCache. The class that acquiresper-step mutable batch metadata is the one class that never got a batch-aware implementation.
The asymmetry, stated as one sentence
ArraysCacheis the only batch-capable cache class whose per-step mutable metadata is neitherincluded in
statenor pinned withmx.depends. The sibling classes already have the guard:BatchKVCache.state(cache.py:990-995) returnsk, v, self.offset, self.left_paddingBatchRotatingKVCache(cache.py:1203-1204and1256-1257) doesself.keys = mx.depends(self.keys, (self.left_padding, self.offset))under the literal comment# Make sure left_padding and offset are evaluatedSuggested fix
Any of, in order of locality — all inside
cache.py:left_paddingandlengthsinArraysCache.state, and add a decode-loop flush,since today the decode loop evaluates no cache state at all.
mx.dependsguard the batch KV caches already use, pinning the metadata into agraph that is forced with the token.
mx.eval(self.left_padding)insideadvance()— simplest, at the cost of one eval per layerper step rather than one batched eval.
We deployed the equivalent of (3) as a batched per-step eval of
left_padding/lengthsinGenerationBatch._step. Measured over 6,000 tokens: headroom flat with no drop (control fell450,000 → 150,000), and 22% faster wall-clock — the growing unevaluated graph costs time as
well as handles, which also explains the superlinear decode slowdown reported in these threads.
End-to-end, a single request now completes 14,000 tokens where 10,560 was previously fatal.
Scope
.advance(has call sites acrossmodels/for: falcon_h1, granitemoehybrid, kimi_linear, lfm2,lfm2_moe, mamba2, nemotron_h, plamo2, qwen3_next, qwen3_5. Every hybrid/SSM family served through
the batch path is exposed, scaled by its own linear-layer count. Fixing it in
ArraysCachefixesall of them at once.
zcbenz commentedon Aug 27, 2026
I'm merging this to #1662.
Materialize cache arrays after each forward pass (Metal residency fix)