Skip to content

fix(engine): drain a cold auxiliary or speech load that fails after allocating - #146

Merged
magicnight merged 1 commit into
mainfrom
fix/aux-cold-load-drain
Oct 12, 2026
Merged

magicnight merged 1 commit into
mainfrom
fix/aux-cold-load-drain

Conversation

@magicnight

Copy link
Copy Markdown
Owner

A follow-up to #144 (noted in its review): the server's two auxiliary slots drained a failed load only when they had just released a resident engine (hadResident), while MLXSwiftEngine.load since #140 also drains when a model has loaded anywhere in the process (EngineMemory.hasLoadedAModel), the signal that Metal is up. A cold reranker or embedder load that fails after allocating, weights that do not match the model for instance (mlx-swift-lm's loadWeights reads and evaluates every tensor before update(parameters:verify:) rejects the set), left what it had read in MLX's cache, and nothing in the server drained it until a later swap, unload or stop, or the next plain generation's own clear. The audio engine's STT and TTS loads had the same shape (there a tokenizer or codec that fails after the weights were evaluated).

What changes:

  • The four catches (ensureEmbedderLoaded, ensureRerankerLoaded, the STT and TTS loads in AudioEngine) drain when hadResident || EngineMemory.hasLoadedAModel (the audio pair with their releasedResident), the chat engine's rule. A load that fails after allocating before any model has loaded in the process is still not drained, since Metal may not be up, and a load that fails before touching MLX never initializes Metal, which the SPM test job (no metallib) relies on.

Tests:

  • Gated, real checkpoints (MACMLX_RUN_RERANK_SMOKE=1): testAColdLoadThatFailsAfterAllocatingIsDrained builds a copy of ms-marco-MiniLM-L-6-v2 and one of BAAI/bge-small-zh-v1.5 with model.safetensors rewritten through MLX with one tensor renamed, loads the real embedder first (a model has loaded, so Metal is up, and the reranker slot is empty), asks for the broken reranker cold (a 500, nothing installed), checks through MLX's peak that the failed load allocated more than the bound before it failed (so the bound cannot pass vacuously if a later mlx-swift-lm verifies keys before evaluating), and checks MLX's cache is under 64 MiB afterwards, where the undrained read would leave the checkpoint's 91 MB; then unloads the embedder through the door and does the same through the embedder slot (96 MB). The audio pair is not driven by a test: STT loads only by repo id through the app's Hub cache, and no speech checkpoint is kept on this machine. A TTS case is possible (a local copy of a Qwen3 voice with a broken tokenizer.json fails in its post-load hook after the model is evaluated) but needs a gated download; the change there is the same one-clause rule, covered by review.
  • Mutation run, four mutants, one per catch with the rule reverted to hadResident / releasedResident: the reranker's and the embedder's are caught by the smoke (93.1 MB and 95.8 MB left in MLX's cache when the catch does not drain); the two audio mutants survive, as said above, and are covered by review. With the fix the smoke measured 0 B cached after each failed load (the embedder's 95.8 MB still active after the first, 20 B after the second).

…llocating

The server's reranker and embedder slots and the audio engine drained a
failed load only when they had just released a resident model, so a cold
load that failed after allocating (weights that do not match the model,
say) left what it had read in MLX's cache, and nothing in the server
drained it until a later swap, unload or stop, or the next plain
generation's own clear. They now drain under the chat engine's rule
(#136): whenever a model has loaded somewhere in the process, so Metal is
known to be up. A load that fails after allocating before any model has
loaded in the process is still not drained, and a load that fails before
touching MLX still never initializes Metal. A gated smoke loads an embedder, then asks for a reranker whose
weights were rewritten with one tensor renamed, which fails after the
weights were read and allocated, and checks the cache is near empty; then
the same through the embedder slot.
@magicnight

Copy link
Copy Markdown
Owner Author

Local pipeline on d38f957 (scripts/ci-local.sh, main's copy, CI_LOCAL_REF=d38f957), macOS 27.0.1, Xcode 27.0 (ci.yml pins 26.4.1; Actions minutes are still out, so this is the gate):

job result evidence
website passed
spm passed MacMLXCore 1,050 Swift Testing + 350 XCTest (45 skipped); macmlx-cli 28
metal passed 1,355 passed, 0 failed, 45 skipped of 1,400
app passed 39 passed; app resolved the MLX fork at 1026d23

Gated, same head: RerankEngineSmokeTests.testAColdLoadThatFailsAfterAllocatingIsDrained and the five residency smokes under xcodebuild with MACMLX_RUN_RERANK_SMOKE=1 passed (each failed load allocated more than the 64 MiB bound before it failed, by MLX's peak; after the failed cold reranker load MLX's cache held 0 B with the embedder's 95.8 MB still active, after the failed cold embedder load 0 B cached and 20 B active). Mutation run, same head: four mutants, one per catch with the rule reverted; the reranker's and the embedder's caught by the smoke (93.1 MB and 95.8 MB left in the cache without the drain), the two audio catches survive as expected, since no test drives a speech load that fails after allocating (review covers them).

Cursor Bugbot has reported "usage limit reached" since 2026-10-06 and did not review this PR.

@magicnight
magicnight merged commit 69254fa into main Oct 12, 2026
@magicnight
magicnight deleted the fix/aux-cold-load-drain branch October 12, 2026 05:23
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