Summary
mx.compile keys its cache on non-array (scalar) arguments. Each distinct
scalar value appends a new entry to the compiled function's entry vector, and
detail::CompilerCache::find locates entries by a linear scan with memcmp
over the constants blob. When user code passes a per-step-changing scalar
(the classic case: a KV-cache offset during LLM decode), per-call lookup cost
grows linearly with the number of distinct values seen — and the entries are
retained forever, so host memory grows without bound too.
Real-world impact
mlx-lm's deepseek_v4 (PR ml-explore/mlx-lm#1189) passes the cache offset as
a python int into three @mx.compile'd helpers. Measured on an M-series Mac
(mlx 0.32.0.dev, Metal):
- decode tok/s decays linearly with generated tokens: 28.8 → 17.7 by 6k
generated (~4.3 µs/token added per generated token; ~43 layers × 3 calls).
sample profiles across the same run: CompilerCache::find + memcmp
grow from 12.4% → 28.6% of decode-thread wall time between ~token 2k
and ~7k. GPU-side leaves shrink proportionally — pure CPU lookup cost.
- likely the mechanism behind mlx-lm#1332 (unbounded residency growth during
DeepSeek-V4 decode) — ~3 entries retained per generated token, forever.
The user-side fix is passing a 0-d mx.array instead of the int (verified:
byte-identical outputs, flat decode). But the failure mode is easy to hit and
hard to diagnose — the API accepts the int silently and everything is correct,
just quadratically slower over a generation.
Suggested remedies (any one helps)
- Hash the constants blob into a map (or bucket by hash) instead of the
linear memcmp scan.
- Move-to-front on hit — decode's access pattern is monotonic, so even MTF
makes the common case O(1)-ish.
- A cache-size cap / LRU eviction to bound the memory growth.
- (Cheapest) a debug-mode warning when one compiled function accumulates
N constant-keyed entries — points users straight at the offending arg.
A 20-line repro (one compiled function called in a loop with an incrementing
int) shows the growth cleanly; the full sample profiles and the script are
sitting ready — ask and they're attached.
Related: we previously reported the reduction bug in #3784; same test setup
(2× Mac Studio, Metal, long-context LLM workloads).
Co-authored with Claude Fable 5 (Anthropic)
Summary
mx.compilekeys its cache on non-array (scalar) arguments. Each distinctscalar value appends a new entry to the compiled function's entry vector, and
detail::CompilerCache::findlocates entries by a linear scan with memcmpover the constants blob. When user code passes a per-step-changing scalar
(the classic case: a KV-cache offset during LLM decode), per-call lookup cost
grows linearly with the number of distinct values seen — and the entries are
retained forever, so host memory grows without bound too.
Real-world impact
mlx-lm's
deepseek_v4(PR ml-explore/mlx-lm#1189) passes the cache offset asa python int into three
@mx.compile'd helpers. Measured on an M-series Mac(mlx 0.32.0.dev, Metal):
generated (~4.3 µs/token added per generated token; ~43 layers × 3 calls).
sampleprofiles across the same run:CompilerCache::find+memcmpgrow from 12.4% → 28.6% of decode-thread wall time between ~token 2k
and ~7k. GPU-side leaves shrink proportionally — pure CPU lookup cost.
DeepSeek-V4 decode) — ~3 entries retained per generated token, forever.
The user-side fix is passing a 0-d
mx.arrayinstead of the int (verified:byte-identical outputs, flat decode). But the failure mode is easy to hit and
hard to diagnose — the API accepts the int silently and everything is correct,
just quadratically slower over a generation.
Suggested remedies (any one helps)
linear memcmp scan.
makes the common case O(1)-ish.
A 20-line repro (one compiled function called in a loop with an incrementing
int) shows the growth cleanly; the full
sampleprofiles and the script aresitting ready — ask and they're attached.
Related: we previously reported the reduction bug in #3784; same test setup
(2× Mac Studio, Metal, long-context LLM workloads).
Co-authored with Claude Fable 5 (Anthropic)