mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-19 17:06:43 +00:00
## Summary
Unsloth Studio could not promise an unattended server that no load
unloads another model, and a load that did unload something often went
unreported. This PR:
- turns **Keep multiple models loaded** into a mode: **Off** (default
for fresh installs), **When they fit** (`fit`, today's "on") and
**Always**, which refuses any load that would unload another chat, image
or video model;
- fixes a managed account's load reading its own `studio.db`, seeing the
setting as off, and unloading the owner's idle kept models;
- makes the GPU arbiter track every resident model instead of a single
owner, without changing behaviour under Off;
- reports every model a load unloads, including media freed by a chat
load and chat models freed by an image or video load.
The frontend Select and toast copy come in a follow-up PR; this one is
backend only.
## What changes
**Setting** (`utils/multi_model_settings.py`, `routes/settings.py`)
- `get_multi_model_mode()` returns `off | fit | always`. It is read in
the owner's database (`run_as(OWNER)`), from one `get_app_settings`
snapshot, with a 2 s cache and a write generation.
- Bool-first migration: `keep_multiple_models_loaded` False or missing →
off; True with `multi_model_mode: "always"` → always; True otherwise →
fit. An older build that turns the setting off still wins after an
upgrade.
- `set_multi_model_mode()` writes both keys in one call. `PUT
/api/settings/multi-model` takes `{mode}` or the legacy `{enabled}`. A
legacy `enabled: true` keeps Always. The response adds `mode` and
`default_mode: "off"`.
**Arbiter** (`core/inference/gpu_arbiter.py`)
- `acquire_for(..., policy="off", evicted=None, gpu_ids=None)`. Under
`always` it raises `MultiModelAlwaysRefusal` instead of evicting a chat,
image or video model; a Decision API server is still evicted as before.
`off` and `fit` evict exactly as today.
- `_residents` keyed by owner with physical GPU ids
(`CUDA_VISIBLE_DEVICES=2,3` maps ordinal 0 to GPU 2), plus `holds()` and
`residents()`. `_owner` stays as the last claimant. When a caller or
test sets `_owner` directly, it wins over `_residents`.
- `owner_account(owner=None)` and `restore_owner_account(owner)` work
per owner.
- `evicted=` or the `eviction_sink` context variable collects `{kind,
model}` for every model an evictor unloads: chat primary, each kept
slot, image, video. Idle H3 servers are not listed.
**Where Always is enforced**
- In the arbiter, for chat ↔ image/video. A claim whose load failed and
holds nothing (no reported model, no load in flight, no stuck kept slot)
is still evicted. A model whose status cannot be read counts as loaded.
- Before an image or video load of another repo replaces the loaded
model of its own kind, on any device and before the image route's engine
switch, and again under the arbiter lock as the load registers, so a
concurrent load of another repo is not torn down either. Another file of
the same repo is allowed, as for chat.
- In `_load_model_impl`, before any teardown, when a load would replace
the serving primary with a different repo. Another quant, context length
or engine of the same repo is allowed. This covers
`/api/inference/load`, API auto-switch, CPU audio and zero-VRAM loads
that never enter the arbiter, the NPU path, and vLLM/SGLang loads that
would retire kept models.
- In the kept-slot retry loop: no kept model is evicted and the primary
is not replaced. A capped context still retries alongside, because a
smaller context is not an unload.
- Before the H3 video route unloads an off-torch image engine
(`routes/video.py` ~468), and before an off-torch image load unloads a
native H3 video model.
- The chat fast paths re-assert the GPU claim, and under Always they
skip it instead of refusing: the loaded model stays usable and nothing
is unloaded.
- Training and the idle TTL still free the GPU. `keep_resident` is still
ignored, as today.
**Refusal shape (one contract everywhere)**
- `409` with `X-Unsloth-Refusal: multi-model-always`, no `Retry-After`.
- Plain-string detail on `/api/inference/load` and on the image and
video load routes. Before this, a raw refusal would have become a 500 in
the load catch-all, or a 409 without the header on the media routes.
- OpenAI envelope with `error.code: "multi_model_always"` on `/v1` and
auto-switch, including media auto-switch.
- The `/p` preview keeps its existing 503.
- When the keep-alive padding has already started, `_deferred_error`
keeps `x-unsloth-refusal` and `retry-after` under `headers`.
- Owner copy: "Loading this model would unload the chat model
unsloth/Qwen3-8B-GGUF, and Keep multiple models loaded is set to Always.
Unload that model first, or choose When they fit." The managed-account
copy says "which Unsloth Studio is set to keep loaded" and never names a
model another account loaded. `MULTI_MODEL_REFUSAL_MARKERS` exports both
phrases for readers that lose headers.
**Eviction report**
- `LoadResponse.evicted` keeps `list[str]` and now also lists
image/video models a chat load freed and kept models a vLLM/SGLang load
retired. New `evicted_slots: [{kind: chat|audio|image|video, model}]`
uses public ids. `audio` means a native or audio.cpp audio model.
- An ordinary switch to another chat repo lists the replaced model in
`evicted_slots` only. The legacy `evicted` list drives the chat page's
"Unloaded … to make room" toast, which a normal switch should not show.
- `/images/load` and `/video/load` answers gain `evicted` and
`evicted_slots`, including the image or video model a load of another
repo replaces. They are filled only on the POST answer and are `[]` on
GET.
**Other**
- An unreadable setting keeps the last mode read instead of falling back
to Off.
- `diffusion_engine_router` gains a `before_engine_switch` check that
runs under its transition lock before a switch unloads the active
engine. The image load route uses it under Always.
- `/api/inference/status` gains `multi_model_mode`. MCP `studio_status`
reads it as `chat.multi_model_mode`, which is `None` against an older
server.
- `_ran_out_of_memory` accepts the structured `{code: "out_of_memory"}`
detail, so an audio.cpp OOM still frees an idle kept model.
- The "does not fit" message named a setting that never existed ("Keep
other models loaded"). It now names Keep multiple models loaded, with an
Always variant.
- Callers that assumed one owner now ask `holds()`: post-load guards,
the `/p` pre-check, managed-engine reap, `account_access`, and Laya,
which yields to any resident that is not a Decision API server. The
native-audio handoff waits for freed VRAM only when the media model was
actually evicted.
## Compatibility
- Off and When they fit behave as before (When they fit is today's
"on"). `policy_kwargs("off")` passes no new kwargs, so existing fakes
that take `acquire_for(owner, register)` still work.
- Test edits are in place only: three source-pinned literals, six
`owner_account` fakes that now accept arguments, the `:975` copy, and
two settings monkeypatches.
## Tests
- New: `tests/test_multi_model_settings.py`,
`tests/test_multi_model_always.py`,
`tests/multi_account/test_multi_model_owner_mode.py`. Also new arbiter
cases in `tests/test_gpu_arbiter.py` and the MCP mode read in
`tests/test_mcp_tools_status.py`.
- Arbiter, arbitration sim, multi-model serving, account, decision,
diffusion/video route, auto-switch, preview and multi-account suites
pass. In this environment the known baseline failures, which fail the
same way on main, are `test_alice_bob_matrix` (no `av` module), a
transformers/huggingface-hub version clash in one auto-switch test, and
an order-dependent `test_tunnel_safe_long_post` case.
UI evidence and the load-orchestrator CI run follow in a comment.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Etherl <mrmrmidoessam@gmail.com>
Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
113 lines
3.8 KiB
Python
113 lines
3.8 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Persisted "Keep multiple models loaded" mode: off, fit (When they fit) or always.
|
|
|
|
The setting belongs to the installation, so it is always read and written in the owner's
|
|
database. Each account has its own studio.db, and a managed account reading its own would
|
|
see the default and unload the owner's kept models.
|
|
|
|
Older builds stored only the ``keep_multiple_models_loaded`` bool. The bool still decides
|
|
on or off, and ``multi_model_mode`` only tells When they fit from Always, so an older build
|
|
that turns the setting off wins after an upgrade.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from typing import Any
|
|
|
|
from utils.account_context import OWNER, run_as
|
|
|
|
MULTI_MODEL_SETTING_KEY = "keep_multiple_models_loaded"
|
|
MULTI_MODEL_MODE_SETTING_KEY = "multi_model_mode"
|
|
MULTI_MODEL_MODE_CHOICES = ("off", "fit", "always")
|
|
_VALID_MODES = frozenset(MULTI_MODEL_MODE_CHOICES)
|
|
DEFAULT_MULTI_MODEL_MODE = "off"
|
|
DEFAULT_MULTI_MODEL_ENABLED = False
|
|
|
|
# Read on every load, including media loads, so memo briefly to spare SQLite.
|
|
_CACHE_TTL_S = 2.0
|
|
_cache_lock = threading.Lock()
|
|
_cache: tuple[float, str] | None = None
|
|
# Bumped on every write, so a read that began before a write never caches the old value.
|
|
_generation = 0
|
|
_MAX_REREADS = 3
|
|
|
|
|
|
def _mode_from_stored(stored: dict[str, Any]) -> str:
|
|
if stored.get(MULTI_MODEL_SETTING_KEY) is not True:
|
|
return "off"
|
|
return "always" if stored.get(MULTI_MODEL_MODE_SETTING_KEY) == "always" else "fit"
|
|
|
|
|
|
def _read_mode() -> str:
|
|
from storage.studio_db import get_app_settings
|
|
stored = run_as(
|
|
OWNER,
|
|
get_app_settings,
|
|
[MULTI_MODEL_SETTING_KEY, MULTI_MODEL_MODE_SETTING_KEY],
|
|
)
|
|
return _mode_from_stored(stored)
|
|
|
|
|
|
def get_multi_model_mode() -> str:
|
|
"""``"off"``, ``"fit"`` or ``"always"``, read as the owner."""
|
|
global _cache
|
|
mode = DEFAULT_MULTI_MODEL_MODE
|
|
for _attempt in range(_MAX_REREADS):
|
|
with _cache_lock:
|
|
if _cache is not None and time.monotonic() - _cache[0] < _CACHE_TTL_S:
|
|
return _cache[1]
|
|
generation = _generation
|
|
try:
|
|
mode = _read_mode()
|
|
except Exception:
|
|
# An unreadable DB must not fail a load: keep the last mode read, else the default.
|
|
with _cache_lock:
|
|
return _cache[1] if _cache is not None else DEFAULT_MULTI_MODEL_MODE
|
|
with _cache_lock:
|
|
if _generation == generation:
|
|
_cache = (time.monotonic(), mode)
|
|
return mode
|
|
return mode
|
|
|
|
|
|
def get_multi_model_enabled() -> bool:
|
|
return get_multi_model_mode() != "off"
|
|
|
|
|
|
def invalidate_multi_model_cache() -> None:
|
|
global _cache, _generation
|
|
with _cache_lock:
|
|
_cache = None
|
|
_generation += 1
|
|
|
|
|
|
def set_multi_model_mode(value: Any) -> bool:
|
|
"""Store the mode and return whether it changed."""
|
|
if not isinstance(value, str) or value not in _VALID_MODES:
|
|
raise ValueError("Keep multiple models loaded must be off, fit or always.")
|
|
|
|
from storage.studio_db import upsert_app_settings
|
|
|
|
previous = _read_mode()
|
|
run_as(
|
|
OWNER,
|
|
upsert_app_settings,
|
|
{MULTI_MODEL_MODE_SETTING_KEY: value, MULTI_MODEL_SETTING_KEY: value != "off"},
|
|
)
|
|
invalidate_multi_model_cache()
|
|
return previous != value
|
|
|
|
|
|
def set_multi_model_enabled(value: Any) -> bool:
|
|
"""Legacy bool write: True keeps an existing When they fit or Always, False turns it off."""
|
|
if not isinstance(value, bool):
|
|
raise ValueError("Keep multiple models loaded must be true or false.")
|
|
if not value:
|
|
set_multi_model_mode("off")
|
|
elif _read_mode() == "off":
|
|
set_multi_model_mode("fit")
|
|
return value
|