Skip to content

CompilerCache::find is O(n) over per-constant entries — degrades decode linearly when compiled fns receive changing scalar args #3964

Description

@aidiffuser

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)

  1. Hash the constants blob into a map (or bucket by hash) instead of the
    linear memcmp scan.
  2. Move-to-front on hit — decode's access pattern is monotonic, so even MTF
    makes the common case O(1)-ish.
  3. A cache-size cap / LRU eviction to bound the memory growth.
  4. (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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions