Skip to content

Harden #1903's load-time MTP warmup: drain deadline, gate-busy skip, lease pinning#1929

Draft
mimeding wants to merge 1 commit into
osaurus-ai:mainfrom
mimeding:mlx-perf/warmup-hardening
Draft

Harden #1903's load-time MTP warmup: drain deadline, gate-busy skip, lease pinning#1929
mimeding wants to merge 1 commit into
osaurus-ai:mainfrom
mimeding:mlx-perf/warmup-hardening

Conversation

@mimeding

@mimeding mimeding commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

Post-merge hardening for #1903's load-time MTP warmup, remediating three defects found in adversarial review of the merged code:

  1. 30 s drain deadline — the warmup's stream drain could hang forever on a wedged generation while holding the process-wide cold-load slot, blocking every other model's load with no unblocking path. The drain now races a deadline (structured task group — safe here because AsyncStream iteration is cancellation-responsive); on timeout the generation task is cancelled, the warm flag reset, and the load proceeds without MTP warm. A 2-token warmup completes in <2 s on any supported hardware; 30 s means wedged.
  2. Gate-busy skip — at maxBatchSize == 1 the warmup queued on the global SoloGenerationGate while the cold-load slot was held: under multi-model, loading model A during model B's long generation stalled A's entire load (and any other pending load) for B's full response. SoloGenerationGate.tryAcquire (never enqueues) + a soloGateBehavior: .skipIfBusy seam on generate — a busy gate skips the warmup entirely, deferring to the original first-request AR warmup without consuming the warm flag (the throw happens before consumption by construction). The seam shape avoids both the probe-then-call deadlock and the release-then-call TOCTOU.
  3. Lease pinning — the warmup ran after modelCache publication and loading-record removal but before the caller's lease, so unload/strict-evict could tear the container down mid-warmup. finishLoadedContainer now holds a ModelLease across the warmup.

Also corrects the kill-switch doc command to the app's actual defaults domain (com.dinoki.osaurus — the previously documented ai.osaurus domain is never read, making the documented rollback inert).

Why

#1903 is merged and its failure paths are production paths. All three defects share a trigger class this codebase documents extensively (wedged Metal generations, multi-model concurrency) and had no recovery story.

Measurement

8 tests green: tryAcquire semantics (free→lease, busy→nil, no gate-state disturbance, queued waiters unaffected) and the drain deadline (completes on finished stream; times out at deadline on an injected never-ending stream). The pre-existing warmup flag tests stay green; behavior for the common single-model case is unchanged (gate free → warmup runs exactly as today).

Risk & rollback

Strictly narrows failure modes: every new path (timeout, busy-skip) degrades to #1903's original fallback (first-request AR warmup). Revert = one commit.

🤖 Generated with Claude Code

…ase pin

Post-merge hardening for osaurus-ai#1903 warmup, fixing three verified defects in
MLXBatchAdapter.warmupNativeMTPAtLoad and its finishLoadedContainer call
site:

- No timeout: "for await _ in prepared.stream {}" could hang forever on
  a wedged generation while finishLoadedContainer held the process-wide
  cold-load slot. The drain now races a 30-second deadline (a 2-token
  greedy warmup completes in <2 s on any supported hardware; 30 s means
  something is wedged). On timeout: cancel prepared.genTask, reset the
  registry warm flag, log a notice, and return — the load proceeds
  without MTP warm and the first request falls back to AR warmup.

- Global solo-gate contention: at maxBatchSize == 1 the warmup's
  generate queued on the process-wide SoloGenerationGate, so under
  manualMultiModel loading model A during model B's long generation
  stalled A's entire load. SoloGenerationGate gains a non-queuing
  tryAcquire(modelName:) -> Lease?, and generate gains a
  soloGateBehavior parameter (.waitForTurn default / .skipIfBusy). The
  warmup path uses .skipIfBusy: when the gate is held, generate throws
  SoloGateBusyError before consumeNativeMTPColdWarmup runs, so the
  warmup is skipped without consuming the warm flag and the first real
  request performs the AR warmup as before.

- Lease-less warmup window: finishLoadedContainer publishes modelCache
  and removes the loading record before awaiting the warmup, so an
  unload / strict-single-model eviction could tear the container down
  under the in-flight warmup generation. The model is now pinned with
  ModelLease.shared.acquire(name) across the warmup and released on
  every path (warmupNativeMTPAtLoad never throws; the timeout path
  returns normally).

Also corrects the escape-hatch doc comment ("defaults write ai.osaurus
...") to the app's actual bundle id domain com.dinoki.osaurus.

New SoloGateWarmupHardeningTests covers tryAcquire semantics (free ->
lease, busy -> nil, failed probe leaves queued waiters unaffected) and
the drain deadline via an injected never-ending stream; existing
NativeMTPWarmupFlagTests stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mimeding
mimeding marked this pull request as draft July 18, 2026 22:53
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