Studio: serialise GGUF reload and inherit unsloth-run extra args

Closes #5401.

Three related GGUF reload bugs reproduced against `unsloth studio run -m unsloth/Qwen3-0.6B-GGUF --gguf-variant Q4_K_M --top-k 20 --seed 42`:

1. The `POST /api/inference/load` already-loaded short-circuit only compared `model_identifier` and `hf_variant`. A same-(model, variant) Apply that flipped `cache_type_kv` / `speculative_type` / `chat_template_override` / `max_seq_length` / `llama_extra_args` returned `status="already_loaded"` and the new setting silently never reached llama-server.

2. The frontend chat-settings Apply path POSTs `/unload` then `/load` without round-tripping `llama_extra_args`. Every reload after `unsloth run --some-flag X` quietly dropped `--some-flag X` from the spawned `llama-server` command line.

3. `LlamaCppBackend.load_model` released `_lock` between Phase 1 (kill) and Phase 3 (spawn) so two concurrent loads each passed Phase 1 with `self._process is None`. Both ran Phase 2 (download), both reached Phase 3, and the Phase 3 defensive `_kill_process()` from #5171 collapsed them to one survivor only after both `subprocess.Popen` calls had landed. For the 86 GB MoE in #5161 / the model in #5401 the overlap window was tens of seconds, long enough to OOM the host. With a 0.6B model the pgrep timeline showed two simultaneous PIDs for 3.3 s on `main`.

Fix:

`studio/backend/core/inference/llama_cpp.py`

* Add `self._serial_load_lock = threading.Lock()`. The whole body of `load_model` runs under this lock so two concurrent `/api/inference/load` requests are strictly sequential. The fine-grained `_lock` and the Phase 3 defensive `_kill_process()` from #5171 are kept as a second layer. `/unload`, `/status`, and `/load-progress` are unaffected because they only touch the fine-grained lock or read properties.
* Add `self._extra_args` plus an `extra_args` property, written inside `load_model` whenever the caller supplies a non-`None` value. `unload_model()` deliberately does not reset it so the route layer can inherit the args across the frontend's `/unload` + `/load` gap.

`studio/backend/routes/inference.py`

* Add `_request_matches_loaded_settings(request, llama_backend)` that compares `max_seq_length`, `cache_type_kv`, `speculative_type`, `chat_template_override`, and `llama_extra_args` between the incoming request and the live backend. Same-(model, variant) requests whose runtime settings differ now fall through to a real reload instead of returning `already_loaded`. A missing `llama_extra_args` field on the request is treated as "inherit current", so the short-circuit still fires when the only difference is the frontend not echoing the CLI flags back.
* GGUF load branch inherits `llama_extra_args` from `llama_backend.extra_args` when the request omits the field, re-validates through `validate_extra_args`, and forwards the result to `load_model(...)`. An explicit `[]` from the caller is still honoured as "clear".

Verified end to end against a live `unsloth studio run` instance:

| Scenario                                                        | Before    | After                                                                    |
| --------------------------------------------------------------- | --------- | ------------------------------------------------------------------------ |
| `/load` same (model, variant, settings)                         | 1 PID, `already_loaded` | unchanged                                                                |
| `/load` same model, variant, new `cache_type_kv=q8_0` ctx=8192  | `already_loaded`, settings dropped | `loaded`, `/status` reports the new settings, new server has `-c 8192 --cache-type-k q8_0 --top-k 20 --seed 42` |
| Frontend Apply `/unload` + `/load`, new settings, no `llama_extra_args` field | Drops `--top-k 20 --seed 42` | Preserves `--top-k 20 --seed 42`                                          |
| `/unload` + two parallel `/load`                                | Two PIDs for 3.3 s | Max simultaneous count = 1 across the full pgrep timeline                  |
| `/load` with `llama_extra_args=[]` (explicit clear)             | n/a       | `loaded`, new server has no `--top-k` / `--seed`                          |
| `/load` with `llama_extra_args=["--top-k","30","--seed","7"]` (override) | n/a       | `loaded`, new server has the supplied flags                                |

`pytest studio/backend/tests` is green except for one pre-existing terminal-width-sensitive assertion (`test_studio_api.py::test_help_output`) and the pre-existing `test_studio_api.py` fixture errors that fail on unmodified main too. No new regressions.
This commit is contained in:
Daniel Han 2026-05-14 23:18:49 +00:00
commit f9cbec3b60
2 changed files with 741 additions and 583 deletions

