Bound the MLX buffer cache instead of letting it track the machine - #227
Merged
Conversation
MLX caches every freed buffer in a multimap keyed by exact byte size, reuses one only within min(2*size, size + 2*page_size) of the request -- effectively an exact match above ~32 KiB -- and defaults its cache limit to the size of the machine. A graph whose message length varies therefore mints a permanent size class per length. Measured on a 30 kHz feature chain seeing 40 distinct lengths over an hour: 6015 MiB of physical footprint, none of it visible in RSS, because Metal buffers are IOKit allocations rather than malloc'd pages. AsArray caps the cache when it converts TO MLX. A limit rather than periodic clearing because eviction is LRU: the steady-state shape is re-touched every message and stays at the head while one-off shapes from a stall fall to the tail and are freed first. The hot allocation measured 0.97x after 39 rare shapes were evicted past a 128 MiB limit, and 128-512 MiB runs slightly faster than unbounded for less memory pressure. Only 0 hurts, at -40%. It is set in _process rather than the Unit's initialize() for two reasons: the transformer is also used bare, and set_cache_limit does not survive a spawn, so it has to run in whichever process actually converts rather than the one that built the graph. chunked_scan now concatenates the padded chunks and trims once, so its intermediates land on the multiple-of-chunk_size grid instead of at n_samples -- 31% less cached memory over 40 distinct lengths, in isolation. That is the smaller half of the fix: chain-wide it is worth ~8%, because most of the cost is the message-shaped arrays in stages that never call chunked_scan at all. Trimming once is sound only because padding is confined to the final chunk, which the loop now asserts rather than leaving implicit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
MLX caches every freed buffer in a
std::multimapkeyed by exact byte size, andreuse_from_cacheaccepts a candidate only withinmin(2 * size, size + 2 * page_size)of the request.2 * page_sizeis 32 KiB, so above that the2 * sizebranch never wins and it degenerates to a near-exact match. Measured against a cached 3000x256 f32 buffer on mlx 0.32.1:So a graph whose message length varies mints a permanent new size class per length, in a cache whose default limit is the size of the machine (23347 MiB on a 24 GB host).
This is easy to miss because RSS does not show it. Metal buffers are IOKit allocations, not malloc'd pages. On a 30 kHz feature chain over one simulated hour, seeing 40 distinct message lengths,
phys_footprintwent 421 -> 6015 MiB while RSS fell 384 -> 119 MiB.Growth is a staircase — one permanent step per never-before-seen shape — so a least-squares slope understates it badly. Compare first-vs-last.
It is driven by shape diversity, not shape size. Same chain, constant message length:
mlx_cachefootprintThe fix:
AsArraySettings.mlx_cache_limit_mb(default 512.0)Applied only when converting to MLX.
A limit rather than periodic
clear_cache()because eviction is LRU: the steady-state shape is re-touched every message and stays at the head of the list, while one-off shapes from a stall fall to the tail and are freed first. So the limit evicts precisely the size classes worth losing and keeps the one worth keeping. Verified — after 39 rare shapes blow past a 128 MiB limit, the hot 300-sample allocation runs at 0.97x its former speed, i.e. unaffected.20 min stream, stalls every 300 messages, 256 ch:
512 MiB256 MiB128 MiB0clear_cache()~60 sclear_cache()~6 sclear_cache()~1 s128-512 MiB is faster than unbounded (less memory pressure), so the limit costs nothing. Only
0hurts. Periodic clearing is strictly dominated on both axes and needs a policy besides.Two implementation notes:
_process, not the Unit'sinitialize(). The transformer is also used bare (offline chains, benchmarks), andset_cache_limitdoes not survive a spawn — verified: a spawned child reports the 23347 MiB default even when the parent set 256 MiB. It has to run in whichever process actually converts, not the one that built the graph.The smaller half:
chunked_scanpadded concatchunk_sizesbounds the set of Metal kernel specializations; it does nothing for buffer size classes, because the scan then undoes it withy_chunk[:, :valid]per chunk andmx.concatenate(...)at exactn_samples. Now it concatenates the padded chunks and trims once, so intermediates land on the multiple-of-chunk_sizegrid.Worth 31% in isolation (EWMA core, 400 messages, 40 distinct lengths: 503 -> 349 MiB). Worth about 8% chain-wide (6206 -> 5683 MiB), because most of the cost is message-shaped arrays in stages that never call
chunked_scan. Keeping it because it is free and correct, not because it is load-bearing.Trimming once is sound only because padding is confined to the final chunk: every earlier iteration has
remaining > chunk_size, sovalid == chunk_sizeand the chunk is emitted whole. That is an implicit property of the size-selection rule, so the loop now asserts it rather than trusting the next edit to preserve it.Combined
Full chain, 20 min, stalls every 300 messages: 6206 -> 938 MiB, a 6.6x reduction. The limit is doing nearly all of it.
What this does not do
It bounds the cache; it does not reduce the number of shapes. Bounding that means capping message length at the source (e.g. a
max_batchesonWindow), which is deliberately not in this PR — it would hide the growth from pipelines that never window at all.A padded-message convention across a whole MLX segment (pad at the conversion in, carry
valid_length, trim at the conversion out) would be the real fix and measured -79%, but it was rejected as unsafe: a new edge added to a live graph could bypass the trimming conversion and silently process padding as data.Testing
4096 passed, 6 skipped. New:
tests/unit/test_mlx_metal_common.py(exact-length contract across size sets and lengths, the pad-only-on-tail invariant, and a cache comparison against an inline copy of the pre-change implementation with identical held-live inputs — comparing against varying inputs instead measures input diversity, which this change does not address, and fails at 17.7x); plusAsArraycases intests/unit/test_asarray.pycovering the default, the numpy-target no-op, idempotency, the conflict warning, and that the cache is genuinely capped under size churn.Judgment call worth a second opinion
The default is
512.0, notNone. A library silently setting a process-global on first use can surprise, but aNonedefault only helps people who already know the knob exists — and nobody does, because the growth is invisible in RSS. The accepted risk: large-batch offline MLX work sharing the process gets a 512 MiB cap where it had unlimited, which could thrash. Called out in the docstring. Trivial to flip to opt-in.All measurements are from an in-process synthetic harness on an M-series host (no ezmsg graph, no SHM), so the throughput column reflects allocator cost rather than live scheduling.