[CRITICAL] Model switch holds _model_switch_lock across 10-30s subprocess shutdown, serializing the entire inference queue (proxy.py:163-220) #41
Labels
No labels
blocked:lint-failed
blocked:needs-decision
blocked:no-changes
blocked:no-procedure
blocked:preflight
blocked:verify-failed
bug
feature-request
in-progress
lint-cleanup
ops-task
quickfix
security/high
security/medium
test-coverage
triage-approved
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Mark/vllm_mm#41
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
_model_switch_lockis held acrossprocess_manager.stop_modelandstart_model, which serialize the entire inference queue behind a single 10-30s model swap.Location
backend/app/routers/proxy.pylines 60-128 (InferenceQueueworker loop)backend/app/routers/proxy.pylines 163-220 (switch path inside_forward_request)backend/app/services/process_manager.pylines 412-421 (stop_modelgraceful shutdown)Bug
InferenceQueue._worker_loop(line 96) launchesmax_workers(default 4) worker tasks that all funnel throughasync with self._model_switch_lock:(line 163) whenever a request names a model that isn't currently active. Inside that critical section:await process_manager.stop_model(active.process_id)(line 179)await session.commit()(line 187, 227)await process_manager.start_model(...)(line 213)stop_modelblocks for up to 30 seconds on graceful termination (process_manager.py:412-421) before SIGKILL — and during those 30 seconds the other three workers in the inference queue are all blocked waiting on the sameasyncio.Lock. They cannot pick up requests, cannot forward to the still-warming model, and the queue fills tomax_queue_size(default 100) before requests start returning 503.For a vLLM 7B model on a single GPU this manifests as: a single
/v1/chat/completionscall requesting a model switch stalls the entire proxy for 15-45 seconds. Streaming requests through the same path are particularly bad because the client sees zero bytes until the lock is released.Reproduction
Suggested Fix
Reduce the lock scope. Hold
_model_switch_lockonly for the in-process active-model re-check and the DB flip (proxy.py:163-188), then release before callingstop_model/start_model. Use a separateis_starting: str | Nonefield (or second lock) so that:active.id == target_model.id) pass through with no lock contention.Concretely:
Workers checking the active model see
is_startingand either (a) wait on a per-model event, or (b) return 503 "model loading" withRetry-After.Severity
Critical — defeats the purpose of the inference queue under realistic model-swap workloads.
Detected
Code review of HEAD
e91deb0(2026-09-14).Closed by fix landed on
fix/41-model-switch-lock-scope(commit374736d).What changed:
_model_switch_lock(now_swap_inflight) was renamed and its scope narrowed: it now serializes only the short DB flip in two windows, not the entire stop/start subprocess shutdown. Between those windows,stop_model+start_modelrun with the lock released. Concurrent workers asking for the same target model share one load by waiting on a per-modelEventin_loading_events.Regression tests added under
TestModelSwitchLockScope+ existingTestSwapLockScope:test_lock_released_during_stop_model— observes lock state during stop_modeltest_concurrent_swap_waiters_share_one_load— exactly 1 start_model call for N concurrent requeststest_loading_slot_cleared_after_successful_swap— slot cleared on successtest_failed_start_clears_loading_slot— slot cleared on start failure (no hang)test_fast_path_no_lock_for_matching_active— fast path acquires no locktest_concurrent_different_targets_run_concurrently— different targets don't serializeFull backend test suite: 243 passed, 2 skipped.
Open follow-up: streaming /v1/chat/completions bypasses the queue entirely (proxy.py:939-953), so a model swap during a streaming request can still drop the SSE connection. That path should also surface a
Retry-After503 when the active model is being torn down.