* Studio: GPU memory dropdown — llama.cpp --fit on and manual gpu-layers/cpu-moe * Studio: simplify GPU memory changes (reuse ParamSlider, GPU_LAYERS_ALL, loadedGpuMemoryFields helper) * Studio: GPU picker — choose which GPUs a GGUF model loads on (gpu_ids) * Studio: simplify GPU picker (share /api/system fetch, validate gpu_ids) * Studio: GPU picker review fixes (gate relative indices, no cross-model leak, validate, types) * Studio: group GPU controls under a collapsible GPU section * Studio: GPU feature review fixes (fix fit-ctx test, behavior-test the floor, comment accuracy) * Studio: make GPU a top-level settings section (not nested under Model) * Studio: flatten GPU controls into the Model section, group by GPU/context/generation * Studio: move GPU Memory to the bottom of Model with its dependent controls beneath it * Studio: move GPU Memory below Tensor Parallelism and GPUs below GPU Memory * Studio: tighten GPU Memory and GPU Layers tooltip copy * Studio: fix fit-mode context slider track-click, restore GPU Memory tooltip, shorten fit dropdown label * Studio: GPU Memory tooltip one mode per line, briefer * Studio: note HIP_VISIBLE_DEVICES (ROCm) in the GPUs picker tooltip * Studio: narrow the GPU Memory dropdown to fit the shortened label * Studio: use 'llama.cpp --fit' in the GPU Memory tooltip for consistency * Studio: allow Tensor Parallelism in Manual GPU mode * Studio: graduated MoE-on-CPU offload (--n-cpu-moe) replacing the all-or-nothing toggle * Studio: size the MoE-offload slider for staged (deferred-load) models * Studio: share one GGUF header walk for the context-length and MoE-count readers * Studio: size the GPU Layers slider for staged models (one staged-header read) * Studio: move Tensor Parallelism below the GPUs picker * Studio: GPU split (--tensor-split) per-GPU model share in Manual mode * Studio: tolerate whitespace in GPU split input, move it below GPU Layers * Studio: rename the GPU split control to "Split ratio" * Studio: Split ratio sends explicit even input; fix blank=free-VRAM (not even) copy * Studio: tighten llama.cpp --fit VRAM margin with --fit-target 512 * Studio: GPU memory review fixes (rollback re-baseline, single-GPU TP gate, accurate copy) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: move Split ratio below MoE Layers on CPU * Studio: address PR review (fix GPU-info hydration race, share fit context-length across load paths) * Studio: address codex review (manual single-GPU TP guard, GPU-aware spec defaults in fit/manual, GGUF-only context/preference) * Studio: address codex review round 2 (gpu_present seed, single-GPU tensor-split guard, staged manual-knob reset, strip inherited offload flags) * Studio: address codex review round 3 (strip inherited --n-cpu-moe, CPU-fallback warning in Manual mode) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: address codex review round 4 (preserve pinned fit context across a later Apply) * Studio: address codex review round 5 (honor GPU picker for diffusion GGUFs, clear fit pin on cross-model switch) * Studio: preserve the pending GPU Memory mode when staging a model * Studio: pin diffusion GPU device order and reset GPU-memory state for diffusion loads * Studio: address codex review round 6 (fit-Auto rollback context, preserve manual non-tensor split modes, persist GPU mode on load not select) * Studio: persist the applied GPU Memory mode, not the requested one (skip diffusion loads) * Studio: replace Manual-mode split-ratio field with per-GPU layer sliders * Studio: clarify per-GPU layer split hint for tensor-parallel mode * Studio: address codex review round 7 (allow GGUF gpu_ids past the legacy guard, replay GPU-memory fields on respawn) * Studio: address codex review round 8 (size the validate preflight like the load in fit mode, across both load paths) * Studio: skip the training-OOM guard for llama.cpp --fit GGUF loads (they spill to RAM) * Studio: drop the now-redundant compare-path validate sizing (the --fit guard skip makes it moot) * Studio: address codex review round 9 (keep the training guard for fit loads, forward gpu_ids to validate, strip inherited manual tensor-split) * Studio: address codex review round 10 (gate GPU-memory adoption on is_gguf, record manual knobs only in Manual mode) * Studio: handle diffusion GGUFs symmetrically in the GPU Memory controls (preserve the standing mode preference, hide the inapplicable mode/TP controls) * Studio: remember the GPU Memory settings per model * Studio: consolidate --fit mode and Manual mode into a single Manual mode * Studio: preserve the per-GPU layer split across GPU Layers changes * Studio: trim overly long GPU Memory comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * address GPU memory config review comments * trim redundant GPU memory tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reconcile manual-mode TP drops with the #6659 drop-site invariants * Preserve quantized KV in manual --fit, charge GGUF companions in full, reconcile GPU pick on load * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clear stale GPU baseline on non-GGUF loads so it can't read as dirty * Fix no-context-shift test for the conditional -c flag * Credit manual GPU-layer offload for cached HF GGUFs * Reset per-model load knobs on GGUF quant switch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Strip inherited tensor-split when manual ratio is cleared * Match auto-load validation to safetensors placement * Reset editable manual knobs after Auto GGUF loads * Record a single device for diffusion GPU picks * Reset per-model GPU knobs before applying saved settings * Address review comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard manual tensor splits and keep remembered context on auto-load * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Snapshot compare knobs, seed splits from free VRAM, flag zero-offload loads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Exempt CPU-only loads from the guard floor and harden compare and reseed paths * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reach full offload from the layers slider and charge extras drafters in the guard * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Warm the GPU device cache before pick reconciles and disable staged GPU controls * Align the training guard with inherited extras, spec mode, and compare targets * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hide GPUs from companion-less zero-offload loads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Size diffusion picks per device, own manual offload flags, reject XPU picks * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop tensor flags at zero layers and exempt CPU-pinned drafters * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Allowlist the zero-layer tensor parallel drop site * Keep validate and load guards on the same extras and refresh stale baselines * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop mismatched manual tensor splits before launch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gate XPU picks on the real backend field and harden split and hydration paths * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Weight full GPUs as zero, clamp split shares, and refine the zero-layer mask gate * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Carry fit context across mode changes and align drafter and picker gates * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Catch variant switches, uncached diffusion repos, and text-only mmproj skips * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check companions on the first device and size native and remote zero-layer loads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Replace the training guard's precise VRAM modeling with a conservative bound * Baseline context pins on non-GGUF hydration and reprobe list-seeded staged GGUFs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Size manual splits by their largest share and preserve resolved context from Default * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Default-deny unsized required companions and price KV at the effective cache dtype * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reserve MTP draft KV and MLA target-copy in the training guard * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Size tensor-parallel loads per device and show GPU controls for native GGUFs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reserve MTP overhead for uncached remote GGUFs and the mmproj runtime factor * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the training-coexistence VRAM estimation this PR added * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gate remembered load settings to GGUF picks * Lock the remaining load-time controls during a staged load * Clear the stale native-path token on compare loads * Drop a stale guard reference from the zero-offload masking comment * Seed GPU baselines from the rollback response and drop never-emitted offload flags * Match validate's training guard to load and keep the native reload token * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim verbose GPU-memory comments * Thread the variants header walk off the event loop, honor device pins on zero-offload, and hold staged GPU edits * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Honor manual placement and classify pinned zero-offload loads * Close diffusion admission and status hydration gaps * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check the actual diffusion GPU during training * Align staged baselines and manual reload dedupe * Fix GGUF placement and rollback state * Harden manual GGUF placement boundaries * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused resolve_tensor_parallel import in llama_cpp.py The name is used only in llama_server_args.py, routes/inference.py, and tests, not in llama_cpp.py; the unused hoisted import trips the import-hoist verifier in the source-lint CI job. * Fix diffusion GPU dedup and training guard for non-numeric device tokens The diffusion runner drives only its single lowest device and the backend records that one device (self._gpu_ids = [sorted(gpu_ids)[0]]), but the reload dedupe compared it against the full requested list, so a multi-GPU pick that resolves to the same device forced a needless reload. Normalize the request the same way for a loaded diffusion model in both _already_in_target_state and the route _request_matches_loaded_settings. The chat-during-training coexistence guard called int() on the single-device token and hard-rejected when it could not parse. A non-numeric token (a CUDA UUID / MIG handle) now sizes against the whole visible pool like the GGUF guard instead of falsely blocking the load, and an empty token (a CPU-only runner such as a CPU diffusion GGUF) is allowed outright since it uses no GPU VRAM. * Tighten comments added by the GPU memory config changes * Harden GGUF placement from independent review: VRAM sizing, diffusion TP reset, tensor_split validation - Training coexistence guard: a single-device runner pinned through an unresolvable UUID/MIG token was sized against the aggregate visible-VRAM pool, so a load could pass on capacity it cannot use and then OOM active training. Size against the worst-case visible device (min free) instead, keeping the guard's documented default-deny contract. The empty-token (CPU-only runner) allow path is unchanged. - Diffusion startup: _start_diffusion_server now resets self._tensor_parallel to False alongside the other placement resets. A prior tensor-parallel chat load (process killed but not fully unload-reset) otherwise left /status misreporting tensor parallelism and made an identical diffusion re-Apply reload against the stale state. - tensor_split: reject negative / non-finite / all-zero splits up front. They were dropped at launch but still compared raw in the reload dedupe, so an identical Apply reloaded indefinitely. - Tests: the shared httpx stub was incomplete and, installed via setdefault before real httpx loaded, broke a combined pytest run (collection errors on httpx.Response). Import the real installed httpx instead. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
879 lines
37 KiB
Python
879 lines
37 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 GPU Memory mode dropdown.
|
|
|
|
The dropdown threads a single ``gpu_memory_mode`` ("auto" | "manual") from the
|
|
chat UI through the load request. "manual" lets the user own the offload: with
|
|
``gpu_layers < 0`` (Auto, the default) it hands all memory management to
|
|
llama.cpp's ``--fit on`` (no CUDA/HIP device masking, no context auto-reduce, no
|
|
gpu-layer or tensor-split planning); with ``gpu_layers >= 0`` it pins the layers
|
|
and MoE offload itself (``--fit off``). These tests pin:
|
|
|
|
* the pydantic request/response/status contract (snake_case key, default
|
|
"auto", unknown values rejected),
|
|
* the backend ``gpu_memory_mode`` property and its reset on unload,
|
|
* the ``_already_in_target_state`` reload-detection branch, and
|
|
* that the manual + Auto-layers branch in ``load_model`` empties the probed
|
|
GPU set and drops tensor parallelism so the selection below no-ops, while
|
|
the explicit-offload branch emits ``--gpu-layers`` / ``--fit off``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import sys
|
|
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 is a real, installed backend dependency: import it so the genuine module
|
|
# is in sys.modules. A hand-rolled stub here is inevitably incomplete and, since
|
|
# setdefault installs it before real httpx loads, would poison a combined pytest
|
|
# run -- routes/inference references httpx.Response (and other attrs) at def time.
|
|
import httpx # noqa: F401
|
|
|
|
from core.inference import llama_cpp as llama_cpp_module
|
|
from core.inference.llama_cpp import LlamaCppBackend
|
|
from models.inference import (
|
|
InferenceStatusResponse,
|
|
LoadRequest,
|
|
LoadResponse,
|
|
)
|
|
|
|
|
|
# ── Pydantic contract (snake_case key, default "auto") ───────────────
|
|
|
|
|
|
def test_load_request_defaults_gpu_memory_mode_auto():
|
|
assert LoadRequest(model_path = "owner/repo").gpu_memory_mode == "auto"
|
|
|
|
|
|
def test_load_request_round_trips_json_key():
|
|
req = LoadRequest.model_validate({"model_path": "owner/repo", "gpu_memory_mode": "manual"})
|
|
assert req.gpu_memory_mode == "manual"
|
|
assert req.model_dump()["gpu_memory_mode"] == "manual"
|
|
|
|
|
|
def test_load_request_rejects_unknown_mode():
|
|
with pytest.raises(ValueError):
|
|
LoadRequest(model_path = "owner/repo", gpu_memory_mode = "bogus")
|
|
|
|
|
|
@pytest.mark.parametrize("model_cls", [LoadResponse, InferenceStatusResponse])
|
|
def test_response_models_emit_gpu_memory_mode(model_cls):
|
|
if model_cls is LoadResponse:
|
|
default = model_cls(
|
|
status = "loaded",
|
|
model = "owner/repo",
|
|
display_name = "repo",
|
|
inference = {},
|
|
)
|
|
manual = model_cls(
|
|
status = "loaded",
|
|
model = "owner/repo",
|
|
display_name = "repo",
|
|
inference = {},
|
|
gpu_memory_mode = "manual",
|
|
)
|
|
else:
|
|
default = model_cls()
|
|
manual = model_cls(gpu_memory_mode = "manual")
|
|
assert default.model_dump()["gpu_memory_mode"] == "auto"
|
|
assert manual.model_dump()["gpu_memory_mode"] == "manual"
|
|
|
|
|
|
# ── 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_gpu_memory_mode_property_defaults_auto():
|
|
assert LlamaCppBackend().gpu_memory_mode == "auto"
|
|
|
|
|
|
def test_gpu_memory_mode_property_reflects_field():
|
|
backend = LlamaCppBackend()
|
|
backend._gpu_memory_mode = "manual"
|
|
assert backend.gpu_memory_mode == "manual"
|
|
|
|
|
|
def test_unload_resets_gpu_memory_mode():
|
|
backend = LlamaCppBackend()
|
|
backend._process = _FakeProcess()
|
|
backend._gpu_memory_mode = "manual"
|
|
backend.unload_model()
|
|
assert backend.gpu_memory_mode == "auto"
|
|
|
|
|
|
# ── _already_in_target_state reload-detection branch ─────────────────
|
|
|
|
|
|
def _loaded_backend(gpu_memory_mode: str) -> 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._gpu_memory_mode = gpu_memory_mode
|
|
return backend
|
|
|
|
|
|
def _target_state(backend: LlamaCppBackend, gpu_memory_mode: str) -> 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,
|
|
gpu_memory_mode = gpu_memory_mode,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["auto", "manual"])
|
|
def test_already_in_target_state_matches_same_mode(mode):
|
|
assert _target_state(_loaded_backend(mode), mode) is True
|
|
|
|
|
|
@pytest.mark.parametrize("loaded,requested", [("auto", "manual"), ("manual", "auto")])
|
|
def test_already_in_target_state_reloads_on_mode_change(loaded, requested):
|
|
# Flipping the dropdown either direction must force a reload so the command
|
|
# is rebuilt with/without the Unsloth GPU masking.
|
|
assert _target_state(_loaded_backend(loaded), requested) is False
|
|
|
|
|
|
def test_already_in_target_state_ignores_mode_for_diffusion():
|
|
# The diffusion runner is mode-agnostic (always "auto"), so a standing manual
|
|
# preference must not force a needless reload.
|
|
backend = _loaded_backend("auto")
|
|
backend._is_diffusion = True
|
|
assert _target_state(backend, "manual") is True
|
|
|
|
|
|
# ── load_model: manual + Auto layers bypasses Unsloth GPU management ──
|
|
|
|
|
|
def _load_model_source() -> str:
|
|
return inspect.getsource(llama_cpp_module.LlamaCppBackend.load_model)
|
|
|
|
|
|
def test_auto_layers_branch_empties_gpus_and_drops_tensor_parallel():
|
|
# Emptying the probed set makes the selection / TP planning below no-op, so
|
|
# gpu_indices stays None and use_fit True (--fit on).
|
|
src = _load_model_source()
|
|
gate = src.find('if gpu_memory_mode == "manual" and gpu_layers < 0:')
|
|
assert gate != -1, "load_model must branch on manual + Auto layers (gpu_layers < 0)"
|
|
block = src[gate : gate + 1400]
|
|
assert "gpus = []" in block, "Auto-layers branch must empty the probed GPU set"
|
|
# --fit aborts under --split-mode tensor, so a raw-extras split-mode is stripped.
|
|
assert "strip_split_mode_only(extra_args)" in block
|
|
assert "requested_ctx if requested_ctx > 0 else 0" in block
|
|
# The branch sits before GPU selection assigns gpu_indices; --fit on is its emission.
|
|
assert gate < src.find("gpu_indices, use_fit = None, True")
|
|
assert 'cmd.extend(["--fit", "on"])' in src
|
|
# TP drops for this path, but at a guard BEFORE the quantized-KV cache-drop, so
|
|
# a requested quantized cache survives into the --fit load.
|
|
tp_drop = src.find('if tensor_parallel and gpu_memory_mode == "manual" and gpu_layers < 0:')
|
|
assert tp_drop != -1, "manual + Auto layers must drop tensor_parallel"
|
|
assert "tensor_parallel = False" in src[tp_drop : tp_drop + 400]
|
|
cache_drop = src.find("Tensor parallelism requires a non-quantized KV cache")
|
|
assert cache_drop != -1
|
|
assert (
|
|
tp_drop < cache_drop
|
|
), "TP must drop before the cache-drop so a quantized KV survives --fit"
|
|
|
|
|
|
def test_auto_layers_never_sends_ctx_size_zero():
|
|
# Sending "-c 0" sets fit_params_min_ctx = UINT32_MAX in llama.cpp, pinning
|
|
# the full native context and disabling --fit's reduction. So the base cmd
|
|
# must never carry -c, "-c 0" is emitted only outside the Auto-layers (--fit)
|
|
# case, and a positive context is passed through (which --fit optimizes
|
|
# layers around).
|
|
src = _load_model_source()
|
|
base_start = src.find("cmd = [")
|
|
base_end = src.find("\n ]", base_start)
|
|
base_block = src[base_start:base_end]
|
|
assert '"-c"' not in base_block, "-c must be conditional, not in the base cmd list"
|
|
assert 'cmd.extend(["-c", str(effective_ctx)])' in src, "positive ctx must pass -c"
|
|
assert 'auto_fit = gpu_memory_mode == "manual" and gpu_layers < 0' in src
|
|
zero = src.find('cmd.extend(["-c", "0"])')
|
|
assert zero != -1, '"-c 0" emission must exist outside the Auto-layers case'
|
|
guard = src.rfind("elif not auto_fit:", 0, zero)
|
|
assert guard != -1 and zero - guard < 120, '"-c 0" must sit under the not-auto_fit guard'
|
|
|
|
|
|
def test_manual_mode_clears_inherited_main_model_placement_env():
|
|
env = {name: "inherited" for name in LlamaCppBackend._MANUAL_PLACEMENT_ENV_VARS}
|
|
env["LLAMA_ARG_N_GPU_LAYERS_DRAFT"] = "7"
|
|
env["UNRELATED"] = "kept"
|
|
|
|
LlamaCppBackend._clear_manual_placement_env(env)
|
|
|
|
assert not (set(env) & set(LlamaCppBackend._MANUAL_PLACEMENT_ENV_VARS))
|
|
assert env["LLAMA_ARG_N_GPU_LAYERS_DRAFT"] == "7"
|
|
assert env["UNRELATED"] == "kept"
|
|
|
|
|
|
def test_load_model_sanitizes_manual_env_after_building_child_env():
|
|
src = _load_model_source()
|
|
env_build = src.find("env = self._llama_server_env_for_binary(binary)")
|
|
env_clear = src.find("self._clear_manual_placement_env(env)", env_build)
|
|
launch = src.find("subprocess.Popen", env_build)
|
|
assert env_build != -1
|
|
assert env_build < env_clear < launch
|
|
|
|
|
|
# ── Manual offload (--gpu-layers + --fit off + --n-cpu-moe) ───────────
|
|
|
|
|
|
def test_load_request_accepts_manual():
|
|
req = LoadRequest(
|
|
model_path = "owner/repo",
|
|
gpu_memory_mode = "manual",
|
|
gpu_layers = 20,
|
|
n_cpu_moe = 8,
|
|
tensor_split = [2, 1],
|
|
)
|
|
assert req.gpu_memory_mode == "manual"
|
|
assert req.gpu_layers == 20
|
|
assert req.n_cpu_moe == 8
|
|
assert req.tensor_split == [2, 1]
|
|
|
|
|
|
def test_load_request_manual_defaults():
|
|
req = LoadRequest(model_path = "owner/repo")
|
|
assert req.gpu_layers == -1
|
|
assert req.n_cpu_moe == 0
|
|
assert req.tensor_split is None
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [[0, 0], [-1, 2], [float("inf"), 1], [float("nan"), 1]])
|
|
def test_load_request_rejects_degenerate_tensor_split(bad):
|
|
# A negative/non-finite/all-zero split is dropped at launch but compared raw
|
|
# in the reload dedupe, so it would reload forever -- reject it up front.
|
|
with pytest.raises(ValueError):
|
|
LoadRequest(model_path = "owner/repo", tensor_split = bad)
|
|
|
|
|
|
@pytest.mark.parametrize("good", [[2, 1], [1, 1], [], None])
|
|
def test_load_request_accepts_valid_tensor_split(good):
|
|
assert LoadRequest(model_path = "owner/repo", tensor_split = good).tensor_split == good
|
|
|
|
|
|
def test_route_normalizes_explicit_extras_before_reload_dedupe():
|
|
route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8")
|
|
load_impl = route_src[route_src.index("async def _load_model_impl") :]
|
|
strip = load_impl.index("_stripped_explicit = strip_shadowing_flags")
|
|
normalize = load_impl.index(
|
|
'request = request.model_copy(update = {"llama_extra_args": extra_llama_args})'
|
|
)
|
|
dedupe = load_impl.index("and _request_matches_loaded_settings(")
|
|
assert strip < normalize < dedupe
|
|
|
|
|
|
@pytest.mark.parametrize("model_cls", [LoadResponse, InferenceStatusResponse])
|
|
def test_response_models_emit_manual_fields(model_cls):
|
|
if model_cls is LoadResponse:
|
|
obj = model_cls(
|
|
status = "loaded",
|
|
model = "owner/repo",
|
|
display_name = "repo",
|
|
inference = {},
|
|
gpu_memory_mode = "manual",
|
|
gpu_layers = 20,
|
|
n_cpu_moe = 8,
|
|
tensor_split = [2, 1],
|
|
n_layers = 32,
|
|
n_moe_layers = 32,
|
|
)
|
|
else:
|
|
obj = model_cls(
|
|
gpu_memory_mode = "manual",
|
|
gpu_layers = 20,
|
|
n_cpu_moe = 8,
|
|
tensor_split = [2, 1],
|
|
n_layers = 32,
|
|
n_moe_layers = 32,
|
|
)
|
|
dumped = obj.model_dump()
|
|
assert dumped["gpu_memory_mode"] == "manual"
|
|
assert dumped["gpu_layers"] == 20
|
|
assert dumped["n_cpu_moe"] == 8
|
|
assert dumped["tensor_split"] == [2, 1]
|
|
assert dumped["n_layers"] == 32
|
|
assert dumped["n_moe_layers"] == 32
|
|
|
|
|
|
def test_manual_properties_default_and_reflect_and_reset():
|
|
backend = LlamaCppBackend()
|
|
assert backend.gpu_layers == -1 and backend.n_cpu_moe == 0
|
|
assert backend.tensor_split is None
|
|
backend._gpu_layers = 20
|
|
backend._n_cpu_moe = 8
|
|
backend._tensor_split = [2, 1]
|
|
assert backend.gpu_layers == 20 and backend.n_cpu_moe == 8
|
|
assert backend.tensor_split == [2, 1]
|
|
backend._process = _FakeProcess()
|
|
backend.unload_model()
|
|
assert backend.gpu_layers == -1 and backend.n_cpu_moe == 0
|
|
assert backend.tensor_split is None
|
|
|
|
|
|
def test_n_moe_layers_property():
|
|
# 0 for a dense model (hides the slider); block_count for all-MoE;
|
|
# block_count - leading_dense otherwise (GLM-4.7-Flash: 47 - 1 -> 46).
|
|
b = LlamaCppBackend()
|
|
b._n_layers = 36
|
|
b._n_experts = None
|
|
assert b.n_moe_layers == 0
|
|
b._n_experts = 128
|
|
b._leading_dense_block_count = None
|
|
assert b.n_moe_layers == 36
|
|
b._n_layers = 47
|
|
b._leading_dense_block_count = 1
|
|
assert b.n_moe_layers == 46
|
|
|
|
|
|
def _target_state_manual(
|
|
backend,
|
|
*,
|
|
gpu_layers,
|
|
n_cpu_moe,
|
|
tensor_split = None,
|
|
):
|
|
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,
|
|
gpu_memory_mode = "manual",
|
|
gpu_layers = gpu_layers,
|
|
n_cpu_moe = n_cpu_moe,
|
|
tensor_split = tensor_split,
|
|
)
|
|
|
|
|
|
def test_manual_reloads_on_gpu_layers_or_n_cpu_moe_or_split_change():
|
|
backend = _loaded_backend("manual")
|
|
backend._gpu_layers = 20
|
|
backend._n_cpu_moe = 0
|
|
backend._tensor_split = None
|
|
# Same knobs -> no reload.
|
|
assert _target_state_manual(backend, gpu_layers = 20, n_cpu_moe = 0) is True
|
|
# Changed layer count -> reload.
|
|
assert _target_state_manual(backend, gpu_layers = 16, n_cpu_moe = 0) is False
|
|
# Changed MoE offload -> reload.
|
|
assert _target_state_manual(backend, gpu_layers = 20, n_cpu_moe = 8) is False
|
|
# Added a GPU split -> reload.
|
|
assert _target_state_manual(backend, gpu_layers = 20, n_cpu_moe = 0, tensor_split = [2, 1]) is False
|
|
# Same GPU split -> no reload.
|
|
backend._tensor_split = [2, 1]
|
|
assert _target_state_manual(backend, gpu_layers = 20, n_cpu_moe = 0, tensor_split = [2, 1]) is True
|
|
|
|
|
|
def test_auto_layers_reload_tracks_only_gpu_layers():
|
|
# Under Auto (gpu_layers < 0) the MoE/split knobs don't apply, so a leftover
|
|
# request value must not reload -- only a gpu_layers change (Auto -> pinned) does.
|
|
backend = _loaded_backend("manual")
|
|
backend._gpu_layers = -1
|
|
backend._n_cpu_moe = 0
|
|
backend._tensor_split = None
|
|
# Same Auto, leftover MoE/split in the request -> still no reload.
|
|
assert _target_state_manual(backend, gpu_layers = -1, n_cpu_moe = 8, tensor_split = [2, 1]) is True
|
|
# Auto -> explicit offload reloads.
|
|
assert _target_state_manual(backend, gpu_layers = 20, n_cpu_moe = 0) is False
|
|
|
|
|
|
def test_manual_offload_emits_gpu_layers_fit_off_and_n_cpu_moe():
|
|
src = _load_model_source()
|
|
gate = src.find('elif gpu_memory_mode == "manual":')
|
|
assert gate != -1, "load_model must have an explicit-offload manual branch"
|
|
block = src[gate : gate + 700]
|
|
# Empties the probed set (skips the planner) but keeps the user's TP choice
|
|
# (only the Auto-layers branch above drops TP).
|
|
assert "gpus = []" in block
|
|
assert "tensor_parallel = False" not in block
|
|
# The cmd emits the layer count with fit disabled, gated on gpu_layers >= 0.
|
|
assert 'if gpu_memory_mode == "manual" and gpu_layers >= 0:' in src
|
|
assert 'cmd.extend(["--gpu-layers", str(gpu_layers), "--fit", "off"])' in src
|
|
# MoE offload uses --n-cpu-moe via _resolve_cpu_moe_flag (tested behaviorally below).
|
|
assert "_resolve_cpu_moe_flag(" in src
|
|
assert 'cmd.extend(["--n-cpu-moe", str(moe_flag)])' in src
|
|
# A count requested on a dense model is never emitted, so it must also be
|
|
# dropped from the recorded state -- else /status and /load report a count
|
|
# llama-server never received (same rule as the tensor-split drop below).
|
|
moe_emit = src.find('cmd.extend(["--n-cpu-moe", str(moe_flag)])')
|
|
assert "elif n_cpu_moe:" in src[moe_emit : moe_emit + 300]
|
|
assert "self._n_cpu_moe = 0" in src[moe_emit : moe_emit + 300]
|
|
# The offload path forces use_fit False so --fit-ctx is never added under --fit off.
|
|
emit = src.find('cmd.extend(["--gpu-layers", str(gpu_layers), "--fit", "off"])')
|
|
assert "use_fit = False" in src[src.rfind("\n", 0, emit) - 200 : emit + 80]
|
|
|
|
|
|
def test_status_reports_requested_context_length():
|
|
# The hydration path re-seeds a Manual+Auto context pin from the REQUESTED
|
|
# n_ctx (0 = Auto); context_length only exposes the resolved value.
|
|
assert "requested_context_length" in InferenceStatusResponse.model_fields
|
|
s = InferenceStatusResponse(requested_context_length = 8192)
|
|
assert s.model_dump()["requested_context_length"] == 8192
|
|
assert InferenceStatusResponse().model_dump()["requested_context_length"] is None
|
|
# The /status route must actually wire it from the backend (a declared-but-
|
|
# never-populated field would leave hydration silently reverting the pin).
|
|
from pathlib import Path as _P
|
|
|
|
route_src = (_P(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8")
|
|
assert "requested_context_length = llama_backend.requested_n_ctx" in route_src
|
|
|
|
|
|
def test_manual_offload_emits_tensor_split():
|
|
# The offload path emits --tensor-split from the per-GPU shares, only when
|
|
# provided, with >1 GPU in use, AND matching that count (a stale ratio on a
|
|
# narrowed picker or a mismatched direct-API list must not emit -- llama-
|
|
# server aborts on a split/GPU-count mismatch).
|
|
src = _load_model_source()
|
|
assert "if tensor_split and _split_gpus > 1:" in src
|
|
# Emit only on a length match AND a positive sanitized total: a mismatched
|
|
# or all-zero split aborts llama-server / assigns nothing, so it's dropped.
|
|
# The emitted list is the sanitized one (clamping tested behaviorally below).
|
|
assert "_sanitized_split = self._sanitize_tensor_split(tensor_split)" in src
|
|
assert "if len(_sanitized_split) == _split_gpus and _split_total > 0:" in src
|
|
assert '"--tensor-split"' in src
|
|
# Joined as a comma list (e.g. "2,1") within the explicit-offload cmd branch.
|
|
gate = src.find('if gpu_memory_mode == "manual" and gpu_layers >= 0:')
|
|
nxt = src.find("elif use_fit:", gate)
|
|
assert '","' in src[gate:nxt] and "tensor_split" in src[gate:nxt]
|
|
# A split with a single effective GPU is never emitted, so it must also be
|
|
# dropped from the recorded state -- else /status and /load report a ratio
|
|
# llama-server never received and the dedupe baseline preserves it.
|
|
assert "elif tensor_split:" in src[gate:nxt]
|
|
drop = src.find("elif tensor_split:", gate, nxt)
|
|
assert "self._tensor_split = None" in src[drop : drop + 250]
|
|
|
|
|
|
def test_sanitize_tensor_split_clamps_negative_and_non_finite():
|
|
# Negative entries would launch a placement different from the ratio the
|
|
# UI showed; inf passes a plain > 0 total gate and would emit
|
|
# "--tensor-split inf,..." (llama.cpp normalizes shares by the running
|
|
# total, so an inf poisons the shares from that entry on). Both clamp to 0.
|
|
sanitize = LlamaCppBackend._sanitize_tensor_split
|
|
assert sanitize([2, 1]) == [2.0, 1.0]
|
|
assert sanitize([-1, 2]) == [0.0, 2.0]
|
|
assert sanitize([float("inf"), 1]) == [0.0, 1.0]
|
|
assert sanitize([float("nan"), 1]) == [0.0, 1.0]
|
|
# All-zero survives sanitization; the call site's total gate drops it.
|
|
assert sanitize([0, 0]) == [0.0, 0.0]
|
|
# Unreadable input -> []; the call site's length gate drops it.
|
|
assert sanitize(["x", 1]) == []
|
|
assert sanitize([10**400, 1]) == []
|
|
|
|
|
|
def test_zero_offload_mask_honors_device_pin_spellings():
|
|
# A user device pin must keep the GPUs visible: llama-server aborts on a
|
|
# pin it can't see ('error: invalid device'). The pin can arrive as
|
|
# --device or its -dev alias, as the draft forms (parsed even with no
|
|
# drafter loaded), or as an inherited LLAMA_ARG_DEVICE env var.
|
|
load_src = _load_model_source()
|
|
assert "self._zero_offload_keeps_gpu_visible(cmd, env)" in load_src
|
|
block = inspect.getsource(LlamaCppBackend._cmd_has_gpu_device_pin)
|
|
for flag in (
|
|
'"--device"',
|
|
'"-dev"',
|
|
'"--spec-draft-device"',
|
|
'"-devd"',
|
|
'"--device-draft"',
|
|
):
|
|
assert flag in block
|
|
assert '"LLAMA_ARG_DEVICE"' in block
|
|
|
|
|
|
def test_resolve_cpu_moe_flag():
|
|
# Clamp the requested MoE-layer count to the model's MoE layers, then offset
|
|
# past leading dense layers (--n-cpu-moe counts from layer 0).
|
|
R = LlamaCppBackend._resolve_cpu_moe_flag
|
|
assert R(0, 40, 0) is None # nothing requested
|
|
assert R(8, 0, 0) is None # dense model (no MoE layers)
|
|
assert R(8, 40, 0) == 8 # all-MoE: direct
|
|
assert R(100, 40, 0) == 40 # clamp to the MoE layer count
|
|
# GLM-4.7-Flash (deepseek2): block_count 47, leading_dense 1, n_moe 46.
|
|
assert R(5, 46, 1) == 6 # offset past the 1 dense layer
|
|
assert R(46, 46, 1) == 47 # all MoE on CPU == block_count
|
|
|
|
|
|
def test_manual_allows_tensor_parallel_via_split_mode():
|
|
# Manual offload keeps the user's TP choice but skips the memory-based planner
|
|
# (plan_tp excludes manual, so its empty gpu set can't downgrade TP). The
|
|
# --split-mode tensor emission gates on tensor_parallel alone, so manual
|
|
# reaches it -- with tp_tensor_split None it's an even split (no
|
|
# --tensor-split). --fit off means no fit/tensor abort.
|
|
src = _load_model_source()
|
|
assert 'plan_tp = tensor_parallel and gpu_memory_mode != "manual"' in src
|
|
assert "if plan_tp:" in src
|
|
assert "if plan_tp and len(tp_gpus) < 2:" in src
|
|
sm = src.find('cmd.extend(["--split-mode", "tensor"])')
|
|
assert sm != -1, "TP must emit --split-mode tensor"
|
|
guard = src.rfind("if tensor_parallel:", 0, sm)
|
|
assert guard != -1 and sm - guard < 200, "split-mode gates on tensor_parallel"
|
|
# The tensor-split is only emitted for a planned (non-even) split, which
|
|
# manual never produces, so manual stays an even split.
|
|
assert "if tp_tensor_split and len(tp_tensor_split) > 1:" in src
|
|
|
|
|
|
def test_fit_sets_target_margin():
|
|
# Manual + Auto (auto_fit) tightens the per-device VRAM margin to 512 MiB.
|
|
caps = {"supports_fit_target": True}
|
|
flags = LlamaCppBackend._ctx_integrity_flags(1, True, True, 0, 0, caps)
|
|
assert flags[flags.index("--fit-target") + 1] == "512"
|
|
# Not emitted on the legacy auto path (fit on but not auto_fit): -c 0 pins
|
|
# native there, so the tighter margin must not ride along.
|
|
assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, True, False, 0, 0, caps)
|
|
# Not emitted when fit is off.
|
|
assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, False, False, 0, 0, caps)
|
|
# Not emitted when the binary lacks support.
|
|
assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(
|
|
1, True, True, 0, 0, {"supports_fit_target": False}
|
|
)
|
|
|
|
|
|
# ── GPU picker (gpu_ids -> CUDA_VISIBLE_DEVICES) ─────────────────────
|
|
|
|
|
|
def test_load_request_accepts_gpu_ids():
|
|
req = LoadRequest(model_path = "owner/repo", gpu_ids = [1, 0])
|
|
assert req.gpu_ids == [1, 0]
|
|
assert LoadRequest(model_path = "owner/repo").gpu_ids is None
|
|
|
|
|
|
@pytest.mark.parametrize("model_cls", [LoadResponse, InferenceStatusResponse])
|
|
def test_response_models_emit_gpu_ids(model_cls):
|
|
if model_cls is LoadResponse:
|
|
obj = model_cls(status = "loaded", model = "m", display_name = "m", inference = {}, gpu_ids = [1])
|
|
else:
|
|
obj = model_cls(gpu_ids = [1])
|
|
assert obj.model_dump()["gpu_ids"] == [1]
|
|
|
|
|
|
def test_gpu_ids_property_default_and_reset():
|
|
backend = LlamaCppBackend()
|
|
assert backend.gpu_ids is None
|
|
backend._gpu_ids = [0, 1]
|
|
assert backend.gpu_ids == [0, 1]
|
|
backend._process = _FakeProcess()
|
|
backend.unload_model()
|
|
assert backend.gpu_ids is None
|
|
|
|
|
|
def _target_state_gpu_ids(backend, gpu_ids):
|
|
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,
|
|
gpu_ids = gpu_ids,
|
|
)
|
|
|
|
|
|
def test_gpu_ids_reload_detection_is_order_insensitive():
|
|
backend = _loaded_backend("auto")
|
|
backend._gpu_ids = [0, 1]
|
|
# Same set, different order -> no reload.
|
|
assert _target_state_gpu_ids(backend, [1, 0]) is True
|
|
# Different set -> reload.
|
|
assert _target_state_gpu_ids(backend, [0]) is False
|
|
# Dropping the pick (auto) -> reload.
|
|
assert _target_state_gpu_ids(backend, None) is False
|
|
|
|
|
|
def test_gpu_ids_reload_detection_collapses_diffusion_to_single_device():
|
|
# The diffusion runner drives only its single lowest device, so the backend
|
|
# records [lowest]. A later multi-GPU request that still resolves to that
|
|
# same lowest device must dedupe (no needless reload); a request whose lowest
|
|
# device moves, or that drops the pick, must reload.
|
|
backend = _loaded_backend("auto")
|
|
backend._is_diffusion = True
|
|
backend._gpu_ids = [1] # loaded on the lowest of an earlier [3, 1] pick
|
|
assert _target_state_gpu_ids(backend, [3, 1]) is True
|
|
assert _target_state_gpu_ids(backend, [1]) is True
|
|
# Lowest device changes (2, not 1) -> reload.
|
|
assert _target_state_gpu_ids(backend, [3, 2]) is False
|
|
# Dropping the pick (auto) -> reload.
|
|
assert _target_state_gpu_ids(backend, None) is False
|
|
|
|
|
|
def test_start_diffusion_server_resets_tensor_parallel():
|
|
# A prior tensor-parallel chat load leaves self._tensor_parallel True (load_model
|
|
# phase 1 only kills the process, it skips the unload reset). Diffusion is never
|
|
# TP, so startup must clear it -- else /status misreports TP and an identical
|
|
# diffusion re-Apply reloads against stale tensor-parallel state.
|
|
src = inspect.getsource(llama_cpp_module.LlamaCppBackend._start_diffusion_server)
|
|
assert "self._tensor_parallel = False" in src
|
|
|
|
|
|
def test_route_matches_loaded_settings_collapses_diffusion_gpu_ids():
|
|
# The route-level reload dedupe mirrors the backend: for a loaded diffusion
|
|
# model it compares the request against the single recorded device, not the
|
|
# full requested list, or a same-device multi-GPU pick reloads needlessly.
|
|
route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8")
|
|
match_impl = route_src[route_src.index("def _request_matches_loaded_settings") :]
|
|
guard = match_impl.index("if llama_backend.is_diffusion:")
|
|
collapse = match_impl.index("[sorted(request.gpu_ids)[0]] if request.gpu_ids else None")
|
|
compare = match_impl.index("if _req_gpu_ids != llama_backend.gpu_ids:")
|
|
assert guard < collapse < compare
|
|
|
|
|
|
# ── Manual tensor split: child enumeration pinned to the picker's order ──────
|
|
|
|
|
|
def _patch_split_pin_env(monkeypatch, *, inherited, reported):
|
|
"""Point the pin helper at a fake inherited mask and picker report.
|
|
``reported`` None = enumeration unavailable (falls back to ascending)."""
|
|
import utils.hardware as hw
|
|
|
|
monkeypatch.setattr(
|
|
LlamaCppBackend, "_resolve_visible_physical_ids", staticmethod(lambda: inherited)
|
|
)
|
|
info = (
|
|
{"available": False}
|
|
if reported is None
|
|
else {
|
|
"available": True,
|
|
"index_kind": "physical",
|
|
"devices": [{"index": i} for i in reported],
|
|
}
|
|
)
|
|
monkeypatch.setattr(hw, "get_backend_visible_gpu_info", lambda: info)
|
|
|
|
|
|
def test_split_pin_reorders_inherited_numeric_mask(monkeypatch):
|
|
# Parent CUDA_VISIBLE_DEVICES=3,1 makes the child enumerate dev0=phys3, but
|
|
# nvidia-smi reported the picker's list ascending -- the mask must be
|
|
# re-emitted in that order or the per-GPU shares land on the wrong cards.
|
|
_patch_split_pin_env(monkeypatch, inherited = [3, 1], reported = [1, 3])
|
|
env = {"CUDA_VISIBLE_DEVICES": "3,1"}
|
|
LlamaCppBackend._pin_visible_gpu_order_for_split(env)
|
|
assert env["CUDA_DEVICE_ORDER"] == "PCI_BUS_ID"
|
|
assert env["CUDA_VISIBLE_DEVICES"] == "1,3"
|
|
|
|
|
|
def test_split_pin_keeps_mask_order_when_picker_reported_it(monkeypatch):
|
|
# Torch-fallback enumeration (no nvidia-smi) reports devices in inherited
|
|
# mask order, so the picker's split list follows the mask -- the pin must
|
|
# keep that order, not re-sort it into a mismatch.
|
|
_patch_split_pin_env(monkeypatch, inherited = [3, 1], reported = [3, 1])
|
|
env = {"CUDA_VISIBLE_DEVICES": "3,1"}
|
|
LlamaCppBackend._pin_visible_gpu_order_for_split(env)
|
|
assert env["CUDA_VISIBLE_DEVICES"] == "3,1"
|
|
|
|
|
|
def test_split_pin_falls_back_to_ascending_without_report(monkeypatch):
|
|
# Enumeration unavailable: ascending physical is the best guess (it matches
|
|
# the dominant nvidia-smi report order).
|
|
_patch_split_pin_env(monkeypatch, inherited = [3, 1], reported = None)
|
|
env = {"CUDA_VISIBLE_DEVICES": "3,1"}
|
|
LlamaCppBackend._pin_visible_gpu_order_for_split(env)
|
|
assert env["CUDA_VISIBLE_DEVICES"] == "1,3"
|
|
|
|
|
|
def test_split_pin_without_mask_only_sets_pci_order(monkeypatch):
|
|
# No inherited mask (or a UUID/MIG one resolving to None): enumeration order
|
|
# is fully fixed by CUDA_DEVICE_ORDER, so no mask is written.
|
|
_patch_split_pin_env(monkeypatch, inherited = None, reported = None)
|
|
env = {}
|
|
LlamaCppBackend._pin_visible_gpu_order_for_split(env)
|
|
assert env == {"CUDA_DEVICE_ORDER": "PCI_BUS_ID"}
|
|
|
|
|
|
def test_split_pin_mirrors_hip_mask_on_rocm(monkeypatch):
|
|
# ROCm: the pin must land in HIP_VISIBLE_DEVICES too, and an inherited ROCR
|
|
# mask is cleared so the mask can't apply twice (ROCR re-indexes, then HIP
|
|
# would index into the already-reduced set).
|
|
_patch_split_pin_env(monkeypatch, inherited = [3, 1], reported = [1, 3])
|
|
torch_stub = _types.ModuleType("torch")
|
|
torch_stub.version = _types.SimpleNamespace(hip = "6.0")
|
|
monkeypatch.setitem(sys.modules, "torch", torch_stub)
|
|
env = {"CUDA_VISIBLE_DEVICES": "3,1", "ROCR_VISIBLE_DEVICES": "3,1"}
|
|
LlamaCppBackend._pin_visible_gpu_order_for_split(env)
|
|
assert env["CUDA_VISIBLE_DEVICES"] == "1,3"
|
|
assert env["HIP_VISIBLE_DEVICES"] == "1,3"
|
|
assert "ROCR_VISIBLE_DEVICES" not in env
|
|
|
|
|
|
# ── Diffusion single-device selection ───────────────────────────────────────
|
|
|
|
|
|
def test_diffusion_gpu_arg_uses_lowest_explicit_physical_id(monkeypatch):
|
|
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "3,1")
|
|
monkeypatch.setenv("DG_GPU", "7")
|
|
assert LlamaCppBackend._diffusion_gpu_arg([3, 1]) == "1"
|
|
|
|
|
|
def test_diffusion_gpu_arg_preserves_parent_mask_order(monkeypatch):
|
|
monkeypatch.delenv("DG_GPU", raising = False)
|
|
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "3,1")
|
|
assert LlamaCppBackend._diffusion_gpu_arg(None) == "3"
|
|
|
|
|
|
def test_diffusion_gpu_arg_honors_override_and_cpu_mask(monkeypatch):
|
|
monkeypatch.setenv("DG_GPU", "GPU-abc")
|
|
assert LlamaCppBackend._diffusion_gpu_arg(None) == "GPU-abc"
|
|
assert LlamaCppBackend._diffusion_gpu_arg(None, cpu_only = True) == ""
|
|
|
|
|
|
# ── Deliberate zero-offload (manual gpu_layers=0): training-skip flag ─────────
|
|
|
|
|
|
def test_zero_offload_flag_false_without_companions():
|
|
# CPU-only by construction: False lets training skip unloading a server that
|
|
# holds no VRAM.
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0", "--fit", "off"]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], {}) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"companion",
|
|
["--mmproj", "--model-draft", "-md", "--spec-draft-model", "-hfd"],
|
|
)
|
|
def test_zero_offload_flag_true_with_companion(companion):
|
|
# mmproj / a drafter offload to GPU regardless of --gpu-layers, so the
|
|
# server still holds VRAM and training must unload it. Drafter detection
|
|
# reuses the extras parser, so pass-through aliases count too.
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0", companion, "x.gguf"]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], {}) is True
|
|
|
|
|
|
def test_zero_offload_flag_true_with_inline_companion_forms():
|
|
cmd = ["llama-server", "-m", "model.gguf", "--spec-draft-model=x.gguf"]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], {}) is True
|
|
cmd = ["llama-server", "-m", "model.gguf", "--mmproj=proj.gguf"]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], {}) is True
|
|
|
|
|
|
def test_zero_offload_flag_true_with_env_drafter():
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0"]
|
|
env = {"LLAMA_ARG_SPEC_DRAFT_MODEL": "x.gguf"}
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], env) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"device_args",
|
|
[
|
|
["--device", "CUDA0"],
|
|
["--device=CUDA0"],
|
|
["-dev", "CUDA0"],
|
|
["--spec-draft-device", "CUDA0"],
|
|
["--device-draft=CUDA0"],
|
|
],
|
|
)
|
|
def test_zero_offload_flag_true_with_device_pin(device_args):
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0", *device_args]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], {}) is True
|
|
|
|
|
|
def test_zero_offload_flag_true_with_env_device_pin():
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0"]
|
|
env = {"LLAMA_ARG_DEVICE": "CUDA0"}
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], env) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("device_args", "env"),
|
|
[
|
|
(["--device", "cpu"], {}),
|
|
(["--device=none"], {}),
|
|
(["--spec-draft-device", "cpu"], {}),
|
|
([], {"LLAMA_ARG_DEVICE": "none"}),
|
|
(["--device", "CUDA0", "--device", "cpu"], {}),
|
|
],
|
|
)
|
|
def test_zero_offload_flag_false_with_cpu_device_pin(device_args, env):
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0", *device_args]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], env) is False
|
|
|
|
|
|
def test_zero_offload_flag_true_with_surviving_tensor_mode():
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0", "--split-mode", "tensor"]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], {}) is True
|
|
|
|
|
|
def test_zero_offload_flag_true_for_unmasked_vulkan(monkeypatch):
|
|
monkeypatch.setattr(LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda: True))
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0"]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [(0, 8000, 24000)], {}) is True
|
|
|
|
|
|
def test_zero_offload_flag_none_without_gpus():
|
|
cmd = ["llama-server", "-m", "model.gguf", "--gpu-layers", "0"]
|
|
assert LlamaCppBackend._zero_offload_gpu_flag(cmd, [], {}) is None
|
|
|
|
|
|
def test_cmd_has_gpu_companion_detection():
|
|
# The env mask for CPU-only zero-offload loads keys off this scan: any
|
|
# --mmproj form or a drafter (flag aliases / env) keeps the GPUs visible.
|
|
has = LlamaCppBackend._cmd_has_gpu_companion
|
|
assert has(["llama-server", "-m", "m.gguf"], {}) is False
|
|
assert has(["llama-server", "--mmproj", "p.gguf"], {}) is True
|
|
assert has(["llama-server", "--mmproj=p.gguf"], {}) is True
|
|
assert has(["llama-server", "-md", "d.gguf"], {}) is True
|
|
assert has(["llama-server"], {"LLAMA_ARG_SPEC_DRAFT_MODEL": "d.gguf"}) is True
|
|
|
|
|
|
def test_cmd_companion_ignores_cpu_forced_drafter():
|
|
# A CPU-pinned drafter holds no VRAM: the zero-offload mask may hide the GPUs
|
|
# and training may leave the server alone.
|
|
has = LlamaCppBackend._cmd_has_gpu_companion
|
|
cmd = ["llama-server", "-md", "d.gguf", "--spec-draft-ngl", "0"]
|
|
assert has(cmd, {}) is False
|
|
cmd = ["llama-server", "-md", "d.gguf", "--spec-draft-device", "cpu"]
|
|
assert has(cmd, {}) is False
|
|
# mmproj still counts even alongside a CPU drafter.
|
|
cmd = ["llama-server", "-md", "d.gguf", "--spec-draft-ngl", "0", "--mmproj", "p.gguf"]
|
|
assert has(cmd, {}) is True
|