Skip to content

Fix DeepSeek-V4 Metal residency leak: materialize per-layer cache state each forward - #25

Open
snagnever wants to merge 1 commit into
Blaizzy:pc/add-deepseekv4flash-modelfrom
snagnever:fix/deepseek-v4-metal-residency-leak
Open

Fix DeepSeek-V4 Metal residency leak: materialize per-layer cache state each forward#25
snagnever wants to merge 1 commit into
Blaizzy:pc/add-deepseekv4flash-modelfrom
snagnever:fix/deepseek-v4-metal-residency-leak

Conversation

@snagnever

Copy link
Copy Markdown

Fixes the Metal residency leak in the DeepSeek‑V4 port that aborts generation with [metal::malloc] Resource limit (499000) exceeded after ~11K decode tokens on Apple Silicon (full root cause: ml-explore#1332).

Targeting: DeepSeek‑V4 isn't in main yet, so this PR targets the ml-explore#1192 head branch pc/add-deepseekv4flash-model (which is where mlx_lm/models/deepseek_v4.py lives). @Blaizzy — happy to adjust placement if you'd prefer to fold it into ml-explore#1192 directly.

Cause (short)

The attention caches build per‑decode‑step lazy graphs that are never detached: PoolingCache grows via mx.concatenate, RotatingKVCache via sliced assignment (and the Batch* variants likewise). Because the cache object holds the head of the chain, MLX keeps every prior step's intermediate array — and its backing Metal buffer — resident. The count of live buffers grows ~one per layer per step until it hits the device residency cap (resource_limit, 499000), ≈ 11.3K tokens regardless of prompt length, after which the command queue wedges until a process restart.

Change

Materialize all per‑layer cache state once per forward pass in DeepseekV4Model.__call__, which detaches the chains so the resident‑buffer count stays bounded. One mx.eval per forward (not per cache), so the cost is negligible.

        for layer, layer_cache in zip(self.pipeline_layers, cache):
            h = layer(h, mask, layer_cache, inputs)

        # DeepSeek-V4 Metal OOM fix: materialize all per-layer cache state each
        # forward pass. PoolingCache (concatenate-grow) and RotatingKVCache
        # (sliced assignment), single and batched, otherwise keep each prior
        # step's intermediate arrays -- and their Metal buffers -- resident, so
        # num_resources_ climbs to resource_limit (499000) after ~11.3K tokens.
        # One eval per forward keeps the live-buffer count bounded.
        if cache is not None:
            _cache_arrays = []
            for _c in cache:
                for _leaf in (getattr(_c, "caches", None) or (_c,)):
                    if _leaf is None:
                        continue
                    for _v in vars(_leaf).values():
                        if isinstance(_v, mx.array):
                            _cache_arrays.append(_v)
            if _cache_arrays:
                mx.eval(*_cache_arrays)

Verification (M4 Max 128 GB, mlx 0.31.2)

before after
in‑process leak slope ~205 KB/step (linear, no plateau) 7 KB/step
forced 20K‑token generation OOM at 11,314 clean to 19,989, 0 OOMs
throughput (long gen) ~31 tok/s 31.3 tok/s (no regression)
40‑request tool‑call sweep, single long‑lived server 49 OOMs, aborted at request 20 0 OOMs, 40/40, 19.8 min
knowledge‑bench soak (MMLU+GPQA+HumanEval, 300 requests), single long‑lived server 0 OOMs, 300/300, 0 errors, ~2h44m

Notes

  • Walking each leaf cache's array attributes (rather than a .state accessor) covers PoolingCache/RotatingKVCache/BatchPoolingCache/BatchRotatingKVCache uniformly; a cache‑class‑local fix is possible but is whack‑a‑mole across four classes (two shared with other models).
  • Open question for maintainers: is it intended that realizing the output doesn't detach intermediate cache arrays? A cleaner long‑term fix might be storing the pooled cache with step‑allocated in‑place writes (like KVCache/RotatingKVCache._update_in_place) instead of concatenate, removing the chain at the source.
  • (Unrelated heads‑up, not part of this fix: the 2‑bit DQ checkpoint degenerates/loops in open‑ended chat — a quantization‑quality issue independent of this leak. With this fix, such a loop merely runs to max_tokens instead of crashing.)

…te each forward

The attention caches (PoolingCache concat-grow, RotatingKVCache slice-assign,
and their Batch* variants) build un-detached per-decode-step lazy graphs, so MLX
keeps every prior step's intermediate array -- and its backing Metal buffer --
resident. The live-buffer count hits the Metal residency cap (resource_limit,
499000) after ~11.3K decode tokens regardless of prompt length, aborting with
`[metal::malloc] Resource limit (499000) exceeded`; the command queue then wedges
until a process restart.

Materialize all per-layer cache state once per forward pass in
DeepseekV4Model.__call__ to detach the chains, keeping the live-buffer count
bounded. One mx.eval per forward (not per cache); negligible cost, no throughput
regression. Verified on M4 Max 128GB / mlx 0.31.2: forced 20K-token generation
clean to 19,989 (was OOM at 11,314), 31.3 tok/s (no regression), and a 300-request
knowledge-bench soak on a single long-lived server with 0 OOMs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
snagnever added a commit to snagnever/macstudio-local-llm that referenced this pull request May 31, 2026
…-bench soak

Submitted the Metal residency-leak fix upstream:
- issue ml-explore/mlx-lm#1332 (root cause + reproducer + diagnostics)
- PR Blaizzy/mlx-lm#25 against the #1192 head branch
- heads-up comment on #1192 linking both

Adds the pre-submission soak result (MMLU+GPQA+HumanEval, 300 requests on a
single long-lived patched server: 0 metal::malloc, 0 errors, ~2h44m) to the
writeup + PR-draft verification tables, plus the soak driver script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jundot added a commit to jundot/omlx that referenced this pull request Jun 1, 2026
Detach DeepSeek-V4 cache update graphs after each forward pass to avoid Metal residency growth during long decode. Apply the same materialization to the MTP override path, including MTP cache updates.

This ports the fix from Blaizzy/mlx-lm#25, which addresses the DeepSeek-V4 Metal residency leak described in ml-explore/mlx-lm#1332. The upstream change materializes per-layer cache arrays so PoolingCache concatenation and RotatingKVCache slice updates do not retain prior lazy graph chains indefinitely.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant