Skip to content

server: reload an MLX runner when num_ctx changes, either direction - #279

Merged
glennneuber merged 1 commit into
mainfrom
fix/mlx-reload-on-num-ctx
Sep 6, 2026
Merged

glennneuber merged 1 commit into
mainfrom
fix/mlx-reload-on-num-ctx

Conversation

@glennneuber

@glennneuber glennneuber commented Sep 6, 2026 •

Copy link
Copy Markdown

Closes the gap the main validation (step 1b, 2026-09-06) found in #276: needsReload compared runner options only for non-MLX models (upstream a16f9665), so a warm MLX runner served any later num_ctx without reloading and admission — which prices the rung at load — never saw a rung raise. Measured: 31b loaded at 8192 under a 35 GiB cap served a 65536 request in 0 s with no admission line; the cache then grows into memory nobody priced (#210's failure).

Change. An MLX runner reloads when the requested num_ctx differs from the served one, either direction — larger so admission prices it first, smaller so the memory comes back instead of the runner hogging the bigger window (the maintainer's refinement). GGML already behaves this way through its option comparison; a test pins both paths. The other runner options remain ignored for MLX as upstream intended, and two automatic rungs are still equalised first so unpinned clients don't churn. Because the MLX client may have clamped an automatic rung, the scheduler records llama.ContextLength() (the served rung) at creation when known and refreshes it after the load completes, so a request naming the served rung matches rather than reloading.

Tests. TestSchedNeedsReloadMLXOnNumCtxChange (same rung: no reload; larger: reload; smaller: reload; other options: no reload; auto+auto: no reload) and TestSchedNeedsReloadGGMLOnNumCtxChange. go test ./server/ ./x/mlxrunner/ green in golang:1.26.0, golangci-lint 0 issues. The post-load recording of the served rung is not unit-testable with the scheduler mock (the MLX branch constructs its client directly); it is covered by the live proof below.

Live proof (queued behind the running validation chain): one container under a 35 GiB cap: 31b@8192 admitted → @65536 must reload and be refused at admission → @8192 admitted again → @4096 reloads and /api/ps reports 4096 → @8192 twice, the second served warm. Control on main-a523d60b must show no reload at step 2. Results will be posted here.

🤖 Generated with Claude Code

needsReload compared runner options only for non-MLX models (upstream
a16f966), so a warm MLX runner served any later num_ctx without reloading.
That left #276's admission -- which prices the rung at load -- blind to a
rung raise: 31b loaded at 8192 under a 35 GiB cap served a 65536 request in
0 s with no admission at all (main validation, step 1b, 2026-09-06), and the
cache then grows into memory nobody priced, the very failure #210 describes.

An MLX runner now reloads when the requested num_ctx differs from the served
one: larger, so admission prices the new rung first; smaller, so the memory
comes back instead of the runner hogging the bigger window. That is what
GGML already does through its option comparison, pinned by a test so the
two paths cannot drift. The other runner options stay ignored for MLX, as
upstream intended, and two automatic rungs are still equalised before the
comparison so unpinned clients do not churn the runner.

The served rung is what gets compared: the MLX client only learns it after
the runner's status probe (an automatic rung may have been clamped), so the
scheduler records llama.ContextLength() at runner creation when known and
refreshes it once the load completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@glennneuber

Copy link
Copy Markdown
Author

Live proof (CUDA host, one container, 35 GiB cap, gemma4:31b-nvfp4; runner starts counted from the server log):

##### FIX: maxusai/ollama:reloadfix -> {"version":"0.33.3-dynres-11-gd9055b7"} 15:53:16
  1. 31b@8192  -> 200 452s ok 
  2. 31b@65536 -> 500 4s model requires 37.6 GiB but OLLAMA_MLX_MEMORY_LIMIT caps the MLX budget at 35.0 GiB | runner starts +0
  3. 31b@8192  -> 200 53s ok  | runner starts +1
  4. 31b@4096  -> 200 32s ok  | runner starts +1
  5. 31b@8192  -> 200 32s ok  | runner starts +1
  6. 31b@8192  -> 200 0s ok  | runner starts +0 (want +0: warm)
##### CONTROL (main, no fix): maxusai/ollama:main-a523d60b -> {"version":"0.33.3-dynres-10-ga523d60"} 16:03:07
  1. 31b@8192  -> 200 251s ok 
  2. 31b@65536 -> 200 1s ok | runner starts +0
  3. 31b@8192  -> 200 1s ok  | runner starts +0
  4. 31b@4096  -> 200 1s ok  | runner starts +0
  5. 31b@8192  -> 200 1s ok  | runner starts +0
  6. 31b@8192  -> 200 1s ok  | runner starts +0 (want +0: warm)

FIX (0.33.3-dynres-11-gd9055b7): 8192 admitted → 65536 refused at admission in 4 s, no runner started (model requires 37.6 GiB but OLLAMA_MLX_MEMORY_LIMIT caps the MLX budget at 35.0 GiB) → 8192 reloaded → 4096 reloaded (a smaller rung releases the bigger window) → 8192 reloaded → 8192 again served warm, no reload. CONTROL (main-a523d60b): every rung change served warm in ~1 s with zero runner starts, including 65536 under the cap that cannot hold it — the gap as found. Same rung never churns; a different rung always re-admits.

@glennneuber
glennneuber merged commit 38a930e into main Sep 6, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant