unsloth/studio/backend/tests/test_tensor_parallel.py
Daniel Han 6d27160dcc
Studio: graceful recovery ladder when llama-server hard-crashes at startup (#6291)
* 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>
2026-06-18 09:07:25 -07:00

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