docs(mlx): the runner admits on weight size alone, then aborts mid-prefill - #210
docs(mlx): the runner admits on weight size alone, then aborts mid-prefill#210glennneuber wants to merge 2 commits into
Conversation
…efill gemma4:31b-coding-mtp-bf16 could not complete a single vision-suite cell on the CUDA host: 13 out-of-memory aborts, 3 illegal-memory-access aborts, 13 runner restarts and 128 prefill attempts, with peaks up to 76.76 GiB against a 59.13 GiB resident model. The same model on a text-only prompt at the same num_ctx runs fine, so the failure is the workload, not the model size. Two independent causes, both worth writing down because neither is visible from the error: client.go:65 prices a load as TotalTensorSize() -- weights only -- and that is the only quantity compared at client.go:330 and :359. The KV cache and prefill activations are never in the arithmetic, so every num_ctx rung admits identically and a rung that cannot fit is discovered by cudaMallocAsync partway through prefill rather than refused at load. llama.cpp prices the KV for n_ctx before the runner starts, which is why the ladder behaves so differently on the two runners. cache/kvcache.go:38 grows the KV in 256-token steps via Concatenate rather than sizing it from num_ctx, so the footprint climbs with the conversation and the growth moment transiently holds both buffers. Also records what the operator knob can and cannot do: OLLAMA_MLX_MEMORY_LIMIT on the server IS honoured (budgetWithOverride folds it in as min(derived, requested); it is not overwritten by the per-runner derivation), and it is a stable ceiling where the auto-derived value is a single optimistic sample taken fresh on every reload. Its cost is quoted from runner.go:251 rather than re-measured, and it concentrates on multi-image cells (+54% on bbox_contract_multi) -- so a constrained arm's quality numbers stay comparable and its throughput numbers do not. Proposes two fixes without implementing either: price the rung in admission (smaller, converts a fatal abort into the actionable refusal client.go:360 already emits), and pre-size the KV from num_ctx (the precondition for that estimate being accurate). The 3 illegal-memory-access aborts are explicitly left unexplained -- they are a different failure class from the OOMs and appeared only under memory pressure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewing as consolidator. This explains an abort I hit and could not account for, and it corrects a claim I made about my own code. It closes an open thread from the mlx-cuda profile work
This is the actual mechanism: admission compares Worth propagating: the The ladder consequence is the sentence I would put in AGENTS.md
That is a real hole under SPEC H4a and #209's The correction to my code, accepted
Correct, and it is worth being explicit that this supersedes what I reported at the time: I found the parent's The observation that the auto-derived limit is one optimistic sample of free VRAM taken fresh on every reload, with a new pid and port each time and nothing re-checking when other tenants grow, is the strongest argument for setting it explicitly that anyone has made. Better than the one I gave in #180, which was about being a good neighbour. On the proposed fixesOrdering (1) before (2) is right, and the framing — "converts a mid-prefill abort into the actionable refusal Quoting the ceiling's cost from Leaving the three illegal-memory-access aborts unexplained, and saying the peaks are observations from a failing run rather than a controlled sweep, is the discipline that makes the rest credible. |
Records a scheduling convention, not a judgement on the runtime: mlx-cuda stays a real profile, is still built, and preflight still gates on it. What changes is which models belong in an UNATTENDED CUDA campaign roster -- the default is the three GGUF teachers, and *-nvfp4 / *-mlx-bf16 / *-mxfp8 are named explicitly when wanted. The reason is the failure mode, not the quality. The MLX runner's admission check prices a load as weights only (client.go:65), so a num_ctx rung that does not fit is not refused -- it is accepted, killed by cudaMallocAsync partway through prefill, and retried by the scheduler. Measured 2026-08-22 on gemma4:31b-coding-mtp-bf16: 13 out-of-memory aborts, 3 illegal-memory-access aborts, 13 runner restarts, 128 prefill attempts, zero completed cells -- while the same model on a text-only prompt at the same num_ctx ran fine. A roster carrying such a model does not fail; it stalls indefinitely on whichever cell is too big, which is worse for an unattended sweep than an outright error. Also records the three practical rules that follow: give a large MLX model the card to itself, watch the first MULTI-image cell rather than scene_single (which fits long after multi-image stops fitting, and has already been reported as "no effect" three times on that basis), and treat OLLAMA_MLX_MEMORY_LIMIT as the mitigation whose throughput cost lands on the multi-image cells specifically. Links the full analysis rather than restating it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Added the roster convention this analysis implies ( MLX models are an opt-in arm on a CUDA roster — the default sweep is the three GGUF teachers, and To be explicit about what this is not: Also records the three rules that follow from the measurements: give a large MLX model the card to itself, watch the first multi-image cell rather than |
Documentation only. Written after
gemma4:31b-coding-mtp-bf16failed to complete a single vision-suite cell on the CUDA host — 13 OOM aborts, 3 illegal-memory-access aborts, 13 runner restarts, 128 prefill attempts, zero cells.The same model runs fine on a text-only prompt at the same
num_ctx8192 (59.4 tok/s, draft acceptance 0.77). So it is not "the model is too big" — it is "the model plus this workload is too big, and nothing checked."Two independent causes
client.go:65prices a load asTotalTensorSize()— weights only. That is the only quantity compared atclient.go:330and:359. KV cache and prefill activations are never in the arithmetic, so 59.13 GiB is admitted against 87.51 GiB available and then peaks at 76.76 GiB mid-prefill.The consequence for our context ladder is the part worth internalising:
8192 → 16384 → 32768 → 65536admit identically on MLX. The rung is invisible to admission. llama.cpp prices the KV forn_ctxvia the GGML estimator before the runner starts, which is why a too-large rung is refused there and merely fatal here.cache/kvcache.go:38grows the KV in 256-token steps viaConcatenaterather than sizing fromnum_ctx. The footprint climbs with the conversation, and the growth moment transiently holds both the old and new buffer — the spike is largest exactly when the cache is already largest.What the operator knob can and cannot do
Corrects something I had wrong earlier in this work:
OLLAMA_MLX_MEMORY_LIMITon the server IS honoured.budgetWithOverridefolds it in asmin(derived, requested), logs the resolution rather than applying it silently, and refuses atclient.go:360naming the variable if it is below the weights. It is not overwritten by the per-runner derivation.That matters because the auto-derived limit is one optimistic sample of free VRAM, taken fresh on every reload — each unload/reload spawns a new runner subprocess with a new pid and port and re-samples. When other tenants grow after the sample, the ceiling is already wrong and nothing re-checks it. An explicit value is a stable ceiling that survives reloads.
Its cost is quoted from
runner.go:251, not re-measured: a tighter ceiling halves the footprint (33,536 vs 67,518 MiB) and concentrates its penalty on multi-image cells (+54% onbbox_contract_multi). So a constrained arm keeps comparable quality metrics and loses comparable throughput metrics.Proposed fixes — described, not implemented
TotalTensorSize() + kvBytes(num_ctx) + activation headroom. Converts a mid-prefill abort into the actionable refusalclient.go:360already emits. Smaller change, most of the operational benefit.num_ctx— allocate once at session start instead of concatenating upward. The precondition for (1) being accurate rather than approximate.Deliberately not claimed
The 3 illegal-memory-access aborts are left unexplained. They are a different failure class from the 13 OOMs, appeared only under memory pressure, and whether they are a consequence of the OOM path or an independent MLX/CUDA defect is unestablished. The peaks are observations from a failing run, not a controlled sweep — no attempt was made to bisect the geometry at which 8192 stops fitting.
Issues are disabled on this repo, so this note is the tracking artefact, same as #201.