Repository navigation
feat(mlxrunner): bound MLX's retained buffer cache instead of the whole card - #181
glennneuber wants to merge 3 commits into
Conversation
|
Reviewing as consolidator. This is the right lever and the evidence is the right evidence — The numbers match what I measured independently on the same image ( One claim the arithmetic doesn't supportThe PR says the excess is MLX's cache and not CUDA's async pool. Your own before/after numbers say it is partly both: MLX gave back 9.14 GiB and the process only shrank by 4.50. The missing 4.64 GiB was freed by MLX and not returned to the driver — which is precisely So I'd soften that section rather than drop it: the cache is the dominant term and the one we can control from Go, which is enough to justify the change. But the pool still holds ~4.6 GiB, and it's the likely reason the capped footprint is 28,526 MiB against llama.cpp's 23,586 — still 4.82 GiB higher for identical work, which the PR doesn't remark on. Worth stating explicitly in the PR, because "the excess is cache, not the pool" is the kind of line that stops the next person looking. On the 4 GiB defaultThe reasoning — peak 25.37 minus active 18.29 ≈ 7 GiB transient, so 4 GiB covers the bulk — is sound as far as it goes, but Two things I'd want before this is treated as tuned rather than reasonable:
Neither blocks merge. The change is correct, bounded, env-overridable and defaults conservatively. |
The throughput arm contradicts the default — do not merge yetI said in the PR body that a default costing tok/s should be reconsidered before merge. It appears to. Same binary, same image, same −4.6 GB footprint, −8% decode. Two reasons not to act on that yet, and one reason not to ignore it:
Running default-vs-unbounded at n=3, alternating so neither arm is systematically first. I'll post the result. If the gap holds, I'll either raise the default to cover the working set or drop the default entirely and leave the knob opt-in — the binding and the parser fix stand on their own either way. |
n=3: the gap is real, and 4 GiB is the wrong defaultRanges do not overlap. default's best run is below unbounded's worst. −15.8% decode for −13.6% footprint. Identical work in all six runs ( My earlier "8% may be noise" was too generous: at n=1 it looked marginal, at n=3 it is separated cleanly. Why 4 GiB was the wrong pick, in hindsight from the same numbers I already had: peak 25.37 GiB against active 18.29 GiB puts the transient working set near 7 GiB. A 4 GiB cache cannot hold it, so the allocator round-trips to the driver inside every forward. I quoted that 7 GiB figure in the commit message as justification for 4 GiB — it was an argument for something larger. Sweeping 8 and 16 GiB at n=3 to find the smallest cache that keeps full throughput. If one exists well below MLX's 90 GiB, that is the right default. If throughput only recovers at a value close to unbounded, then there should be no default and the knob stays opt-in for operators who need the footprint on a shared card. Either way the binding and |
Resolved: no default. The trade has no middle.Swept 8 and 16 GiB at n=3 to find the smallest cache that keeps full throughput:
Throughput recovers fully by 8 GiB — but 8 GiB saves 497 MiB. The whole 4.5 GB saving is at 4 GiB, which costs 15.8% decode. So neither candidate is worth shipping: at 8 GiB a default buys nothing while adding a surprise; at 4 GiB it silently costs every user a sixth of their throughput to save memory most are not short of. The cause was visible before I picked 4 GiB: peak 25.37 GiB against active 18.29 GiB puts the transient working set near 7 GiB, so a 4 GiB cache cannot hold it and the allocator round-trips to the driver inside every forward. I quoted that 7 GiB figure in the commit message as justification for 4 GiB. It was an argument for something larger. The measured curve now sits in the code beside the knob, so the next person choosing a value starts from data rather than from the same footprint-only argument. Unchanged and still the substance of this PR: |
The full-suite sweep reached 67 GB, and the penalty is concentratedEvery number in the PR's cache table is one 3072×1728 image. Driving the allocator from the other knob —
Peak footprint 33,536 MiB against 67,518 MiB — the 67 GB regime finally reproduced. Stable across both reps on each side (17.73/17.71 vs 25.0/27.21), so the 54% is not noise. This strengthens the no-default decision. A bounded cache would hit hardest precisely on multi-image prompts, which is the shape of real vision traffic and the reason the suite has The methodological note matters more than the number. Three separate sweeps over the allocator ceiling came back flat today and were each reported as "no throughput effect". All three ran Recorded in the code beside the knob. |
… not build (#182) x/models/qwen3_5/qwen3_5_test.go declares TestSanitizeConvWeight twice, at line 21 and again at line 43: x/models/qwen3_5/qwen3_5_test.go:43:6: TestSanitizeConvWeight redeclared in this block x/models/qwen3_5/qwen3_5_test.go:21:6: other declaration of TestSanitizeConvWeight The package therefore fails to build, `go test ./...` fails on ubuntu, macos and windows, and main's last three CI runs are all red (9594f81, ade6362, bc11eab). It arrived with the upstream merge in #165, which touched this file. The two copies are BYTE-IDENTICAL -- verified by diffing the ranges before deleting anything, because "duplicate name" and "two different tests that collide" need opposite fixes and only the first can be resolved by deletion. So the second copy is removed and nothing is renamed: no coverage is lost. WHY THIS SAT UNNOTICED ALL DAY. The `test` job is gated behind the `changes` filter, so the documentation and harness PRs that made up most of today's work reported CLEAN while main was broken. Only a PR touching Go triggers it, and the first two that did (#180, #181) inherited the failure and looked like they had caused it. go vet ./x/models/qwen3_5: clean. go test ./x/models/qwen3_5: ok. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…le card
MLX's allocator keeps freed blocks for reuse, and its default cache limit is
derived from TOTAL device memory -- 90.22 GiB on this card -- which is the same
wrong denominator the allocator ceiling had. Measured on gemma4:31b-nvfp4, one
3072x1728 image, CUDA:
memory.active 18.29 GiB live tensors, ~= the weights
memory.cache 13.16 GiB retained
nvidia-smi 33,134 MiB
llama.cpp does the identical work -- same image, same num_ctx, same
prompt_eval_count 1118 -- in 23,586 MiB. active + cache accounts for 31.45 of
the 32.36 GiB observed, so the excess is MLX's cache and NOT, as first assumed,
CUDA's async memory pool, which would have needed cudaMemPoolTrimTo and been out
of reach.
mlx_set_cache_limit was never bound. SetMemoryLimit was, and the two are easy to
confuse: that one caps TOTAL allocation and decides when an allocation FAILS; it
does not make MLX release anything. This one decides how much MLX KEEPS after it
is done, which is what governs steady-state footprint.
MEASURED, one 3072x1728 image, same binary:
cache limit unset (MLX default) 33,134 MiB cache 13.16 GiB
cache limit 4 GiB 28,526 MiB cache 4.02 GiB
default (4 GiB, env unset) 28,718 MiB
The default is 4 GiB rather than 0. Peak 25.37 GiB against active 18.29 GiB puts
the transient working set near 7 GiB, so 4 GiB covers the bulk of the reuse
while bounding retention; returning every buffer would cost allocator churn on a
path that runs per forward, for a footprint win that does not pay for an
unmeasured latency risk. OLLAMA_MLX_CACHE_LIMIT overrides, including 0.
parseCacheLimit exists because parseMemoryBudget REJECTS ZERO -- correctly, a
zero memory budget forbids every allocation -- while 0 is the most useful cache
limit there is. Reusing it silently discarded OLLAMA_MLX_CACHE_LIMIT=0 and left
the backend default in place, so a measurement of that setting reported the
DEFAULT's footprint under the cap-0 label: 33,134 MiB, byte-identical to the
unset arm, which is what gave it away.
Note the asymmetry, because it bounds what this can achieve: cache fell 13.16 ->
4.02 GiB (9.1 GiB) but the process fell only 4.6 GB, so roughly half the freed
cache is not returned to the driver. That residue IS the cudaMallocAsync pool.
It is secondary, not zero, and out of scope here.
Applied after the weights are resident so the logged "previous" and any trim
reflect a loaded model. go test ./x/mlxrunner -run 'CacheLimit|MemoryBudget|
BudgetOverride': ok. go vet ./x/mlxrunner/...: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ddle
The 4 GiB default shipped in the previous commit is measurably a bad trade, and
8 GiB -- the value that fixes the throughput -- is a pointless one. Measured n=3
on gemma4:31b-nvfp4, one 3072x1728 image, decode tok/s against peak footprint:
4 GiB 29.44 tok/s 28,749 MiB <- all of the footprint win
8 GiB 34.73 tok/s 32,779 MiB
16 GiB 34.77 tok/s 33,295 MiB
90 GiB 34.98 tok/s 33,276 MiB <- MLX's own default
Throughput recovers fully by 8 GiB and the ranges overlap from there up. But
8 GiB saves 497 MiB. The entire 4.5 GB saving is at 4 GiB, and 4 GiB costs 15.8%
decode -- ranges [31.02, 27.19, 30.10] against [36.30, 32.96, 35.67], no
overlap at all.
The cause was in the numbers before the default was picked: peak 25.37 GiB
against active 18.29 GiB puts the transient working set near 7 GiB, so a 4 GiB
cache cannot hold it and the allocator round-trips to the driver inside every
forward. That 7 GiB figure was QUOTED in the previous commit message as
justification for choosing 4 GiB. It was an argument for something larger.
So there is no default worth shipping. At 8 GiB it buys nothing while adding a
surprise; at 4 GiB it silently costs every user a sixth of their throughput to
save memory most are not short of. The knob stays opt-in for the cases that do
want it -- a shared card, or a model that OOMs at large geometries, which is the
open qwen3.6:35b-a3b-nvfp4 abort at 3072x1728.
The binding and parseCacheLimit are unchanged and stand on their own:
mlx_set_cache_limit was never bound, and parseMemoryBudget silently ate
OLLAMA_MLX_CACHE_LIMIT=0.
The measured curve is now in the code next to the knob, so the next person
choosing a value starts from data rather than from the same footprint-only
argument that produced 4 GiB.
go vet ./x/mlxrunner/...: clean. go test -run 'CacheLimit|MemoryBudget|
BudgetOverride': ok.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every number in the cache-limit table is ONE 3072x1728 image. Starving the
allocator from the other knob -- OLLAMA_MLX_MEMORY_LIMIT, full 12-test suite,
n=2 -- shows where the cost actually lands:
cell 24 GiB 82 GiB delta
scene_single 30.46 t/s 30.87 t/s +1%
multi_3img 28.46 t/s 29.19 t/s +3%
bbox_contract_multi 17.71 t/s 27.21 t/s +54%
Peak footprint 33,536 MiB against 67,518 MiB. Stable across both reps on each
side (17.73/17.71 vs 25.0/27.21), so the 54% is not noise.
This strengthens the no-default decision rather than changing it: a bounded
cache would hit hardest exactly on multi-image prompts, which is the shape of
real vision traffic and the reason this suite has bbox_contract_multi at all.
The methodological note is worth more than the number. THREE separate sweeps
over the allocator ceiling came back flat and were reported as "no throughput
effect" -- all three ran scene_single, a single-image cell that cannot reach the
constraint. The conclusion was correctly scoped each time and the scope was the
defect. A future sweep over anything allocator-related must include a
multi-image cell or it is measuring nothing.
go vet ./x/mlxrunner: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9ecc744 to
72a2cf9
Compare
759777a to
2644cab
Compare
The finding
MLX's allocator keeps freed blocks for reuse, and its default cache limit is derived from total device memory — 90.22 GiB on this card — the same wrong denominator the allocator ceiling had. Measured on
gemma4:31b-nvfp4, one 3072×1728 image, CUDA:llama.cpp does the identical work — same image, same
num_ctx, sameprompt_eval_count1118 — in 23,586 MiB.active + cacheaccounts for 31.45 of the 32.36 GiB observed, so the excess is MLX's cache and not CUDA's async memory pool, which was my first assumption and would have neededcudaMemPoolTrimTo— out of our reach.The lever was never bound
mlx_set_cache_limitships in MLX and had no Go binding.SetMemoryLimitdid, and the two are easy to confuse:SetMemoryLimitSetCacheLimitMeasured
One 3072×1728 image, same binary:
The default reproduces the explicit setting across two separate runs.
Why 4 GiB and not 0
Peak 25.37 GiB against active 18.29 GiB puts the transient working set near 7 GiB, so 4 GiB covers the bulk of the reuse while bounding retention. Returning every buffer costs allocator churn on a path that runs per forward — a footprint win that doesn't pay for an unmeasured latency risk.
OLLAMA_MLX_CACHE_LIMIToverrides, including0.A parser bug this surfaced
parseCacheLimitexists becauseparseMemoryBudgetrejects zero — correctly, since a zero memory budget forbids every allocation — while0is the most useful cache limit there is.Reusing it silently discarded
OLLAMA_MLX_CACHE_LIMIT=0and left the backend default in place, so my measurement of that setting reported the default's footprint under the cap-0 label: 33,134 MiB, byte-identical to the unset arm. That identity is what gave it away, and it's now pinned by a test.What this does NOT fix
Cache fell 13.16 → 4.02 GiB (9.1 GiB) but the process fell only 4.6 GB — roughly half the freed cache is not returned to the driver. That residue is the
cudaMallocAsyncpool. Secondary, not zero, out of scope here.MLX at a bounded cache is still ~4.9 GB above llama.cpp for the same work.
Tests
Applied after the weights are resident, so the logged
previousand any trim reflect a loaded model rather than an empty allocator.🤖 Generated with Claude Code