File diff suppressed because it is too large Load diff

View file

@ -401,6 +401,59 @@ def _validate_native_mmproj_companion(
) from exc
def _normalise_settings_str(value: Optional[str]) -> Optional[str]:
"""Lowercase + strip a settings string, mapping blank/None to None."""
if value is None:
return None
if isinstance(value, str):
stripped = value.strip().lower()
return stripped or None
return value
def _request_matches_loaded_settings(
request: LoadRequest, llama_backend: LlamaCppBackend
) -> bool:
"""Return True iff the request's runtime settings match the loaded server.
The /api/inference/load short-circuit at the call site additionally checks
model identifier + GGUF variant + ``is_loaded`` before delegating here.
We compare every setting that ends up on the ``llama-server`` command
line so a same-(model, variant) Apply request that *did* change a
runtime setting falls through to a real reload instead of silently
returning ``status="already_loaded"`` (issue #5401).
"""
# max_seq_length == 0 means "use model default" -- treat as a match
# against the running effective context length.
backend_ctx = llama_backend.context_length or 0
if request.max_seq_length and request.max_seq_length != backend_ctx:
return False
if _normalise_settings_str(request.cache_type_kv) != _normalise_settings_str(
llama_backend.cache_type_kv
):
return False
# Speculative dropdown sends "default" / "off" / explicit type names.
# The backend stores the type the user selected (or None when not
# speculatively decoding). Treat None and "off" as equivalent.
req_spec = _normalise_settings_str(request.speculative_type)
backend_spec = _normalise_settings_str(llama_backend.speculative_type)
if (req_spec or "off") != (backend_spec or "off"):
return False
if (request.chat_template_override or None) != (
llama_backend.chat_template_override or None
):
return False
req_extra = list(request.llama_extra_args) if request.llama_extra_args else []
backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else []
# ``llama_extra_args is None`` on the request means "inherit current",
# i.e. the frontend Apply path that does not round-trip the field. We
# only fall through to a real reload when the caller *explicitly*
# supplied a non-empty list and it differs from what is loaded.
if request.llama_extra_args is not None and req_extra != backend_extra:
return False
return True
def _resolve_model_identifier_for_request(
request: LoadRequest | ValidateModelRequest,
*,
@ -474,6 +527,13 @@ async def load_model(
and llama_backend.hf_variant.lower() == request.gguf_variant.lower()
and llama_backend.model_identifier
and llama_backend.model_identifier.lower() == model_identifier.lower()
# Identifier + variant alone are insufficient: a same-model
# Apply that changed KV / ctx / spec / template / extra
# args used to silently short-circuit and the user's
# change was dropped. Require every runtime setting that
# ends up on the llama-server command line to also match
# before reporting "already_loaded". See issue #5401.
and _request_matches_loaded_settings(request, llama_backend)
):
logger.info(
f"Model already loaded (GGUF): {model_log_label} variant={request.gguf_variant}, skipping reload"
@ -608,6 +668,33 @@ async def load_model(
)
unsloth_backend.unload_model(unsloth_backend.active_model_name)
# Inherit ``llama_extra_args`` from the previous load when the
# incoming request omits the field. The chat-settings Apply
# path on the frontend does not round-trip these flags (see
# use-chat-model-runtime.ts), so without this, every reload
# after ``unsloth run --some-flag X`` quietly drops the
# user's ``--some-flag X`` from the spawned llama-server.
# An explicit empty list is still honoured as "clear them".
if request.llama_extra_args is None and llama_backend.extra_args:
inherited = list(llama_backend.extra_args)
try:
extra_llama_args = validate_extra_args(inherited)
except ValueError:
# Stored args came from a prior validated load; revalidation
# should not normally fail, but if it does (e.g. managed-flag
# rules changed) fall back to "no extras" rather than 400.
logger.warning(
"Stored llama_extra_args failed revalidation; "
"loading without them: %s",
inherited,
)
extra_llama_args = []
else:
logger.info(
"Inheriting llama_extra_args from previous load: %s",
extra_llama_args,
)
# Route to HF mode or local mode based on config
# Run in a thread so the event loop stays free for progress
# polling and other requests during the (potentially long)