Skip to content

fix(server): release the resident reranker or embedder before loading its replacement, then drain MLX's cache - #133

Merged
magicnight merged 7 commits into
mainfrom
fix/engine-swap-order
Oct 4, 2026
Merged

magicnight merged 7 commits into
mainfrom
fix/engine-swap-order

Conversation

@magicnight

@magicnight magicnight commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Partially addresses #130 (the cheap half; the pool-budget half stays open).

ensureRerankerLoaded and ensureEmbedderLoaded loaded the replacement engine and only then let the old one go, so both models were resident for the duration of the load. Both now release the current engine first, drain MLX's buffer cache through a new EngineMemory.releaseCachedBuffers() (so HummingbirdServer.swift keeps no MLX import), then load. A failed load therefore leaves no reranker or embedder resident; the next request for the previous model reloads it.

Measured

Same probe as in #130 (real HummingbirdServer, Memory.snapshot() after each /v1/rerank call), two model ids pointing at the same Qwen3-Reranker-0.6B-4bit weights, then a swap to ms-marco:

step before: active / cache / peak after: active / cache / peak
load 0.6B 320 / 291 / 591 MB 320 / 291 / 591 MB
swap to the same weights under a second id 320 / 611 / 639 MB 320 / 291 / 591 MB
swap back 320 / 611 / 639 MB 320 / 291 / 591 MB
swap to ms-marco 87 / 932 / 639 MB 87 / 5 / 591 MB

The 639 MB peak is the two 320 MB copies side by side; after the change the same serial probe never rises above its first request's 591 MB high-water mark. The 4B bf16 numbers from #130 (7.66 GB for one copy, 14.98 GB during a swap, 15.5 GB left in the cache) show the old order at scale; the 4B run was not repeated after the change.

Review rounds

  • Bugbot found that the already-loaded check suspends the actor, so a concurrent swap could empty the slot under a same-model request that then re-read it and got a 500. Second commit: ensureEmbedderLoaded / ensureRerankerLoaded return the engine for the request and the handlers never re-read the slot. This also closes the mirror race main had since v0.5.3 (embedder) and v0.9.0 (cross-encoder): a re-read after another request's swap finished could pick up the other model's engine and answer under the requested model's name. Recorded as a Fixed entry.
  • Critic: the swap ran outside the generation lock, so requests arriving during a swap would each load their own copy (including requests for the model being replaced), and two swaps could load at once. Third commit: the three handlers acquire the generation lock before the swap, as the chat model's handler always has (SRV-2); every early return releases it. The CHANGELOG now states exactly what was measured (serial requests; the 4B run was not repeated; the 591 MB figure is the first request's high-water mark) and says a chat generation waits behind a swap for the load's duration.
  • New gated smoke (ms-marco under two ids, then Qwen3-0.6B-4bit, then back; real server): one copy 90.9 MB active, peak during the same-weights swap 100.2 MB (asserted below 1.5 copies); after the swap back 9.4 MB left in MLX's cache (asserted below 64 MB). Two mutations each turn it red: load-then-assign order → swap peak 181.7 MB (two copies); release without the drain → 646.5 MB left in the cache. Full pure suite 971/101 green; no drain on a failed load, because a load that fails before touching MLX must not be the first thing to initialize Metal (the SPM test job has no metallib).

