* Studio: fall back to text-only when a vision projector hard-crashes llama-server The text-only mmproj fallback (#6075) only fired when llama-server printed a recognizable projector-format error ("Unknown projector type", exit -6). An installed llama.cpp that predates a model's projector can instead SIGSEGV (exit -11) with no parseable output, e.g. unsloth/Qwen3.5-4B-MTP-GGUF + mmproj-F16 on an older gfx1151 prebuilt: llama-server crashes on load, the --fit off retry crashes the same way, and load_model gives up with a hard 500 instead of dropping vision. Generalize the decision: a vision (--mmproj) launch killed by a signal (POSIX returncode < 0, e.g. -11 SIGSEGV / -6 SIGABRT; Windows 0xC0000000+ access violation) is treated like a projector incompatibility, so the load retries once text-only. The retry is skipped if a cancel/unload is pending, mirroring the MTP guard. Clean non-zero exits (bad GGUF, port bind) and hung processes keep their own handling; non-vision launches are unaffected. Reproduced and verified on gfx1151 (Radeon 8060S, ROCm 7.2.1): a current prebuilt (llama.cpp b9596) loads the exact model + args fine, confirming the crash is a stale prebuilt. With a wrapper that SIGSEGVs on --mmproj, Studio now recovers: the load returns 200 (is_vision=false) and serves at ~31 tok/s text-only instead of failing. New _is_signal_crash helper plus tests pin the decision. Also normalize a few em-dashes to ASCII punctuation in existing comments. * Studio: refine mmproj hard-crash fallback (signal scope + last argv) - Limit _is_signal_crash to genuine program faults (SIGSEGV, SIGABRT, SIGILL, SIGFPE, SIGBUS) and Windows 0xC0000000+ statuses. SIGKILL, SIGTERM and SIGINT no longer count, so an OOM-killer, unload or supervisor kill is not masked as a projector incompatibility. - Strip --mmproj from the last attempted argv so the text-only retry keeps --fit off / --spec-default instead of resurrecting the original spec flags (matters for MTP vision models on an older llama.cpp). - Drop stray temp files committed by mistake and gitignore the "~" dir so they cannot be re-added. * Studio: tighten comments in mmproj hard-crash fallback * Studio: retry --flash-attn off before dropping vision on a startup crash When llama-server hard-crashes at startup, the recovery chain now tries the least-destructive mitigation first. Flash-attention kernels SIGSEGV at load on some ROCm/GPU builds (often inside the vision tower's attention); disabling flash attention keeps BOTH vision and MTP, so a hard program fault with --flash-attn on now retries once with --flash-attn off before the MTP-drop or the text-only (mmproj-strip) fallbacks. _is_signal_crash already gates this to genuine faults (SIGSEGV/SIGABRT/SIGILL/SIGFPE/SIGBUS), so an OOM-kill or unload (SIGKILL/SIGTERM/SIGINT) does not trigger a retry. Field context: a gfx1151 user crashes loading a vision GGUF even on the latest prebuilt, so an update cannot help, and the same model and args load fine on another gfx1151 box, pointing at a runtime/flash-attn fault. New _with_flash_attn_off helper plus tests. Verified on hardware with a wrapper that SIGSEGVs on --flash-attn on: Studio recovers with is_vision=true (vision and MTP intact) instead of failing or losing vision. * Studio: name the OOM kill on a too-large model load When the OS kills llama-server with no diagnostic output (SIGKILL/SIGTERM, almost always the OOM killer, e.g. a BF16 model too large for the WSL VM's RAM cap), the recovery ladder correctly does not retry an external kill, so this is the message the user sees. It fell through to the generic "is the GGUF valid / out of memory" text. Make it actionable: name the signal and point at a smaller or more quantized GGUF, a lower context length, or raising the WSL memory limit. Output-based diagnoses still win and a hard fault keeps the generic fallback. * Studio: refuse a model too large for system RAM on a unified-memory APU On gfx1150/gfx1151 APUs the weights load into shared system RAM (GGML unified memory). _get_gpu_free_memory reports the full ROCm/APU budget as free (often ~100 GB), but under WSL the VM's RAM cap is the real ceiling. Studio trusted the budget, spawned a load larger than RAM, and the OS killed it mid-flight, taking the Studio process with it (a silent "Terminated" with no error, the model resident in RAM not VRAM). Add a pre-flight guard on the APU path: if the weights exceed available system RAM (psutil, then /proc/meminfo), refuse before spawning with a clear message (smaller/more-quantized GGUF, lower context, or raise the WSL memory limit). Weights only so KV/context auto-reduction is not double-counted; unknown RAM never refuses; non-APU and discrete-GPU paths are untouched. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: refine recovery ladder (keep diagnosed errors, flip all flash-attn) Two review points on the hard-crash recovery ladder: 1. The signal-only text-only fallback stripped --mmproj on any hard fault, even when llama-server had already printed a non-projector cause (an OOM such as "cudaMalloc failed: out of memory", an unsupported architecture, or a tensor-parallel limit). That masked the real error and told the user to update llama.cpp for vision. New _output_has_nonprojector_diagnostic gates the signal path: it fires only when no such marker is present, so a bare SIGSEGV with no output still retries text-only, but a diagnosed OOM surfaces the real error instead of silently dropping vision. 2. _with_flash_attn_off only flipped the first --flash-attn. llama.cpp is last-wins, so a leftover enable from extra_args (--flash-attn on, -fa on, or the = form) could keep flash attention on and re-crash the retry. It now flips every occurrence and returns None only when nothing is flippable. test_llama_cpp_mmproj_fallback.py and the classification/APU suites: 103 passed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: pass the text-only retry's exit code to the failure classifier When the text-only fallback retry itself fails, read its exit code before _kill_process() clears it and forward it to _classify_llama_start_failure, so an OS-killed retry surfaces the actionable out-of-memory message instead of the generic one (matching the primary failure path). * Studio: scope APU RAM guard to selected GPUs, count MTP drafter, neutral SIGTERM Three refinements to the startup recovery work in this PR: - The unified-memory APU RAM guard fired whenever any visible GPU was a gfx1150/gfx1151 APU, so on a mixed APU+dGPU host it could refuse a valid load placed on the discrete GPU. Scope _amd_apu_wants_unified_memory to the selected gpu_indices (physical ids, mapped via CUDA_VISIBLE_DEVICES like _is_datacenter_gpu); None still means every visible GPU. Applied to both the RAM guard and the GGML_CUDA_ENABLE_UNIFIED_MEMORY env set. - The RAM guard counted only the main GGUF plus mmproj, so a separate MTP drafter (also resident in unified system RAM, even when offloaded to CPU) could push the load past the RAM cap and still get OS-killed mid-load. Add the drafter weights to the APU RAM total. - The startup classifier reported SIGTERM (-15) as 'most likely out of memory', but SIGTERM is also how an unload/cancel or a supervisor stops the server. Keep the OOM wording for SIGKILL (-9, the OOM killer) and report -15 neutrally. Tests updated/added accordingly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: address review on the APU guard and decode-probe ladder - Map APU physical ids via the active ROCm mask (HIP, then ROCR, then CUDA), mirroring _get_gpu_memory, so a HIP_VISIBLE_DEVICES-selected APU is matched. - Only add the MTP drafter to the APU RAM total when MTP will actually engage, so a stale LLAMA_ARG_SPEC_DRAFT_MODEL cannot refuse a non-MTP load. - After an MTP first-decode hard fault, retry --flash-attn off (keeps MTP) before dropping speculative decoding, matching the startup rung. - Fold the --flash-attn= / -fa= rewrite into one branch. Tests: tensor-parallel decode-probe assertion updated for the FA-off rung. * Studio: tighten two comments in the APU guard and RAM preflight * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: refine flash-attn retry and APU RAM guard per review - _with_flash_attn_off now decides on the effective last-wins value: it returns None when FA is already off (no wasted retry), and neutralizes a bare --flash-attn / -fa (which llama.cpp reads as on) so the retry cannot re-enable it. Length is preserved so downstream index slices stay valid. - _amd_apu_wants_unified_memory uses 'gpu_indices is not None' so an empty selection is respected (not treated as all-visible). - The APU RAM refusal now checks the base model only (main + mmproj); an optional MTP drafter is dropped by the existing MTP-drop fallback rather than causing a hard pre-spawn refusal of an otherwise loadable model. Tests: bare-flag / effective-off / empty-selection / HIP-mask cases added. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1151 lines
43 KiB
Python
1151 lines
43 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
|
|
|
|
"""Backend contract for the Tensor Parallelism toggle.
|
|
|
|
The toggle threads a single ``tensor_parallel`` bool from the chat UI
|
|
through the load request to a ``--split-mode tensor`` llama-server flag,
|
|
and round-trips it back via the load/status responses so the switch
|
|
reflects what is actually running. These tests pin:
|
|
|
|
* the pydantic request/response/status contract (snake_case key,
|
|
default False),
|
|
* the backend ``tensor_parallel`` property and its reset on unload,
|
|
* the ``_already_in_target_state`` reload-detection branch, and
|
|
* that ``--split-mode tensor`` is emitted only behind the toggle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
# Same external-dep stubs as the other llama_cpp unit tests so importing
|
|
# the backend doesn't drag in structlog / httpx / loggers.
|
|
_loggers_stub = _types.ModuleType("loggers")
|
|
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
|
sys.modules.setdefault("loggers", _loggers_stub)
|
|
|
|
_structlog_stub = _types.ModuleType("structlog")
|
|
_structlog_stub.get_logger = lambda *a, **k: __import__("logging").getLogger("stub")
|
|
sys.modules.setdefault("structlog", _structlog_stub)
|
|
|
|
_httpx_stub = _types.ModuleType("httpx")
|
|
for _exc in (
|
|
"ConnectError",
|
|
"TimeoutException",
|
|
"ReadTimeout",
|
|
"ReadError",
|
|
"RemoteProtocolError",
|
|
"CloseError",
|
|
):
|
|
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
|
|
_httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
|
|
_httpx_stub.Client = type(
|
|
"C",
|
|
(),
|
|
{
|
|
"__init__": lambda s, **kw: None,
|
|
"__enter__": lambda s: s,
|
|
"__exit__": lambda s, *a: None,
|
|
},
|
|
)
|
|
sys.modules.setdefault("httpx", _httpx_stub)
|
|
|
|
from core.inference import llama_cpp as llama_cpp_module
|
|
from core.inference.llama_cpp import _CTX_FIT_VRAM_FRACTION, LlamaCppBackend
|
|
from core.inference.llama_server_args import (
|
|
_effective_tensor_parallel,
|
|
resolve_tensor_parallel,
|
|
)
|
|
from core.inference.tensor_fallback import load_with_tensor_fallback
|
|
from models.inference import (
|
|
InferenceStatusResponse,
|
|
LoadRequest,
|
|
LoadResponse,
|
|
)
|
|
|
|
|
|
# ── Pydantic contract (snake_case key, default False) ────────────────
|
|
|
|
|
|
def test_load_request_defaults_tensor_parallel_false():
|
|
req = LoadRequest(model_path = "owner/repo")
|
|
assert req.tensor_parallel is False
|
|
|
|
|
|
def test_load_request_accepts_tensor_parallel():
|
|
req = LoadRequest(model_path = "owner/repo", tensor_parallel = True)
|
|
assert req.tensor_parallel is True
|
|
|
|
|
|
def test_load_request_round_trips_json_key():
|
|
# The frontend sends the snake_case key verbatim.
|
|
req = LoadRequest.model_validate({"model_path": "owner/repo", "tensor_parallel": True})
|
|
assert req.tensor_parallel is True
|
|
assert req.model_dump()["tensor_parallel"] is True
|
|
|
|
|
|
@pytest.mark.parametrize("model_cls", [LoadResponse, InferenceStatusResponse])
|
|
def test_response_models_emit_tensor_parallel(model_cls):
|
|
# Default False, and the key is always present in the JSON body.
|
|
if model_cls is LoadResponse:
|
|
default = model_cls(
|
|
status = "loaded",
|
|
model = "owner/repo",
|
|
display_name = "repo",
|
|
inference = {},
|
|
)
|
|
on = model_cls(
|
|
status = "loaded",
|
|
model = "owner/repo",
|
|
display_name = "repo",
|
|
inference = {},
|
|
tensor_parallel = True,
|
|
)
|
|
else:
|
|
default = model_cls()
|
|
on = model_cls(tensor_parallel = True)
|
|
assert default.model_dump()["tensor_parallel"] is False
|
|
assert on.model_dump()["tensor_parallel"] is True
|
|
|
|
|
|
# ── Backend property + reset ─────────────────────────────────────────
|
|
|
|
|
|
class _FakeProcess:
|
|
"""Stand-in for subprocess.Popen so _kill_process is a no-op."""
|
|
|
|
def terminate(self):
|
|
pass
|
|
|
|
def wait(self, timeout = None):
|
|
return 0
|
|
|
|
def kill(self):
|
|
pass
|
|
|
|
def poll(self):
|
|
return 0
|
|
|
|
|
|
def test_tensor_parallel_property_defaults_false():
|
|
assert LlamaCppBackend().tensor_parallel is False
|
|
|
|
|
|
def test_tensor_parallel_property_reflects_field():
|
|
backend = LlamaCppBackend()
|
|
backend._tensor_parallel = True
|
|
assert backend.tensor_parallel is True
|
|
|
|
|
|
def test_unload_resets_tensor_parallel():
|
|
backend = LlamaCppBackend()
|
|
backend._process = _FakeProcess()
|
|
backend._tensor_parallel = True
|
|
backend.unload_model()
|
|
assert backend.tensor_parallel is False
|
|
|
|
|
|
# ── _already_in_target_state reload-detection branch ─────────────────
|
|
|
|
|
|
def _loaded_backend(tensor_parallel: bool) -> LlamaCppBackend:
|
|
backend = LlamaCppBackend()
|
|
backend._process = _FakeProcess() # is_loaded only checks "is not None"
|
|
backend._healthy = True
|
|
backend._model_identifier = "owner/repo"
|
|
backend._hf_variant = "Q4_K_M"
|
|
backend._requested_n_ctx = 8192
|
|
backend._cache_type_kv = None
|
|
backend._requested_spec_mode = "auto"
|
|
backend._chat_template_override = None
|
|
backend._is_vision = False
|
|
backend._extra_args = None
|
|
backend._gguf_path = None
|
|
backend._tensor_parallel = tensor_parallel
|
|
return backend
|
|
|
|
|
|
def _target_state(backend: LlamaCppBackend, tensor_parallel: bool) -> bool:
|
|
return backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "owner/repo",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = "auto",
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
tensor_parallel = tensor_parallel,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("flag", [True, False])
|
|
def test_already_in_target_state_matches_same_tensor_parallel(flag):
|
|
assert _target_state(_loaded_backend(flag), flag) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"loaded,requested",
|
|
[(False, True), (True, False)],
|
|
)
|
|
def test_already_in_target_state_reloads_on_tensor_parallel_change(loaded, requested):
|
|
# Flipping the toggle either direction must force a reload so the
|
|
# command is rebuilt with/without --split-mode tensor.
|
|
assert _target_state(_loaded_backend(loaded), requested) is False
|
|
|
|
|
|
def test_already_in_target_state_reconciles_split_mode_extras():
|
|
# Tensor engaged via --split-mode in extras (boolean omitted/default False)
|
|
# must match a server already running tensor mode -- no spurious reload.
|
|
backend = _loaded_backend(tensor_parallel = True)
|
|
backend._extra_args = ["--split-mode", "tensor"]
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "owner/repo",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = "auto",
|
|
chat_template_override = None,
|
|
extra_args = ["--split-mode", "tensor"],
|
|
is_vision = False,
|
|
tensor_parallel = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# ── --split-mode tensor is emitted only behind the toggle ────────────
|
|
|
|
|
|
def _load_model_source() -> str:
|
|
return inspect.getsource(llama_cpp_module.LlamaCppBackend.load_model)
|
|
|
|
|
|
def test_split_mode_tensor_is_gated_on_the_toggle():
|
|
src = _load_model_source()
|
|
assert (
|
|
'cmd.extend(["--split-mode", "tensor"])' in src
|
|
), "the tensor-parallel flag emission must be present in load_model"
|
|
# The emission lives behind `if tensor_parallel:` -- it must never be
|
|
# part of the unconditional base cmd list.
|
|
base_start = src.find("cmd = [")
|
|
base_end = src.find("\n ]", base_start)
|
|
base_block = src[base_start:base_end] if base_end > base_start else ""
|
|
assert (
|
|
"--split-mode" not in base_block
|
|
), "--split-mode must be conditional, not in the base cmd list"
|
|
gate = src.find("if tensor_parallel:")
|
|
emit = src.find('cmd.extend(["--split-mode", "tensor"])')
|
|
assert 0 <= gate < emit, "emission must sit under `if tensor_parallel:`"
|
|
|
|
|
|
def test_proportional_tensor_split_is_emitted_in_tensor_mode():
|
|
# Asymmetric GPUs (e.g. 48 GB + 24 GB) OOM the smaller card under the
|
|
# even default; the allocator weights --tensor-split by free VRAM. Pin
|
|
# that the flag is emitted from inside the tensor-parallel block.
|
|
src = _load_model_source()
|
|
assert '"--tensor-split"' in src
|
|
gate = src.find("if tensor_parallel:")
|
|
ts = src.find('"--tensor-split"')
|
|
nxt_else = src.find("self._tensor_parallel = False")
|
|
assert 0 <= gate < ts < nxt_else, "--tensor-split must be emitted under `if tensor_parallel:`"
|
|
|
|
|
|
def test_mtp_decode_probe_wired_under_tensor_parallel():
|
|
# MTP-draft can pass /health and crash the CUDA FA kernel only on the first
|
|
# decode under --split-mode tensor. Rather than statically banning MTP+TP
|
|
# (which a future llama.cpp may support), load_model probes a decode; a hard
|
|
# fault retries --flash-attn off first, else routes into the MTP-drop fallback.
|
|
src = _load_model_source()
|
|
probe = src.find("_probe_mtp_decode()")
|
|
assert probe != -1, "load_model must decode-probe MTP under tensor parallelism"
|
|
# Gated on tensor mode AND an MTP request (ordinary MTP loads stay unprobed).
|
|
guard = src[max(0, probe - 400) : probe]
|
|
assert "self._tensor_parallel" in guard and "_spec_requested_mtp" in guard
|
|
# A hard fault retries FA-off (keeps MTP) before flipping healthy so the
|
|
# shared MTP-drop fallback fires.
|
|
after = src[probe : probe + 900]
|
|
assert "_with_flash_attn_off" in after and "healthy = False" in after
|
|
fallback = src.find("if not healthy and _spec_requested_mtp")
|
|
assert 0 <= probe < fallback, "the probe must precede the MTP-drop fallback"
|
|
|
|
|
|
def test_probe_mtp_decode_returns_false_on_crash(monkeypatch):
|
|
# The probe is the decode-time health gate: True only on a clean 200 from a
|
|
# live server; any error (dropped connection, non-200, dead process) is a
|
|
# failed probe so the caller drops MTP and retries.
|
|
backend = LlamaCppBackend()
|
|
backend._port = 0
|
|
|
|
class _Resp:
|
|
def __init__(self, code):
|
|
self.status_code = code
|
|
|
|
backend._process = None # liveness check skipped; exercise the HTTP result
|
|
monkeypatch.setattr(llama_cpp_module.httpx, "post", lambda *a, **k: _Resp(200), raising = False)
|
|
assert backend._probe_mtp_decode(timeout = 1.0) is True
|
|
|
|
def _drop(*a, **k):
|
|
raise llama_cpp_module.httpx.RemoteProtocolError("peer closed connection")
|
|
|
|
monkeypatch.setattr(llama_cpp_module.httpx, "post", _drop, raising = False)
|
|
assert backend._probe_mtp_decode(timeout = 1.0) is False
|
|
|
|
monkeypatch.setattr(llama_cpp_module.httpx, "post", lambda *a, **k: _Resp(500), raising = False)
|
|
assert backend._probe_mtp_decode(timeout = 1.0) is False
|
|
|
|
# 200 but the server aborted right after (poll() returns an exit code).
|
|
backend._process = _FakeProcess()
|
|
monkeypatch.setattr(llama_cpp_module.httpx, "post", lambda *a, **k: _Resp(200), raising = False)
|
|
assert backend._probe_mtp_decode(timeout = 1.0) is False
|
|
|
|
|
|
# ── generation-time MTP recovery (mid-stream crash) ──────────────────
|
|
|
|
|
|
def _recovery_backend() -> LlamaCppBackend:
|
|
# A backend that loaded MTP under tensor parallelism and whose server has
|
|
# since exited (the _FakeProcess poll() returns 0 -> a dead subprocess).
|
|
b = LlamaCppBackend()
|
|
b._tensor_parallel = True
|
|
b._speculative_type = "draft-mtp"
|
|
b._mtp_runtime_fallback_active = True
|
|
b._process = _FakeProcess()
|
|
b._last_load_kwargs = {
|
|
"model_identifier": "owner/repo",
|
|
"tensor_parallel": True,
|
|
"speculative_type": "auto",
|
|
"n_parallel": 4,
|
|
}
|
|
return b
|
|
|
|
|
|
def test_generate_chat_completion_wires_runtime_recovery():
|
|
# The non-tool generation path must route a mid-stream server death into the
|
|
# recovery helper (the tool + passthrough paths do so from the routes).
|
|
src = inspect.getsource(LlamaCppBackend.generate_chat_completion)
|
|
assert "_maybe_recover_from_mtp_crash" in src
|
|
|
|
|
|
def test_runtime_recovery_reloads_without_mtp(monkeypatch):
|
|
# One background reload with speculative_type="off" (rest of snapshot kept),
|
|
# then spec_fallback_reason="runtime_error" and single-flight released.
|
|
b = _recovery_backend()
|
|
done = threading.Event()
|
|
captured = {}
|
|
|
|
def _fake_load_model(**kwargs):
|
|
captured.update(kwargs)
|
|
done.set()
|
|
return True
|
|
|
|
monkeypatch.setattr(b, "load_model", _fake_load_model)
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError("peer closed")) is True
|
|
assert done.wait(timeout = 5)
|
|
assert captured["speculative_type"] == "off"
|
|
assert captured["model_identifier"] == "owner/repo"
|
|
assert captured["n_parallel"] == 4 # snapshot replayed faithfully
|
|
deadline = time.monotonic() + 2
|
|
while b._spec_fallback_reason != "runtime_error" and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
assert b._spec_fallback_reason == "runtime_error"
|
|
assert b._mtp_runtime_fallback_in_progress is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mutate",
|
|
[
|
|
lambda b: setattr(b, "_mtp_runtime_fallback_active", False),
|
|
lambda b: setattr(b, "_last_load_kwargs", None),
|
|
lambda b: setattr(b, "_process", None),
|
|
lambda b: b._cancel_event.set(),
|
|
],
|
|
)
|
|
def test_runtime_recovery_skips_when_not_applicable(monkeypatch, mutate):
|
|
# No reload when this launch is not running MTP+tensor, there is no snapshot,
|
|
# the process handle is gone, or the request was cancelled.
|
|
b = _recovery_backend()
|
|
mutate(b)
|
|
calls = []
|
|
monkeypatch.setattr(b, "load_model", lambda **k: calls.append(k))
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is False
|
|
assert calls == []
|
|
|
|
|
|
class _BlockingDeadProc:
|
|
# Reports alive until released, then dead -- lets a test mutate backend state
|
|
# while the recovery thread is still in its death-confirm poll.
|
|
def __init__(self):
|
|
self._dead = threading.Event()
|
|
|
|
def poll(self):
|
|
return 0 if self._dead.is_set() else None
|
|
|
|
def terminate(self):
|
|
self._dead.set()
|
|
|
|
def kill(self):
|
|
self._dead.set()
|
|
|
|
def wait(self, timeout = None):
|
|
self._dead.set()
|
|
return 0
|
|
|
|
def release(self):
|
|
self._dead.set()
|
|
|
|
|
|
def test_runtime_recovery_fires_for_user_env_mtp(monkeypatch):
|
|
# MTP driven by user extra_args / LLAMA_ARG_SPEC_TYPE leaves _speculative_type
|
|
# unset, but the launch flag still gates recovery on (pass-through MTP).
|
|
b = _recovery_backend()
|
|
b._speculative_type = None # Studio stepped back; user/env owns the spec
|
|
done = threading.Event()
|
|
captured = {}
|
|
|
|
def _fake_load_model(**kwargs):
|
|
captured.update(kwargs)
|
|
done.set()
|
|
return True
|
|
|
|
monkeypatch.setattr(b, "load_model", _fake_load_model)
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is True
|
|
assert done.wait(timeout = 5)
|
|
assert captured["speculative_type"] == "off"
|
|
|
|
|
|
def test_runtime_recovery_strips_user_mtp_extra_args(monkeypatch):
|
|
# A user --spec-type draft-mtp in extra_args must be neutralised on the reload
|
|
# (append a last-wins --spec-default) so MTP can't re-engage and loop.
|
|
b = _recovery_backend()
|
|
b._last_load_kwargs = dict(b._last_load_kwargs, extra_args = ["--spec-type", "draft-mtp"])
|
|
done = threading.Event()
|
|
captured = {}
|
|
|
|
def _fake_load_model(**kwargs):
|
|
captured.update(kwargs)
|
|
done.set()
|
|
return True
|
|
|
|
monkeypatch.setattr(b, "load_model", _fake_load_model)
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is True
|
|
assert done.wait(timeout = 5)
|
|
assert captured["speculative_type"] == "off"
|
|
assert captured["extra_args"][-1] == "--spec-default"
|
|
|
|
|
|
def test_runtime_recovery_restores_requested_mode(monkeypatch):
|
|
# After the off-reload, /status must show the user's requested mode + the
|
|
# runtime-error note, not a bare "off" (matches the startup MTP fallback).
|
|
b = _recovery_backend()
|
|
b._last_load_kwargs = dict(b._last_load_kwargs, speculative_type = "mtp")
|
|
done = threading.Event()
|
|
|
|
def _fake_load_model(**kwargs):
|
|
b._requested_spec_mode = "off" # what a real off-reload would leave behind
|
|
done.set()
|
|
return True
|
|
|
|
monkeypatch.setattr(b, "load_model", _fake_load_model)
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is True
|
|
assert done.wait(timeout = 5)
|
|
deadline = time.monotonic() + 2
|
|
while b._requested_spec_mode != "mtp" and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
assert b._requested_spec_mode == "mtp"
|
|
assert b._spec_fallback_reason == "runtime_error"
|
|
|
|
|
|
def test_runtime_recovery_skips_when_process_replaced(monkeypatch):
|
|
# A newer user load that replaces the process during the death-confirm poll
|
|
# must not be clobbered by the stale recovery replay.
|
|
b = _recovery_backend()
|
|
p1 = _BlockingDeadProc()
|
|
b._process = p1
|
|
calls = []
|
|
monkeypatch.setattr(b, "load_model", lambda **k: calls.append(k))
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is True # captures p1
|
|
b._process = _FakeProcess() # a newer load swapped the live process
|
|
p1.release() # p1 now reports dead -> recovery runs its staleness check
|
|
time.sleep(0.6)
|
|
assert calls == [], "stale recovery replayed over a newer load"
|
|
|
|
|
|
def test_runtime_recovery_skips_when_snapshot_changed(monkeypatch):
|
|
# If the recorded load changed during the poll, the stale snapshot is dropped.
|
|
b = _recovery_backend()
|
|
p1 = _BlockingDeadProc()
|
|
b._process = p1
|
|
calls = []
|
|
monkeypatch.setattr(b, "load_model", lambda **k: calls.append(k))
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is True
|
|
b._last_load_kwargs = dict(b._last_load_kwargs, model_identifier = "other/model")
|
|
p1.release()
|
|
time.sleep(0.6)
|
|
assert calls == []
|
|
|
|
|
|
def test_runtime_recovery_is_single_flight(monkeypatch):
|
|
# Concurrent failures schedule only one reload.
|
|
b = _recovery_backend()
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def _slow_load(**kwargs):
|
|
started.set()
|
|
release.wait(timeout = 5)
|
|
return True
|
|
|
|
monkeypatch.setattr(b, "load_model", _slow_load)
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is True
|
|
assert started.wait(timeout = 5)
|
|
# Second failure while the first reload is in flight is a no-op.
|
|
assert b._maybe_recover_from_mtp_crash(RuntimeError()) is False
|
|
release.set()
|
|
|
|
|
|
def test_runtime_recovery_rechecks_cancel_before_reload():
|
|
# recover() must re-check the cancel flag after the death poll (load_model
|
|
# clears it), so a reload scheduled just before /unload can't resurrect it.
|
|
src = inspect.getsource(LlamaCppBackend._maybe_recover_from_mtp_crash)
|
|
cancel = src.rfind("self._cancel_event.is_set()")
|
|
load = src.find("self.load_model(")
|
|
assert 0 <= cancel < load, "recovery must re-check cancel before reloading"
|
|
|
|
|
|
def test_probe_mtp_decode_uses_api_key_auth(monkeypatch):
|
|
# Direct-stream mode runs llama-server with --api-key; the probe must send
|
|
# the same bearer auth or it gets a spurious 401 and falsely drops MTP.
|
|
backend = LlamaCppBackend()
|
|
backend._port = 0
|
|
backend._process = None
|
|
captured = {}
|
|
|
|
class _Resp:
|
|
status_code = 200
|
|
|
|
def _capture(*a, **k):
|
|
captured.clear()
|
|
captured.update(k)
|
|
return _Resp()
|
|
|
|
monkeypatch.setattr(llama_cpp_module.httpx, "post", _capture, raising = False)
|
|
backend._api_key = "secret"
|
|
backend._probe_mtp_decode(timeout = 1.0)
|
|
assert captured["headers"] == {"Authorization": "Bearer secret"}
|
|
backend._api_key = None
|
|
backend._probe_mtp_decode(timeout = 1.0)
|
|
assert captured["headers"] is None
|
|
|
|
|
|
class _ToggleProcess:
|
|
"""A subprocess stand-in whose liveness can be flipped at runtime."""
|
|
|
|
def __init__(self):
|
|
self._alive = True
|
|
|
|
def poll(self):
|
|
return None if self._alive else 0
|
|
|
|
def terminate(self):
|
|
self._alive = False
|
|
|
|
def kill(self):
|
|
self._alive = False
|
|
|
|
def wait(self, timeout = None):
|
|
self._alive = False
|
|
return 0
|
|
|
|
def die(self):
|
|
self._alive = False
|
|
|
|
|
|
def test_crash_watchdog_triggers_recovery_on_death(monkeypatch):
|
|
# The watchdog must notice the process exit and recover even when no request
|
|
# handler observed it (e.g. the direct proxy endpoints).
|
|
b = _recovery_backend()
|
|
proc = _ToggleProcess()
|
|
b._process = proc
|
|
fired = threading.Event()
|
|
monkeypatch.setattr(b, "_maybe_recover_from_mtp_crash", lambda *a, **k: fired.set())
|
|
b._start_mtp_crash_watchdog()
|
|
assert b._mtp_watchdog_thread is not None
|
|
proc.die()
|
|
assert fired.wait(timeout = 3)
|
|
|
|
|
|
def test_crash_watchdog_ignores_intentional_termination(monkeypatch):
|
|
# A planned reload/unload stops the watchdog before killing the process, so
|
|
# the resulting death must not be mistaken for a crash.
|
|
b = _recovery_backend()
|
|
proc = _ToggleProcess()
|
|
b._process = proc
|
|
fired = threading.Event()
|
|
monkeypatch.setattr(b, "_maybe_recover_from_mtp_crash", lambda *a, **k: fired.set())
|
|
b._start_mtp_crash_watchdog()
|
|
b._stop_mtp_crash_watchdog() # what _kill_process does first
|
|
proc.die()
|
|
assert not fired.wait(timeout = 2)
|
|
assert b._mtp_watchdog_thread is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mutate",
|
|
[
|
|
lambda b: setattr(b, "_mtp_runtime_fallback_active", False),
|
|
lambda b: setattr(b, "_process", None),
|
|
],
|
|
)
|
|
def test_crash_watchdog_not_armed_when_inapplicable(mutate):
|
|
# Only a launch actually running MTP+tensor with a live process arms it.
|
|
b = _recovery_backend()
|
|
b._process = _ToggleProcess()
|
|
mutate(b)
|
|
b._start_mtp_crash_watchdog()
|
|
assert b._mtp_watchdog_thread is None
|
|
|
|
|
|
def test_kill_process_stops_crash_watchdog(monkeypatch):
|
|
# _kill_process is the single deliberate-termination chokepoint; it must
|
|
# stop the watchdog so the planned kill isn't seen as a crash.
|
|
b = _recovery_backend()
|
|
proc = _ToggleProcess()
|
|
b._process = proc
|
|
fired = threading.Event()
|
|
monkeypatch.setattr(b, "_maybe_recover_from_mtp_crash", lambda *a, **k: fired.set())
|
|
b._start_mtp_crash_watchdog()
|
|
b._kill_process()
|
|
assert b._mtp_watchdog_thread is None
|
|
assert b._process is None
|
|
assert not fired.wait(timeout = 2)
|
|
|
|
|
|
def test_kill_process_stops_watchdog_before_terminate():
|
|
# Ordering matters: stop the watchdog before terminating so the watchdog's
|
|
# post-death stop re-check reliably sees a planned kill.
|
|
src = inspect.getsource(LlamaCppBackend._kill_process)
|
|
stop = src.find("_stop_mtp_crash_watchdog()")
|
|
term = src.find(".terminate(")
|
|
assert 0 <= stop < term, "must stop the watchdog before terminating"
|
|
|
|
|
|
def test_crash_watchdog_rechecks_stop_before_recovery():
|
|
# After a detected exit the watchdog re-checks the stop flag so a kill that
|
|
# raced in between the poll-wait and the poll-read can't fire recovery.
|
|
src = inspect.getsource(LlamaCppBackend._start_mtp_crash_watchdog)
|
|
check = src.find("stop.is_set()")
|
|
recover = src.find("_maybe_recover_from_mtp_crash")
|
|
assert 0 <= check < recover, "must re-check stop before recovering"
|
|
|
|
|
|
def test_load_model_arms_crash_watchdog():
|
|
# The healthy-load commit arms the watchdog for this load.
|
|
src = inspect.getsource(LlamaCppBackend.load_model)
|
|
assert "_start_mtp_crash_watchdog" in src
|
|
|
|
|
|
# ── tensor-mode allocation: conservative VRAM budget ─────────────────
|
|
|
|
|
|
def _kv_seeded_backend() -> LlamaCppBackend:
|
|
# Minimal GGUF metadata so _can_estimate_kv() is True (legacy KV path).
|
|
backend = LlamaCppBackend()
|
|
backend._n_layers = 32
|
|
backend._embedding_length = 4096
|
|
backend._n_heads = 32
|
|
backend._n_kv_heads = 8
|
|
backend._context_length = 131072
|
|
return backend
|
|
|
|
|
|
def test_fit_context_budget_frac_override_is_tighter():
|
|
backend = _kv_seeded_backend()
|
|
model_size = 8 * 1024**3
|
|
pool_mib = 24 * 1024 # tight enough that KV capping bites
|
|
|
|
fit_default = backend._fit_context_to_vram(131072, pool_mib, model_size, "f16")
|
|
fit_tp = backend._fit_context_to_vram(131072, pool_mib, model_size, "f16", budget_frac = 0.80)
|
|
assert fit_tp < 131072, "expected the context to be capped at this VRAM tier"
|
|
assert fit_tp <= fit_default, "a tighter budget must not allow MORE context"
|
|
# Omitting the override must reproduce the default budget exactly.
|
|
assert backend._fit_context_to_vram(131072, pool_mib, model_size, "f16") == fit_default
|
|
|
|
|
|
# ── unsupported-arch load failure -> clean message ───────────────────
|
|
|
|
|
|
def test_split_mode_tensor_arch_failure_message():
|
|
msg = LlamaCppBackend._classify_llama_start_failure(
|
|
"llama_model_create: LLAMA_SPLIT_MODE_TENSOR not implemented for "
|
|
"architecture 'deepseek2'",
|
|
None,
|
|
"unsloth/DeepSeek-V3-GGUF",
|
|
)
|
|
assert "Tensor parallelism is not supported" in msg
|
|
|
|
|
|
def test_unrelated_arch_failure_not_hijacked_by_tensor_message():
|
|
msg = LlamaCppBackend._classify_llama_start_failure(
|
|
"unknown model architecture: 'flux'", "/models/flux.gguf", None
|
|
)
|
|
assert "Tensor parallelism" not in msg
|
|
|
|
|
|
# ── _plan_tensor_parallel: the allocation math (pure, no model/GPU) ───
|
|
# Seeded full-attention KV (~128 KiB/token) via _kv_seeded_backend, so the
|
|
# context cap + split are deterministic. Asserts relationships rather than
|
|
# magic numbers so the KV estimate can evolve without breaking these.
|
|
|
|
_GB = 1024**3
|
|
_ASYM = [(0, 48000), (1, 24000)] # asymmetric pool, 72000 MiB
|
|
_SYM = [(0, 24000), (1, 24000)] # symmetric pool
|
|
|
|
|
|
def _plan(
|
|
model_gb,
|
|
target = 131072,
|
|
gpus = _ASYM,
|
|
mtp = False,
|
|
):
|
|
b = _kv_seeded_backend()
|
|
return b, b._plan_tensor_parallel(gpus, int(model_gb * _GB), target, mtp_engaged = mtp)
|
|
|
|
|
|
def _kv_budget_b(model_gb, gpus = _ASYM):
|
|
# No totals here, so usable is the legacy free*frac (keeps the 5% cushion).
|
|
reserve = LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
|
|
usable = sum(f * _CTX_FIT_VRAM_FRACTION for _, f in gpus)
|
|
return (usable - len(gpus) * reserve) * 1024 * 1024 - int(model_gb * _GB)
|
|
|
|
|
|
def test_tp_plan_weighted_split_on_asymmetric_big_model():
|
|
b, (ec, mac, gi, ts) = _plan(50)
|
|
reserve = b._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
|
|
assert gi == [0, 1]
|
|
# split weighted by (usable - buffer); with no totals usable is free*frac
|
|
assert ts == [
|
|
int(48000 * _CTX_FIT_VRAM_FRACTION - reserve),
|
|
int(24000 * _CTX_FIT_VRAM_FRACTION - reserve),
|
|
]
|
|
assert ec < 131072 # capped below native
|
|
|
|
|
|
def test_tp_plan_even_split_when_model_fits():
|
|
# A small model whose even share fits the smallest GPU -> llama.cpp's even
|
|
# default (None), which is safe for archs that crash on a weighted split.
|
|
_, (ec, mac, gi, ts) = _plan(4)
|
|
assert ts is None
|
|
|
|
|
|
def test_tp_plan_symmetric_gpus_use_even_split():
|
|
_, (ec, mac, gi, ts) = _plan(8, gpus = _SYM)
|
|
assert ts is None
|
|
|
|
|
|
def test_tp_plan_context_fits_pool_budget_no_oom():
|
|
b, (ec, mac, gi, ts) = _plan(50)
|
|
# the chosen context's KV must fit the pooled budget (weights + buffers)
|
|
assert b._estimate_kv_cache_bytes(ec) <= _kv_budget_b(50)
|
|
|
|
|
|
def test_tp_plan_uses_available_vram_not_wasteful():
|
|
# when the cap engages, the chosen context nearly fills the budget
|
|
b, (ec, mac, gi, ts) = _plan(50)
|
|
assert b._estimate_kv_cache_bytes(ec) >= 0.9 * _kv_budget_b(50)
|
|
|
|
|
|
def test_tp_plan_weights_exceed_pool_floors_context():
|
|
# 70 GB > pool minus per-GPU reserves -> floor (triggers layer fallback)
|
|
_, (ec, mac, gi, ts) = _plan(70)
|
|
assert ec == 2048
|
|
|
|
|
|
def test_tp_plan_floor_never_exceeds_explicit_small_context():
|
|
# An explicit context below the 2048 floor must not be raised: a caller
|
|
# asking for 1024 should not have KV sized for 2048 (avoidable OOM).
|
|
_, (ec, mac, gi, ts) = _plan(70, target = 1024) # weights exceed pool -> floor path
|
|
assert ec == 1024
|
|
_, (ec2, *_rest) = _plan(50, target = 1024) # cap path with a tiny budget
|
|
assert ec2 <= 1024
|
|
|
|
|
|
def test_tp_plan_explicit_context_honored_when_it_fits():
|
|
_, (ec, mac, gi, ts) = _plan(50, target = 8192)
|
|
assert ec == 8192
|
|
|
|
|
|
def test_tp_plan_explicit_context_capped_when_too_large():
|
|
_, (ec, mac, gi, ts) = _plan(50, target = 131072)
|
|
assert 2048 <= ec < 131072
|
|
|
|
|
|
def test_tp_plan_max_available_ctx_reports_native_not_explicit_ctx():
|
|
# An explicit small ctx caps effective_ctx but the UI ceiling
|
|
# (max_available_ctx) must reflect the native/hardware cap, not the request.
|
|
b = _kv_seeded_backend()
|
|
ec, mac, _gi, _ts = b._plan_tensor_parallel(_ASYM, int(50 * _GB), 8192, max_target_ctx = 131072)
|
|
_, native_mac, *_ = b._plan_tensor_parallel(_ASYM, int(50 * _GB), 131072)
|
|
assert ec == 8192 # explicit request honored for the load
|
|
assert mac == native_mac > ec # ceiling reflects the hardware cap
|
|
|
|
|
|
def test_tp_plan_mtp_reserves_extra_and_shrinks_context():
|
|
_, (ec_no, *_rest) = _plan(50)
|
|
_, (ec_mtp, *_rest) = _plan(50, mtp = True)
|
|
assert ec_mtp < ec_no
|
|
|
|
|
|
def test_tp_plan_no_kv_metadata_floors_context():
|
|
b = LlamaCppBackend() # no KV metadata -> can't size safely
|
|
ec, mac, gi, ts = b._plan_tensor_parallel(_ASYM, int(50 * _GB), 131072)
|
|
assert ec <= 4096
|
|
|
|
|
|
def test_tp_plan_single_gpu_never_splits():
|
|
# The toggle is a no-op without >= 2 GPUs (most dev/CI machines). Even if
|
|
# the planner is reached, it must not emit a tensor split.
|
|
b = _kv_seeded_backend()
|
|
ec, mac, gi, ts = b._plan_tensor_parallel([(0, 24000)], int(8 * _GB), 8192)
|
|
assert ts is None
|
|
assert gi == [0]
|
|
|
|
|
|
def test_tp_plan_zero_gpus_never_splits():
|
|
b = _kv_seeded_backend()
|
|
ec, mac, gi, ts = b._plan_tensor_parallel([], int(8 * _GB), 8192)
|
|
assert ts is None
|
|
assert gi == []
|
|
|
|
|
|
def test_tp_plan_drops_gpu_below_buffer_reserve():
|
|
# A GPU with less free VRAM than the per-device compute-buffer reserve
|
|
# can't host tensor mode; it's excluded, which here leaves <2 usable -> no
|
|
# split (and gpu_indices reflects only the usable device).
|
|
b = _kv_seeded_backend()
|
|
reserve = LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
|
|
ec, mac, gi, ts = b._plan_tensor_parallel([(0, 48000), (1, reserve - 1)], int(8 * _GB), 8192)
|
|
assert gi == [0]
|
|
assert ts is None
|
|
|
|
|
|
# ── route auto-fallback survives a *raised* tensor-load crash ─────────
|
|
# A tensor-incompatible model makes load_model RAISE (not return False); the
|
|
# /load fallback must catch it and retry with layer split (stripping --split-mode
|
|
# so the retry can't relaunch tensor), while a non-tensor load propagates.
|
|
|
|
|
|
class _RecordingLoader:
|
|
"""Fake ``attempt_load``: crashes whenever tensor mode is effectively
|
|
engaged (via the bool or a ``--split-mode`` in extras), like a real
|
|
tensor-incompatible model; succeeds on layer split."""
|
|
|
|
def __init__(self):
|
|
self.calls: list[tuple] = []
|
|
|
|
async def __call__(self, tensor_parallel, extra_args):
|
|
self.calls.append((tensor_parallel, list(extra_args) if extra_args else extra_args))
|
|
if resolve_tensor_parallel(extra_args, tensor_parallel):
|
|
raise RuntimeError("llama-server failed to start")
|
|
return True
|
|
|
|
|
|
def test_tensor_fallback_retries_layer_on_crash():
|
|
loader = _RecordingLoader()
|
|
ok = asyncio.run(
|
|
load_with_tensor_fallback(loader, requested_tensor = True, extra_args = None, label = "m")
|
|
)
|
|
assert ok is True
|
|
# tensor first (crashes), then layer split.
|
|
assert [c[0] for c in loader.calls] == [True, False]
|
|
|
|
|
|
def test_tensor_fallback_no_retry_on_success():
|
|
calls: list[bool] = []
|
|
|
|
async def _ok(tensor_parallel, extra_args):
|
|
calls.append(tensor_parallel)
|
|
return True
|
|
|
|
ok = asyncio.run(
|
|
load_with_tensor_fallback(_ok, requested_tensor = True, extra_args = None, label = "m")
|
|
)
|
|
assert ok is True
|
|
assert calls == [True] # no fallback when the tensor load succeeds
|
|
|
|
|
|
def test_tensor_fallback_retries_when_tensor_returns_false():
|
|
# load_model can signal failure by *returning False* (not only by raising);
|
|
# that must trigger the layer-split retry just like a crash does.
|
|
calls: list[bool] = []
|
|
|
|
async def _false_on_tensor(tensor_parallel, extra_args):
|
|
calls.append(tensor_parallel)
|
|
return not resolve_tensor_parallel(extra_args, tensor_parallel)
|
|
|
|
ok = asyncio.run(
|
|
load_with_tensor_fallback(
|
|
_false_on_tensor, requested_tensor = True, extra_args = None, label = "m"
|
|
)
|
|
)
|
|
assert ok is True
|
|
assert calls == [True, False]
|
|
|
|
|
|
def test_tensor_fallback_returns_false_when_both_attempts_fail():
|
|
# Tensor fails and the layer retry also fails -> the helper returns False so
|
|
# the route raises its own HTTP 500 (it does not crash mid-flight).
|
|
calls: list[bool] = []
|
|
|
|
async def _always_false(tensor_parallel, extra_args):
|
|
calls.append(tensor_parallel)
|
|
return False
|
|
|
|
ok = asyncio.run(
|
|
load_with_tensor_fallback(_always_false, requested_tensor = True, extra_args = None, label = "m")
|
|
)
|
|
assert ok is False
|
|
assert calls == [True, False] # tried tensor, then layer split
|
|
|
|
|
|
def test_tensor_fallback_skips_layer_retry_when_cancelled():
|
|
# load_model returns False on a user cancellation too. When cancelled() is
|
|
# True, the helper must NOT relaunch the load the user just cancelled.
|
|
calls: list[bool] = []
|
|
|
|
async def _false_on_tensor(tensor_parallel, extra_args):
|
|
calls.append(tensor_parallel)
|
|
return False
|
|
|
|
ok = asyncio.run(
|
|
load_with_tensor_fallback(
|
|
_false_on_tensor,
|
|
requested_tensor = True,
|
|
extra_args = None,
|
|
label = "m",
|
|
cancelled = lambda: True,
|
|
)
|
|
)
|
|
assert ok is False
|
|
assert calls == [True] # no layer-split retry after cancellation
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extras",
|
|
[
|
|
["--split-mode", "tensor", "-c", "4096"],
|
|
["-sm", "tensor", "-c", "4096"],
|
|
["--split-mode=tensor", "-c", "4096"],
|
|
["-sm=tensor", "-c", "4096"],
|
|
],
|
|
)
|
|
def test_tensor_fallback_strips_split_mode_from_extras_on_retry(extras):
|
|
# Tensor engaged via extras (boolean False); the retry must drop every
|
|
# --split-mode form (long/short, space/=) and force layer, keeping the user's
|
|
# other flags, else tensor is re-enabled and relaunches the crash.
|
|
loader = _RecordingLoader()
|
|
ok = asyncio.run(
|
|
load_with_tensor_fallback(loader, requested_tensor = False, extra_args = extras, label = "m")
|
|
)
|
|
assert ok is True
|
|
assert len(loader.calls) == 2
|
|
# User --split-mode replaced by an explicit layer override; -c kept.
|
|
assert loader.calls[1][1] == ["-c", "4096", "--split-mode", "layer"]
|
|
|
|
|
|
def test_tensor_fallback_env_tensor_retry_forces_layer(monkeypatch):
|
|
# Env-only tensor (toggle off, no --split-mode extra): load_model engages
|
|
# tensor via LLAMA_ARG_SPLIT_MODE and a tensor-incompatible model crashes. The
|
|
# wrapper must (1) recognise the env tensor request and retry, and (2) force
|
|
# --split-mode layer so the retry doesn't re-engage tensor via the still-set
|
|
# env and crash again (#6312).
|
|
monkeypatch.setenv("LLAMA_ARG_SPLIT_MODE", "tensor")
|
|
calls: list = []
|
|
|
|
async def _crash_when_effectively_tensor(tensor_parallel, extra_args):
|
|
calls.append(list(extra_args) if extra_args else extra_args)
|
|
# Mirror real load_model: env-aware tensor engagement crashes.
|
|
if _effective_tensor_parallel(extra_args, tensor_parallel):
|
|
raise RuntimeError("llama-server failed to start (tensor)")
|
|
return True
|
|
|
|
ok = asyncio.run(
|
|
load_with_tensor_fallback(
|
|
_crash_when_effectively_tensor,
|
|
requested_tensor = False,
|
|
extra_args = None,
|
|
label = "m",
|
|
)
|
|
)
|
|
assert ok is True
|
|
assert len(calls) == 2
|
|
# The forced layer override neutralises the inherited tensor env on retry.
|
|
assert calls[1] == ["--split-mode", "layer"]
|
|
|
|
|
|
def test_tensor_fallback_propagates_non_tensor_crash():
|
|
async def _always_raise(tensor_parallel, extra_args):
|
|
raise RuntimeError("bad model")
|
|
|
|
with pytest.raises(RuntimeError, match = "bad model"):
|
|
asyncio.run(
|
|
load_with_tensor_fallback(
|
|
_always_raise, requested_tensor = False, extra_args = None, label = "m"
|
|
)
|
|
)
|
|
|
|
|
|
# ── _plan_tensor_parallel: total-based headroom + ubatch (review fixes) ──
|
|
|
|
|
|
def test_tensor_caps_context_to_total_vram_budget():
|
|
# Partly-used 80 GB cards: 20 GB free each. With total_by_idx the planner must
|
|
# cap occupancy at 0.95*total (not spend the cushion the layer-split paths keep).
|
|
b = _kv_seeded_backend()
|
|
gpus = [(0, 20000), (1, 20000)]
|
|
totals = {0: 81920, 1: 81920}
|
|
model = int(18 * _GB)
|
|
with_total, *_ = b._plan_tensor_parallel(gpus, model, 131072, total_by_idx = totals)
|
|
without, *_ = b._plan_tensor_parallel(gpus, model, 131072)
|
|
assert with_total < without # total cap tightens the chosen context
|
|
|
|
MIB = 1024 * 1024
|
|
reserve = LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB # flat (no vocab dims)
|
|
pool_usable = sum(f - (1.0 - _CTX_FIT_VRAM_FRACTION) * totals[i] for i, f in gpus)
|
|
foot_total = (model + b._estimate_kv_cache_bytes(with_total, None)) / MIB + len(gpus) * reserve
|
|
foot_free = (model + b._estimate_kv_cache_bytes(without, None)) / MIB + len(gpus) * reserve
|
|
assert foot_total <= pool_usable + 2 # fix: fits the total-based budget
|
|
assert foot_free > pool_usable # old behavior over-spent the cushion
|
|
|
|
|
|
def test_tensor_unknown_total_keeps_fraction_cushion():
|
|
# A two-column nvidia-smi probe yields total 0. The planner must fall back to
|
|
# free*frac (keep the 5% cushion), like _select_gpus/_gpu_usable, not raw free,
|
|
# or it over-advertises context exactly where the PR is hardening the budget.
|
|
b = _kv_seeded_backend()
|
|
gpus = [(0, 20000), (1, 20000)]
|
|
MIB = 1024 * 1024
|
|
reserve = LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
|
|
model = int(18 * _GB)
|
|
ec_zero, *_ = b._plan_tensor_parallel(gpus, model, 131072, total_by_idx = {0: 0, 1: 0})
|
|
ec_none, *_ = b._plan_tensor_parallel(gpus, model, 131072)
|
|
assert ec_zero == ec_none # total 0 == total absent: both use free*frac
|
|
pool_free = sum(f for _, f in gpus)
|
|
foot = (model + b._estimate_kv_cache_bytes(ec_zero, None)) / MIB + len(gpus) * reserve
|
|
assert foot <= pool_free * _CTX_FIT_VRAM_FRACTION + 2 # within free*frac, not raw free
|
|
|
|
|
|
def test_tensor_reserve_scales_with_ubatch():
|
|
# A user --ubatch override must enlarge the per-device reserve -> less ctx room.
|
|
b = _kv_seeded_backend()
|
|
b._vocab_size = 152064 # enable the deterministic compute-buffer estimate
|
|
gpus = [(0, 16000), (1, 16000)]
|
|
model = int(18 * _GB)
|
|
small_ub, *_ = b._plan_tensor_parallel(gpus, model, 131072, n_ubatch = 512)
|
|
big_ub, *_ = b._plan_tensor_parallel(gpus, model, 131072, n_ubatch = 4096)
|
|
assert big_ub < small_ub
|
|
|
|
|
|
def test_plan_tensor_carries_unsized_mtp_flat_reserve():
|
|
# review run3 #1/#5: with a weights-only (KV-unsized) MTP reserve, the planner
|
|
# gets a non-None mtp_overhead_fn but must still subtract the flat unsized-KV
|
|
# cushion, or its binary search spends it on context. Passing the reserve must
|
|
# pick a strictly smaller context than passing 0.
|
|
b = _kv_seeded_backend()
|
|
gpus = [(0, 14000), (1, 14000)] # tight pool so the context is actually capped
|
|
model = int(8 * _GB)
|
|
weights_only = lambda c: 3 * _GB # noqa: E731 -- constant drafter weights, no KV term
|
|
ctx_no_flat, *_ = b._plan_tensor_parallel(
|
|
gpus,
|
|
model,
|
|
131072,
|
|
mtp_engaged = True,
|
|
mtp_overhead_fn = weights_only,
|
|
mtp_flat_reserve_bytes = 0,
|
|
)
|
|
ctx_flat, *_ = b._plan_tensor_parallel(
|
|
gpus,
|
|
model,
|
|
131072,
|
|
mtp_engaged = True,
|
|
mtp_overhead_fn = weights_only,
|
|
mtp_flat_reserve_bytes = 2 * _GB,
|
|
)
|
|
assert 0 < ctx_flat < ctx_no_flat
|
|
|
|
|
|
def test_tensor_admission_drops_gpu_below_usable_budget():
|
|
# A partly-used big card can clear the buffer reserve on raw free yet have no
|
|
# usable budget left (free - 0.05*total). Admit by usable budget: GPU 0 here is
|
|
# 6000 free on an 80 GB card -> usable 1904 < flat reserve 5120, so it's dropped
|
|
# (leaving <2 -> no split). Without total_by_idx, raw free 6000 >= 5120 admits it.
|
|
b = _kv_seeded_backend()
|
|
gpus = [(0, 6000), (1, 40000)]
|
|
totals = {0: 81920, 1: 81920}
|
|
_ec, _mac, gi, ts = b._plan_tensor_parallel(gpus, int(8 * _GB), 8192, total_by_idx = totals)
|
|
assert gi == [1] and ts is None # GPU 0 excluded on usable budget
|
|
_ec2, _mac2, gi_raw, _ts2 = b._plan_tensor_parallel(gpus, int(8 * _GB), 8192)
|
|
assert gi_raw == [0, 1] # raw free would have admitted both
|
|
|
|
|
|
def test_load_model_tensor_admission_and_capacity_gate_use_usable_budget():
|
|
# load_model is too entangled (subprocess + GPU probe) to drive end-to-end, so
|
|
# assert at the source level that the tensor prefilter admits on the usable
|
|
# budget (_gpu_usable), not raw free, and downgrades to layer split when the
|
|
# pooled budget can't hold weights + per-device compute buffers.
|
|
src = inspect.getsource(LlamaCppBackend.load_model)
|
|
assert "_gpu_usable(g) >= reserve_mib" in src # admit by usable budget
|
|
assert "g[1] >= reserve_mib" not in src # not raw free
|
|
assert "_tp_weight_budget_mib" in src # pooled-weight capacity gate
|
|
assert "falling back to layer split" in src # downgrade on overcommit
|
|
# The gate's required footprint must include the non-shrinkable MTP reserve,
|
|
# not weights alone, or a separate-drafter MTP load can still overcommit.
|
|
assert "_tp_mtp_floor" in src
|
|
assert "model_size + _tp_mtp_floor" in src
|
|
|
|
|
|
def test_load_model_tensor_floor_keeps_flat_reserve_for_weights_only():
|
|
# Tensor mode has no --fit valve, so a weights-only drafter (KV unsized) must
|
|
# keep the flat reserve as the draft-KV cushion, not just the byte weights
|
|
# (Finding H1, the tensor analog of the layer-split _mtp_kv_unsized handling).
|
|
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
|
# byte-only floor used only when KV is sizable (not the weights-only case)
|
|
assert "mtp_overhead_fnisnotNoneandnot_mtp_kv_unsized" in compact
|
|
# weights-only / dims-unavailable: flat reserve, never below the byte floor
|
|
assert "_tp_mtp_floor=max(" in compact
|
|
|
|
|
|
def test_load_model_reserves_pipeline_per_device_overhead():
|
|
# Layer split must reserve the fixed per-device overhead per EXTRA device so a
|
|
# tight multi-GPU split can't pin a context that OOMs a device (Finding A); k=1
|
|
# adds nothing.
|
|
assert LlamaCppBackend._PIPELINE_PER_DEVICE_OVERHEAD_MIB > 0
|
|
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
|
assert "def_subset_model_size(n_gpus:int)->int:" in compact
|
|
assert "max(0,n_gpus-1)*_pipeline_overhead_bytes" in compact
|
|
assert "_subset_model_size(n_gpus)" in compact # used in the layer-split fit
|
|
|
|
|
|
def test_load_model_restores_quantized_kv_on_tensor_downgrade():
|
|
# A quantized KV dropped for the tensor attempt must be restored if tensor
|
|
# downgrades to layer split (Finding D); captured once, restored at both the
|
|
# GPU-count and capacity-gate downgrades.
|
|
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
|
assert "_tensor_dropped_cache_type_kv=cache_type_kv" in compact # captured pre-null
|
|
assert compact.count("cache_type_kv=_tensor_dropped_cache_type_kv") >= 2 # restored
|