Third round

  • Lock contract tests (pure, swift test): a swap on /v1/rerank and on /v1/embeddings queues while a generation holds the lock and releases it after a failed load; the embeddings 404 and 400 do not wait for the lock. Four mutations each turn the expected test red: swap moved before the acquire, either failure path leaking the lock, the kind gate moved under the lock. A regression here is a server-wide deadlock, which is why the critic asked for these before merging.
  • /v1/embeddings resolves the model and checks its kind before the lock, as the reranker path already did, so an unknown model or a chat model is answered at once even during a generation; ensureEmbedderLoaded takes the resolved target, the cosine branch reuses the one it had, and one loadFailureResponse maps a failed swap for all three handlers.
  • A failed load drains the cache only when this call had just released a resident engine (so Metal is known to be up); a load that fails before touching MLX never initializes it, which the SPM test job depends on.
  • Merged the new main (fix(detect): classify sequence-classification heads by what MLXRerankers can score, never as embedders #132): both ### Fixed entries sit under one header; the two test files keep both sides' additions. On the merged head the full pure suite is 980/102 green and all five gated smokes pass (ms-marco 1.9e-6 vs PyTorch, NLI 7.4e-6 relative, Qwen3 ordering, endpoint, swap order 100.2 MB peak against 90.9 MB one copy and 9.4 MB left in the cache).

… its replacement, then drain MLX's cache

Partially addresses #130.

ensureRerankerLoaded and ensureEmbedderLoaded loaded the new engine and only
then dropped the old one, so both models were resident for the duration of
the load. Measured on a real server: a second Qwen3-Reranker-4B (bf16) took
the peak to 14.98 GB against 7.66 GB for one copy; with the 0.6B 4-bit
checkpoint a swap peaked at 639 MB where one copy is 320 MB. Both functions
now release the current engine first and drain MLX's buffer cache through a
new EngineMemory helper (the server keeps no MLX import), then load. Same
probe after the change: the peak stays at 591 MB across the swaps, and after
the last one the cache holds 5 MB instead of 932 MB.

A failed load now leaves no engine resident; the next request for the
previous model reloads it. The engines are still outside ModelPool's byte
budget, which is the remaining half of #130.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1f2c9c5. Configure here.

if embeddingEngine != nil {
embeddingEngine = nil
EngineMemory.releaseCachedBuffers()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swap 500s concurrent same-model requests

Medium Severity

Releasing embeddingEngine or rerankEngine before the replacement is assigned opens a nil window. A concurrent same-model request can pass the already-loaded check on a local current handle, then the handler re-reads the now-nil property and returns 500 load_failed for a model that was already resident.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f2c9c5. Configure here.

…ding the slot

Bugbot on #133: the already-loaded check awaits current.loadedModel, which
lets the actor interleave another request's swap; that swap now empties the
slot before its replacement is assigned, so a same-model request that passed
the check could re-read the slot, find nil and answer 500 for a model it had
just verified resident. The old order had the mirror image of the race: the
re-read could pick up a different model's freshly assigned engine and score
against the wrong model.

ensureEmbedderLoaded and ensureRerankerLoaded now return the engine for this
request (the verified current one, or the one they loaded) and the handlers
use that instance; ensureEmbedderOr404 carries it in an EmbedderSwap result.
The returned reference also keeps the model alive for the duration of the
request if a concurrent swap drops it from the slot, so a response always
comes from the model that was asked for.
…k, test the swap order on real checkpoints

Second review round on #133.

- The three handlers acquire the generation lock before the cold swap, as
  the chat model's handler always has (SRV-2). Swaps now serialize with each
  other and with generation: a request arriving during a swap waits instead
  of loading its own copy or holding a reference to the engine being
  replaced, and two swaps to different models can no longer load at once.
  Every early return after the acquire releases the lock.
- Gated smoke (ms-marco under two ids, then Qwen3-Reranker-0.6B-4bit, then
  back): the swap's peak stays below one and a half copies where the old
  order peaked near two, and after the swap back fewer than 64 MB remain in
  MLX's cache where the undrained path parks about 600 MB.
- CHANGELOG: the Changed entry states what was measured (serial requests;
  the 4B run was not repeated) and gains a Fixed entry for the wrong-model
  race the first round closed, present since v0.5.3 (embedder) and v0.9.0
  (cross-encoder).
- EngineMemory is internal; its comment no longer claims MLX never trims its
  cache. The POOL-5 analogy names what the pool has on top. The misplaced
  ensureEmbedderLoaded doc comment is back on its function.
- No drain on a failed load: a load that fails before touching MLX must not
  be the first thing to initialize Metal, which the SPM test job cannot do.
…ract of auxiliary swaps

Third review round on #133.

- /v1/embeddings resolves the model and checks its kind before taking the
  generation lock, as the reranker path already did, so an unknown model
  (404) or a chat model (400) is answered at once even while a generation
  runs; ensureEmbedderLoaded takes the resolved target and only swaps. The
  cosine branch of /v1/rerank reuses the target it had already resolved. One
  loadFailureResponse maps a failed swap for all three handlers.
- A failed load drains MLX's cache only when this call had just released a
  resident engine, which proves Metal is up; a load that fails before
  touching MLX still never initializes it.
- Three pure tests pin the lock contract: a swap on /v1/rerank and on
  /v1/embeddings queues while a generation holds the lock and releases it
  after a failed load; the embeddings 404 and 400 do not wait for the lock.
  Four mutations each turn the expected test red (swap moved before the
  acquire, either failure path leaking the lock, the kind gate moved under
  the lock). A regression here is a server-wide deadlock.
- CHANGELOG: a chat request on the server's single-stream path is what waits
  behind a swap; the swap runs under the lock as the chat model's does.
# Conflicts:
#	CHANGELOG.md
#	MacMLXCore/Tests/MacMLXCoreTests/Engine/RerankEngineSmokeTests.swift
#	MacMLXCore/Tests/MacMLXCoreTests/Server/HummingbirdServerEmbeddingsTests.swift
…; tidy two formatting nits

The third lock-held failure path of /v1/rerank (an .embedder model whose
load fails) now has the same queue-then-release test as the other two; a
missing release there would deadlock every later generation. Also drops a
doubled blank line and re-wraps one long CHANGELOG line.
@magicnight
magicnight merged commit be6e8b3 into main Oct 4, 2026
4 checks passed
@magicnight
magicnight deleted the fix/engine-swap-order branch October 4, 2026 05:55
magicnight added a commit that referenced this pull request Oct 4, 2026
… call, read the lock state in tests

Review of the first round found that taking the lock before the load also
put a model's first download under it: a multi-GB speech model fetched on
first use would have stalled every chat, embeddings and rerank request for
minutes, with nothing in the logs saying why. The handlers now fetch the
files first (AudioEngine.prepareSTT/prepareTTS, upstream's own resolve-or-
download step, files only) outside the lock, and only load and run under
it. A repo the Hub cannot deliver fails with 500 load_failed before the lock
is touched. Concurrent fetches of one repo share a single download, because
upstream clears a directory it finds incomplete and two downloads would
delete each other's files.

The app's view models cancel a superseded task and start the next one at
once, so the superseded call can still be inside the engine; the GUI's
AudioServicing now goes through the model-bound transcribe(model:) and
synthesize(model:) too, and the slot-reading transcribe(audioURL:) and
synthesize(text:) are gone. A swap drains MLX's buffer cache between the
two models, as the embedder and reranker swaps do (#130), which the
"same gate" comment claimed and the code did not do. The SpeechModelBox
doc now says what actually serializes synthesis (the generation lock on
the server; nothing in the app yet).

The Metal CI job runs the suite in parallel on a slow runner, where a
request round trip took close to 20 s and two "does not wait for the lock"
tests (one from #133) failed on a fixed 2 s bound. The tests now read the
server's lock state directly instead of racing an acquire against a timer,
and the bounded waits are 60 s with the lock held throughout, so only a
real wait-for-lock regression can fail them.
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