mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
Some checks are pending
Core / Core (HF=default + TRL=default) (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Waiting to run
Core / Core (HF=latest + TRL=latest) (push) Waiting to run
Core / llama.cpp build + smoke (push) Waiting to run
Kaggle T4 Notebook CI / T4 smoke (push) Blocked by required conditions
Kaggle T4 Notebook CI / gate (push) Waiting to run
Kaggle T4 Studio GPU CI / gate (push) Waiting to run
Kaggle T4 Studio GPU CI / Studio GPU smoke (push) Blocked by required conditions
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Local Agent Guides CI / connection (claude, stable) (push) Waiting to run
Local Agent Guides CI / connection (codex, stable) (push) Waiting to run
Local Agent Guides CI / connection (hermes, stable) (push) Waiting to run
Local Agent Guides CI / connection (openclaw, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, v2) (push) Waiting to run
Local Agent Guides CI / connection (pi, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Frontend CI / Frontend unit tests (Windows) (push) Waiting to run
Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Studio UI + API + Update + Inference CI / Chat UI, API, Update and GGUF inference (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (picker) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (banner) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (chat) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
studiobench CI / Selftests, lint and the single-file build (push) Waiting to run
studiobench CI / Real-path session, every action ran (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (extra) (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection + VC++ round-trip (VS 2026) (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 units, no-VS resolve, VC++ round-trip (windows-latest) (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
* Studio: stop dropping quantized KV cache on tensor split * Studio: budget an env-inherited quantized KV cache * Studio: price the quantized axis, and handle an older llama.cpp Follow-ups to the gate removal, from reviewing what became reachable once a quantized KV cache started surviving a tensor split. Every estimator resolves ONE cache type: the heavier axis, for KV bytes. That same scalar also selected the compute-buffer rate, and _compute_buffer_ctx_bytes charges the per-device dequant scratch only when its type is under f16 -- so an asymmetric -ctk q4_0 -ctv f16 priced the scratch as if nothing were quantized. The tensor gate used to make this unreachable by rewriting both axes, which is what the comment removed from _plan_tensor_parallel said. Split the two: heavier axis for bytes, lighter axis (_planned_scratch_cache_type) for scratch. Measured end to end through load_model on an 80L/n_embd 8192 model, 4x48GB, n_ubatch 2048: the emitted -c was 262144 and is now 175104. Tensor mode has no --fit valve, so that surplus was a startup OOM rather than a spill. The layer path took the same scalar and gets the same fix. An older llama.cpp refuses the pair outright, in llama_init_from_model: "simultaneous use of SPLIT_MODE_TENSOR and KV cache quantization not implemented". That is LLAMA_LOG_ERROR + return nullptr, so it is a clean exit 1 and _should_record_tensor_split_abort cannot see it -- every load spent two full model-load cycles (the tensor attempt plus the --fit retry) before the route's layer fallback, forever, and reported "Check that the GGUF file is valid and you have enough memory". Classify the marker, skip the fit retry on it as the split-axis abort already does, and latch it. One doomed attempt on the first load, then none, and a message naming b9455. The abort latch was keyed on (binary, mtime, model). Now that the cache type reaches the tensor child, an abort can be specific to it: iq4_nl asserts under a tensor split on b10441 while q4_0 runs on the same binary and model (ggml-org/llama.cpp#27116), and a pre-b9455 binary refuses every quantized type while f16 is fine. Key it on the planned (K, V) pair too, so one type's failure does not take tensor mode away from the types that work. Two smaller ones on the same seam. A cache type kv_cache_type_from_str does not know is never emitted, so the child inherits LLAMA_ARG_CACHE_TYPE_K/_V instead -- but the budget priced the unemittable string at _kv_bytes_per_elem's 2.0 unknown default, a 2x under-reservation against an inherited f32. And an inherited type that llama.cpp cannot parse (q3_K, a typo, or a case-mismatched Q8_0) now reaches the child, aborting it at argument parsing; the layer retry inherits the same env and fails too, leaving no server. The managed path has been allow-listed and lowercased at emission for a long time; give the env path the same two. Tests: a new OS x accelerator x cache-type matrix over [linux, wsl2, windows, macos] x [nvidia multi/single, amd rocm, amd vulkan, apple unified, cpu-only] x [f16, q8_0, q4_0, q5_1, iq4_nl], asserting both axes per cell, that the >= 2-device cells keep tensor mode and the rest drop the whole split-mode group, and that no tensor launch pairs --flash-attn off (still a hard error upstream). Plus the end-to-end legacy-build cost, the scratch-axis selection, the two budget guards, and the assertion the deleted restore-path test used to carry: a downgrade must not rewrite an asymmetric pair. Checked against current llama.cpp master rather than its docs: the guard #23792 deleted is gone from src/llama-context.cpp, while docs/multi-gpu.md still lists the restriction -- it was last touched 2026-05-07, three weeks before #23792 merged. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: scope the old-build latch to the binary, and match on the effective cache Three review findings from the round on the previous commit. The pre-b9455 refusal was recorded in the model-and-cache-pair-keyed tensor-abort set, but llama_init_from_model refuses every quantized type for every model on such a binary. Loading a second model, or switching q8_0 to q4_0, therefore paid another doomed full model load. It now records into its own _tensor_quant_kv_unsupported_binaries set keyed on (binary, mtime) and is consulted at the up-front gate, before the spawn. A non-quantized pair is unaffected, since f16 still gets a tensor split on such a build, and the mtime means an updated binary is re-probed. The split-axis abort keeps the finer key: that one really is specific to a model and a cache type (iq4_nl asserts on b10441 while q4_0 runs, ggml-org/llama.cpp#27116). _runtime_matches_intent compared self._cache_type_kv against intent.cache_type_kv. That scalar holds only what Studio emitted as a managed flag, so a cache set through extras or the environment left None on one side and a type on the other, and an identical repeat /load tore down a healthy server to relaunch the same thing. It now compares _effective_cache_types, the per-axis pair the running child was actually launched with, against _planned_main_cache_types for the incoming request. A layer load has always had this; the tensor gate used to hide it by rewriting the cache away. The inherited-cache-type normalisation compared against an already-stripped copy, so " q8_0 " matched itself and nothing was written back. Compared against the original value now. common_arg::get_value_from_env hands the raw getenv string through and kv_cache_type_from_str compares it to ggml_type_name(t) exactly, so surrounding whitespace aborts the child exactly as a typo does. Not taken: the report that removing the cache scrub sends a quantized V cache to a launch with --flash-attn off. The emitted argv is identical to main on the layer path (the extras win last there too), and on the tensor path llama.cpp rejects the load on "SPLIT_MODE_TENSOR requires flash_attn to be enabled", which llama_init_from_model checks before any KV type, so the V type does not decide the outcome. _ALLOWED_TP_DROP_GUARDS gains the new binary-wide gate, which is what that tripwire is for. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: count the implicit f16 axis, match on the request, keep multi-GPU Three more from the round on the last commit. _env_main_cache_type_for_budget collected only the axes that were SET, so a single quantized axis carried the whole scalar: LLAMA_ARG_CACHE_TYPE_V=q4_0 with K unset returned q4_0, and the caller then priced K as q4 while the child ran it at f16. An unset axis is not absent, it is llama.cpp's f16 default, so it now takes part in the max. Measured on a key_length 192 / value_length 128 model at 131072: the reserve was 14.22 GiB against the 25.00 GiB the f16 K axis really needs, so the cheap type had landed on the larger tensor. The existing assertion that a lone q4_0 adopts q4_0 is updated; it pinned the under-reserving answer. The reload matcher compared the pair the child was LAUNCHED with against the pair the request resolves to. A launch-time rewrite makes those differ on purpose (a build with no --flash-attn resets a quantized V cache to f16 before the spawn, and the flash-attn crash recovery does the same), so every identical repeat load was rejected and redid that normalization instead of reusing it. It now records _requested_cache_types beside _effective_cache_types and compares requested against requested, which is the rule the tuning group above it already uses. The binary-capability skip added last commit downgraded to layer without raising _layer_min_gpus, so the auto layer planner started at one GPU and stopped there as soon as the model fit, quietly collapsing a multi-GPU request onto one card. The adjacent split-axis skip raises it for the same reason: neither failure says anything about capacity. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: tighten the comments added by this PR Comments only, no code change. Collapses the longer rationale blocks in llama_cpp.py and the two test headers to the same content in fewer lines. --------- Co-authored-by: danielhanchen <danielhanchen@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1376 lines
63 KiB
Python
1376 lines
63 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
|
|
|
|
"""Tests for ``_estimate_compute_buffer_bytes``: it scales with ``--parallel``,
|
|
tensor exceeds pipeline, and it is a safe upper bound on the allocations measured
|
|
on real hardware (Qwen3.6-27B-MTP: parallel 1/2/4/8 -> 36/492/1388/3220 MiB single
|
|
GPU, ~600 MiB/device tensor). No GPU, subprocess, or GGUF I/O."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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)
|
|
|
|
_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 -- only stub when the real library is missing. Unconditional stubbing
|
|
# shadows HTTPError/Response that huggingface_hub.errors imports at load time,
|
|
# silently breaking the transformers introspection tier in tests collected after
|
|
# this one (the stub leaks via sys.modules for the whole session).
|
|
try:
|
|
import httpx as _httpx_real # noqa: F401
|
|
except ImportError:
|
|
_httpx_stub = _types.ModuleType("httpx")
|
|
for _exc in (
|
|
"ConnectError",
|
|
"TimeoutException",
|
|
"ReadTimeout",
|
|
"ReadError",
|
|
"RemoteProtocolError",
|
|
"CloseError",
|
|
"HTTPError",
|
|
"RequestError",
|
|
):
|
|
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
|
|
_httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
|
|
_httpx_stub.Response = type("Response", (), {})
|
|
_httpx_stub.Client = type(
|
|
"C",
|
|
(),
|
|
{
|
|
"__init__": lambda s, **kw: None,
|
|
"__enter__": lambda s: s,
|
|
"__exit__": lambda s, *a: None,
|
|
},
|
|
)
|
|
sys.modules["httpx"] = _httpx_stub
|
|
|
|
from core.inference.llama_cpp import (
|
|
_FIT_MIN_CTX,
|
|
_kv_bytes_per_elem,
|
|
_planned_main_cache_types,
|
|
_planned_scratch_cache_type,
|
|
LlamaCppBackend,
|
|
)
|
|
|
|
MIB = 1024 * 1024
|
|
GIB = 1024 * MIB
|
|
|
|
|
|
def _backend(
|
|
vocab = 248320,
|
|
embd = 5120,
|
|
mla = None,
|
|
arch = None,
|
|
pooling_type = None,
|
|
):
|
|
"""Backend with just the dims the compute-buffer estimate reads."""
|
|
b = LlamaCppBackend.__new__(LlamaCppBackend)
|
|
b._vocab_size = vocab
|
|
b._embedding_length = embd
|
|
b._key_length_mla = mla # non-None -> MLA (compressed attention)
|
|
b._architecture = arch # GGUF general.architecture (e.g. 'deepseek4')
|
|
b._pooling_type = pooling_type
|
|
return b
|
|
|
|
|
|
def _backend_from_gguf_local(
|
|
n_layers = 80,
|
|
embd = 8192,
|
|
n_kv_heads = 8,
|
|
head_dim = 128,
|
|
):
|
|
"""Backend that can also estimate KV bytes, for the planner cells below.
|
|
|
|
A real ``__init__`` (not ``__new__`` like ``_backend`` above) so every
|
|
attribute the KV estimator reads exists at its default; only the handful of
|
|
dims these cells vary are overridden. These tests are about which cache type
|
|
is priced, not about GGUF parsing.
|
|
"""
|
|
b = LlamaCppBackend()
|
|
b._vocab_size = 248_320
|
|
b._embedding_length = embd
|
|
b._n_layers = n_layers
|
|
b._n_kv_heads = n_kv_heads
|
|
b._kv_key_length = head_dim
|
|
b._kv_value_length = head_dim
|
|
b._context_length = 262_144
|
|
assert b._can_estimate_kv()
|
|
return b
|
|
|
|
|
|
# Measured ground truth (MiB) the estimate must upper-bound.
|
|
_PIPELINE_MEASURED = {1: 36, 2: 492, 4: 1388, 8: 3220}
|
|
_TENSOR_MEASURED_PER_DEVICE = 600
|
|
|
|
|
|
class TestSafeUpperBound:
|
|
"""The estimate must be >= every measured allocation (never under-reserve)."""
|
|
|
|
@pytest.mark.parametrize("parallel,measured", sorted(_PIPELINE_MEASURED.items()))
|
|
def test_pipeline_upper_bounds_measured(self, parallel, measured):
|
|
est = _backend()._estimate_compute_buffer_bytes(n_parallel = parallel) / MIB
|
|
assert est >= measured, f"under-reserved at parallel={parallel}: {est:.0f} < {measured}"
|
|
|
|
@pytest.mark.parametrize("parallel,measured", sorted(_PIPELINE_MEASURED.items()))
|
|
def test_pipeline_not_wildly_over(self, parallel, measured):
|
|
# Stay within ~2x of measured so we don't waste context (the point of
|
|
# replacing the flat reserve). parallel=1 is tiny in absolute terms.
|
|
est = _backend()._estimate_compute_buffer_bytes(n_parallel = parallel) / MIB
|
|
assert est <= max(measured * 2.0, 128)
|
|
|
|
def test_tensor_upper_bounds_measured(self):
|
|
est = _backend()._estimate_compute_buffer_bytes(n_parallel = 1, per_device_tensor = True) / MIB
|
|
assert est >= _TENSOR_MEASURED_PER_DEVICE
|
|
|
|
def test_tensor_far_below_old_flat_reserve(self):
|
|
# The whole point: deterministic estimate << flat 5120 for this model.
|
|
est = _backend()._estimate_compute_buffer_bytes(n_parallel = 1, per_device_tensor = True) / MIB
|
|
assert est < LlamaCppBackend._TENSOR_PARALLEL_BUFFER_RESERVE_MIB
|
|
|
|
|
|
class TestScaling:
|
|
def test_grows_with_serving_slots(self):
|
|
b = _backend()
|
|
vals = [b._estimate_compute_buffer_bytes(n_parallel = p) for p in (1, 2, 4, 8)]
|
|
assert vals == sorted(vals) and vals[0] < vals[-1]
|
|
|
|
def test_parallel_1_is_small(self):
|
|
# Single-token decode: a few tens of MiB, not gigabytes.
|
|
est = _backend()._estimate_compute_buffer_bytes(n_parallel = 1) / MIB
|
|
assert est < 128
|
|
|
|
def test_tensor_exceeds_pipeline_at_same_parallel(self):
|
|
b = _backend()
|
|
pipe = b._estimate_compute_buffer_bytes(n_parallel = 1)
|
|
tens = b._estimate_compute_buffer_bytes(n_parallel = 1, per_device_tensor = True)
|
|
assert tens > pipe
|
|
|
|
def test_scales_with_vocab(self):
|
|
small = _backend(vocab = 32000)._estimate_compute_buffer_bytes(n_parallel = 4)
|
|
big = _backend(vocab = 256000)._estimate_compute_buffer_bytes(n_parallel = 4)
|
|
assert big > small
|
|
|
|
def test_scales_with_ubatch(self):
|
|
b = _backend()
|
|
lo = b._estimate_compute_buffer_bytes(n_parallel = 4, n_ubatch = 256)
|
|
hi = b._estimate_compute_buffer_bytes(n_parallel = 4, n_ubatch = 1024)
|
|
assert hi > lo
|
|
|
|
def test_embedding_mode_budgets_the_first_output_buffer(self):
|
|
chat = _backend()._estimate_compute_buffer_bytes(n_parallel = 1, n_ubatch = 512)
|
|
embedding = _backend(pooling_type = 2)._estimate_compute_buffer_bytes(
|
|
n_parallel = 1, n_ubatch = 512
|
|
)
|
|
assert embedding > chat
|
|
|
|
|
|
class TestFallback:
|
|
def test_zero_when_vocab_missing(self):
|
|
assert _backend(vocab = None)._estimate_compute_buffer_bytes(n_parallel = 4) == 0
|
|
|
|
def test_zero_when_embd_missing(self):
|
|
assert _backend(embd = None)._estimate_compute_buffer_bytes(n_parallel = 4) == 0
|
|
|
|
def test_zero_lets_tensor_plan_use_flat_fallback(self):
|
|
# When dims are missing, _plan_tensor_parallel must fall back to the flat
|
|
# reserve (defense-in-depth) rather than reserving 0 and OOMing.
|
|
b = _backend(vocab = None, embd = None)
|
|
b._n_layers = None # can't estimate KV -> floors ctx, still returns a plan
|
|
ec, mac, gi, ts = b._plan_tensor_parallel([(0, 48000), (1, 48000)], 8 * 1024**3, 8192)
|
|
assert gi == [0, 1] # both GPUs usable under the flat fallback
|
|
|
|
|
|
class TestParallel1Default:
|
|
"""At Unsloth's default --parallel 1 the buffer is negligible in pipeline."""
|
|
|
|
def test_default_n_parallel(self):
|
|
est = _backend()._estimate_compute_buffer_bytes() / MIB
|
|
assert est < 128
|
|
|
|
|
|
class TestContextLinearBuffer:
|
|
"""``_compute_buffer_ctx_bytes``: the flash-attn KQ-mask + attention scratch
|
|
grow ~linearly with context; the flat estimate above only covers ctx -> 0.
|
|
Measured slope (q8_0 KV, ubatch 512) was 0.74-2.02 x n_embd; 2 x n_embd is the
|
|
worst-case upper bound the term must hold to."""
|
|
|
|
# (model, n_embd, ctx, measured CUDA0 compute buffer MiB at that ctx, q8_0/ub512)
|
|
_MEASURED = [
|
|
("Qwen3.5-2B", 2048, 262144, 796),
|
|
("Qwen3.5-4B", 2560, 262144, 1330), # worst slope, 2.02 x n_embd
|
|
("Qwen3.5-9B", 4096, 262144, 1336),
|
|
("Qwen3.6-27B", 5120, 262144, 1360),
|
|
("Gemma-4-31B", 5376, 262144, 2392),
|
|
]
|
|
|
|
def test_zero_by_default(self):
|
|
# Omitted/zero ctx -> no term (keeps the flat callers unchanged).
|
|
assert _backend()._compute_buffer_ctx_bytes(0) == 0
|
|
|
|
def test_zero_when_embd_missing(self):
|
|
assert _backend(embd = None)._compute_buffer_ctx_bytes(262144) == 0
|
|
|
|
def test_grows_linearly_with_context(self):
|
|
b = _backend(embd = 4096)
|
|
a = b._compute_buffer_ctx_bytes(65536)
|
|
d = b._compute_buffer_ctx_bytes(131072)
|
|
assert d == pytest.approx(2 * a, rel = 1e-6)
|
|
|
|
def test_scales_with_embd(self):
|
|
# The quantized (dequant-scratch) rate scales with n_embd; f16 (mask) does not.
|
|
small = _backend(embd = 2048)._compute_buffer_ctx_bytes(131072, cache_type_kv = "q8_0")
|
|
big = _backend(embd = 5120)._compute_buffer_ctx_bytes(131072, cache_type_kv = "q8_0")
|
|
assert big > small
|
|
|
|
def test_scales_with_ubatch(self):
|
|
b = _backend(embd = 4096)
|
|
lo = b._compute_buffer_ctx_bytes(131072, n_ubatch = 256)
|
|
hi = b._compute_buffer_ctx_bytes(131072, n_ubatch = 1024)
|
|
assert hi > lo
|
|
|
|
@pytest.mark.parametrize("name,embd,ctx,measured", _MEASURED)
|
|
def test_upper_bounds_measured_compute_growth(self, name, embd, ctx, measured):
|
|
# flat term + context-linear term must cover the real (q8_0) buffer at full ctx.
|
|
b = _backend(embd = embd)
|
|
flat = b._estimate_compute_buffer_bytes(n_parallel = 1)
|
|
total = (flat + b._compute_buffer_ctx_bytes(ctx, cache_type_kv = "q8_0")) / MIB
|
|
assert total >= measured, f"{name}: under-reserved {total:.0f} < {measured}"
|
|
|
|
def test_worst_case_rate_covers_two_x_embd(self):
|
|
# >= 2 x n_embd bytes per context token at the default micro-batch (the worst
|
|
# measured quantized slope, Qwen3.5-4B), so flat + term upper-bounds the buffer.
|
|
embd = 4096
|
|
b = _backend(embd = embd)
|
|
per_tok = b._compute_buffer_ctx_bytes(100000, cache_type_kv = "q8_0") / 100000
|
|
assert per_tok >= 2 * embd
|
|
|
|
|
|
class TestContextBufferKVQuant:
|
|
"""The context-linear rate depends on the KV cache type: a quantized cache adds a
|
|
context-sized dequant scratch (heavy); f16/bf16/f32 only pays the KQ mask (light).
|
|
Measured Qwen3.5-4B at 256k: 1.30 GiB (q8_0) vs 0.31 GiB (f16)."""
|
|
|
|
def test_quantized_heavier_than_f16(self):
|
|
b = _backend(embd = 4096)
|
|
q = b._compute_buffer_ctx_bytes(131072, cache_type_kv = "q8_0")
|
|
f = b._compute_buffer_ctx_bytes(131072, cache_type_kv = "f16")
|
|
assert q > f
|
|
|
|
def test_none_cache_type_is_f16(self):
|
|
# None -> f16 (llama.cpp's default); the env-quantized case is covered by the
|
|
# KV budget's f16 over-reservation, so we take the lighter mask-only rate.
|
|
b = _backend(embd = 4096)
|
|
assert b._compute_buffer_ctx_bytes(
|
|
131072, cache_type_kv = None
|
|
) == b._compute_buffer_ctx_bytes(131072, cache_type_kv = "f16")
|
|
|
|
@pytest.mark.parametrize("ct", ["f16", "bf16", "f32"])
|
|
def test_unquantized_uses_mask_only_rate(self, ct):
|
|
# f16/bf16/f32: KQ mask only, n_ubatch*2 B/tok, independent of n_embd.
|
|
b_small = _backend(embd = 2048)
|
|
b_big = _backend(embd = 8192)
|
|
per_small = b_small._compute_buffer_ctx_bytes(100000, cache_type_kv = ct) / 100000
|
|
per_big = b_big._compute_buffer_ctx_bytes(100000, cache_type_kv = ct) / 100000
|
|
assert per_small == per_big # no n_embd scaling on the f16 path
|
|
expected = 512 * 2 * LlamaCppBackend._CTX_COMPUTE_F16_MASK_SAFETY # ubatch 512
|
|
assert per_small == pytest.approx(expected, rel = 1e-6)
|
|
|
|
@pytest.mark.parametrize("ct", ["q8_0", "q5_1", "q4_0", "iq4_nl"])
|
|
def test_quantized_types_use_heavy_rate(self, ct):
|
|
embd = 4096
|
|
b = _backend(embd = embd)
|
|
per_tok = b._compute_buffer_ctx_bytes(100000, cache_type_kv = ct) / 100000
|
|
assert per_tok == pytest.approx(
|
|
LlamaCppBackend._CTX_COMPUTE_BYTES_PER_EMBD * embd, rel = 1e-6
|
|
)
|
|
|
|
def test_f16_covers_measured_mask(self):
|
|
# f16 buffer is ~mask only (~n_ubatch*2 B/tok); 0.5 x n_embd must cover the
|
|
# measured Qwen3.5-4B f16 slope (~0.4 x n_embd = 0.31 GiB at 256k).
|
|
b = _backend(embd = 2560) # Qwen3.5-4B
|
|
est = b._compute_buffer_ctx_bytes(262144, cache_type_kv = "f16") / MIB
|
|
assert est >= 320 # measured 0.31 GiB growth
|
|
|
|
|
|
class TestContextBufferMLA:
|
|
"""MLA (compressed attention) needs a smaller quantized dequant scratch than
|
|
regular attention: measured 0.94 x n_embd on GLM-5.2 and Kimi-K2.7 vs up to
|
|
2.02x on Qwen/Gemma. Charging the regular rate would badly over-reserve a tight
|
|
multi-GPU MLA pin (per-device scaling multiplies the error)."""
|
|
|
|
def test_mla_lighter_than_regular(self):
|
|
reg = _backend(embd = 6144, mla = None)._compute_buffer_ctx_bytes(262144, cache_type_kv = "q8_0")
|
|
mla = _backend(embd = 6144, mla = 256)._compute_buffer_ctx_bytes(262144, cache_type_kv = "q8_0")
|
|
assert mla < reg
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,embd,ctx,measured",
|
|
[
|
|
("GLM-5.2", 6144, 754688, 4141), # per-device compute MiB at q8_0
|
|
("Kimi-K2.7", 7168, 262144, 1690),
|
|
],
|
|
)
|
|
def test_mla_rate_covers_measured(self, name, embd, ctx, measured):
|
|
b = _backend(embd = embd, mla = 256)
|
|
est = b._compute_buffer_ctx_bytes(ctx, cache_type_kv = "q8_0") / MIB
|
|
assert est >= measured, f"{name}: MLA under-reserved {est:.0f} < {measured}"
|
|
|
|
def test_mla_not_wildly_over(self):
|
|
# 1.25 x n_embd should stay within ~1.6x of the measured 0.94x (not 2.4x like
|
|
# the regular 2.25 rate would), so a multi-GPU MLA pin keeps its context.
|
|
b = _backend(embd = 6144, mla = 256)
|
|
est = b._compute_buffer_ctx_bytes(754688, cache_type_kv = "q8_0") / MIB
|
|
assert est <= 4141 * 1.7
|
|
|
|
|
|
class TestContextBufferDSV4:
|
|
"""DeepSeek-V4 (deepseek4) reserves a large lightning-indexer / sparse-attention
|
|
compute buffer the KQ-mask and MLA rates miss (present even with an f16 cache).
|
|
Measured on UD-Q4_K_XL (ub=512): ~2 GiB at 16k ctx, ~65.5 GiB at 1M. The auto-fit
|
|
must see this so it does not commit the full 1M train context and OOM (spilling
|
|
to CPU at ~4 tok/s)."""
|
|
|
|
_MEASURED_1M_GIB = 65.5 # 70353790464 B compute-graph reserve that OOM'd at 1M ctx
|
|
GIB = 1024**3
|
|
|
|
def test_covers_measured_1m_buffer(self):
|
|
b = _backend(embd = 4096, arch = "deepseek4")
|
|
gib = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16") / self.GIB
|
|
assert gib >= self._MEASURED_1M_GIB, f"under-reserved {gib:.1f} < {self._MEASURED_1M_GIB}"
|
|
|
|
def test_not_wildly_over_at_1m(self):
|
|
# Within ~1.3x of measured so the fit still grants a large (~256k) context.
|
|
b = _backend(embd = 4096, arch = "deepseek4")
|
|
gib = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16") / self.GIB
|
|
assert gib <= self._MEASURED_1M_GIB * 1.3
|
|
|
|
def test_fires_for_f16_cache(self):
|
|
# The bug: an f16 (default) cache took the tiny mask-only path. DSV4 must
|
|
# reserve GiB, not the ~MiB a non-DSV4 model reserves at the same ctx.
|
|
dsv4 = _backend(embd = 4096, arch = "deepseek4")._compute_buffer_ctx_bytes(
|
|
262144, cache_type_kv = "f16"
|
|
)
|
|
other = _backend(embd = 4096, arch = "qwen3")._compute_buffer_ctx_bytes(
|
|
262144, cache_type_kv = "f16"
|
|
)
|
|
assert dsv4 > 40 * other
|
|
|
|
def test_cache_type_independent(self):
|
|
# Indexer scratch is present for an f16 and a quantized cache alike.
|
|
b = _backend(embd = 4096, arch = "deepseek4")
|
|
assert b._compute_buffer_ctx_bytes(
|
|
262144, cache_type_kv = "f16"
|
|
) == b._compute_buffer_ctx_bytes(262144, cache_type_kv = "q8_0")
|
|
|
|
def test_flat_floor_at_small_ctx(self):
|
|
# ~2 GiB indexer scratch present even at tiny ctx (covers the measured 16k ~2 GiB).
|
|
b = _backend(embd = 4096, arch = "deepseek4")
|
|
assert b._compute_buffer_ctx_bytes(16384, cache_type_kv = "f16") / self.GIB >= 2.0
|
|
|
|
def test_scales_with_context_and_ubatch(self):
|
|
b = _backend(embd = 4096, arch = "deepseek4")
|
|
assert b._compute_buffer_ctx_bytes(131072) > b._compute_buffer_ctx_bytes(65536)
|
|
assert b._compute_buffer_ctx_bytes(131072, n_ubatch = 1024) > b._compute_buffer_ctx_bytes(
|
|
131072, n_ubatch = 256
|
|
)
|
|
|
|
def test_non_dsv4_unchanged(self):
|
|
# Regression guard: a non-deepseek4 model keeps the mask-only f16 rate.
|
|
b = _backend(embd = 4096, arch = "llama")
|
|
per_tok = b._compute_buffer_ctx_bytes(100000, cache_type_kv = "f16") / 100000
|
|
expected = 512 * 2 * LlamaCppBackend._CTX_COMPUTE_F16_MASK_SAFETY
|
|
assert per_tok == pytest.approx(expected, rel = 1e-6)
|
|
|
|
|
|
class TestContextBufferLayerSplit:
|
|
"""The per-device f16 (KQ-mask) rate steps 4x once the model spans >1 device under
|
|
``-sm layer``: a step on "is split", not a ramp in device count. ``_MEASURED`` is
|
|
from llama.cpp's memory breakdown: compute-column ctx slope / n_ctx / n_ubatch."""
|
|
|
|
_RATE_SINGLE = 2.0 # B/tok/ubatch, per device
|
|
_RATE_SPLIT = 8.0
|
|
# (model, n_gpus, n_ubatch, measured B/tok/ubatch/device)
|
|
_MEASURED = [
|
|
("Qwen3.5-9B-MTP", 1, 512, 2.0),
|
|
("Qwen3.5-9B-MTP", 2, 512, 8.0),
|
|
("Qwen3.6-27B-MTP", 1, 512, 2.0),
|
|
("Qwen3.6-27B-MTP", 4, 512, 8.0),
|
|
("Qwen3.6-35B-A3B", 1, 2048, 2.0),
|
|
("Qwen3.6-35B-A3B", 2, 2048, 8.0),
|
|
("gemma-4-12b-it", 1, 512, 2.0),
|
|
("gemma-4-12b-it", 3, 512, 8.0),
|
|
("gemma-4-12b-it", 5, 512, 8.0),
|
|
("gemma-4-12b-it", 6, 512, 8.0),
|
|
("gemma-4-12b-it", 8, 512, 8.0),
|
|
("gemma-4-26B-A4B-it", 1, 512, 2.0),
|
|
("gemma-4-26B-A4B-it", 2, 512, 8.0),
|
|
("Kimi-K3", 4, 512, 8.0),
|
|
]
|
|
|
|
def test_default_is_single_device(self):
|
|
# Existing callers are unaffected: the flag defaults off.
|
|
b = _backend(embd = 4096)
|
|
assert b._compute_buffer_ctx_bytes(
|
|
131072, cache_type_kv = "f16"
|
|
) == b._compute_buffer_ctx_bytes(131072, cache_type_kv = "f16", layer_split = False)
|
|
|
|
def test_split_is_exactly_the_multiplier(self):
|
|
b = _backend(embd = 4096)
|
|
one = b._compute_buffer_ctx_bytes(131072, cache_type_kv = "f16")
|
|
many = b._compute_buffer_ctx_bytes(131072, cache_type_kv = "f16", layer_split = True)
|
|
assert many == pytest.approx(one * LlamaCppBackend._CTX_COMPUTE_SPLIT_MULT, rel = 1e-9)
|
|
|
|
@pytest.mark.parametrize("name,n_gpus,ub,measured", _MEASURED)
|
|
def test_upper_bounds_measured_per_device_rate(self, name, n_gpus, ub, measured):
|
|
# Never under-reserve.
|
|
b = _backend(embd = 4096)
|
|
per_tok = (
|
|
b._compute_buffer_ctx_bytes(
|
|
100000, n_ubatch = ub, cache_type_kv = "f16", layer_split = n_gpus > 1
|
|
)
|
|
/ 100000
|
|
)
|
|
assert per_tok >= measured * ub, f"{name} n={n_gpus}: {per_tok:.0f} < {measured * ub:.0f}"
|
|
|
|
@pytest.mark.parametrize("name,n_gpus,ub,measured", _MEASURED)
|
|
def test_not_wildly_over_measured_per_device_rate(self, name, n_gpus, ub, measured):
|
|
# The step is a correction, not extra headroom: still the 1.5 safety factor.
|
|
b = _backend(embd = 4096)
|
|
per_tok = (
|
|
b._compute_buffer_ctx_bytes(
|
|
100000, n_ubatch = ub, cache_type_kv = "f16", layer_split = n_gpus > 1
|
|
)
|
|
/ 100000
|
|
)
|
|
expected = measured * ub * LlamaCppBackend._CTX_COMPUTE_F16_MASK_SAFETY
|
|
assert per_tok == pytest.approx(expected, rel = 1e-6)
|
|
|
|
def test_pre_fix_split_reserve_was_short(self):
|
|
# The bug: without the step a split reserved 8/(2*1.5) = 2.67x too little.
|
|
b = _backend(embd = 4096)
|
|
one = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16")
|
|
measured = self._RATE_SPLIT * 512 * 1048576
|
|
assert one < measured
|
|
assert (
|
|
b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16", layer_split = True) >= measured
|
|
)
|
|
|
|
def test_kimi_k3_1m_four_gpu_reserve(self):
|
|
# The reported case: Kimi-K3 UD-IQ1_M, 1M ctx, 4 GPUs, ub 512. llama.cpp
|
|
# allocated 4.0 GiB per device; Studio reserved 1.5 GiB.
|
|
b = _backend(embd = 7168, mla = 576)
|
|
gib = b._compute_buffer_ctx_bytes(1048576, cache_type_kv = "f16", layer_split = True) / (
|
|
1024**3
|
|
)
|
|
assert 4.0 <= gib <= 6.5
|
|
|
|
@pytest.mark.parametrize("ct", ["q8_0", "q4_0"])
|
|
@pytest.mark.parametrize("embd,mla", [(2048, None), (8192, None), (7168, 576)])
|
|
def test_quantized_adds_the_mask_delta(self, ct, embd, mla):
|
|
# The quantized rate is a single-GPU TOTAL holding mask*1 + dequant scratch.
|
|
# Only the mask replicates, so a split adds exactly 3 more masks.
|
|
b = _backend(embd = embd, mla = mla)
|
|
mask = b._compute_buffer_ctx_bytes(131072, cache_type_kv = "f16")
|
|
single = b._compute_buffer_ctx_bytes(131072, cache_type_kv = ct)
|
|
split = b._compute_buffer_ctx_bytes(131072, cache_type_kv = ct, layer_split = True)
|
|
delta = (LlamaCppBackend._CTX_COMPUTE_SPLIT_MULT - 1) * mask
|
|
assert split == pytest.approx(single + delta, rel = 1e-9)
|
|
|
|
@pytest.mark.parametrize("embd,mla", [(2048, None), (2560, None), (8192, None), (7168, 576)])
|
|
def test_quantized_split_beats_the_old_max_floor(self, embd, mla):
|
|
# The pre-fix floor max(quantized, 4x mask) treated the dequant scratch and
|
|
# the enlarged mask as alternatives, leaving the smaller one unbudgeted.
|
|
b = _backend(embd = embd, mla = mla)
|
|
old_floor = max(
|
|
b._compute_buffer_ctx_bytes(262144, cache_type_kv = "q8_0"),
|
|
b._compute_buffer_ctx_bytes(262144, cache_type_kv = "f16", layer_split = True),
|
|
)
|
|
assert b._compute_buffer_ctx_bytes(262144, cache_type_kv = "q8_0", layer_split = True) > (
|
|
old_floor
|
|
)
|
|
|
|
def test_quantized_split_covers_measured_plus_mask(self):
|
|
# Qwen3.5-4B (n_embd 2560) at 256k q8_0: 1330 MiB measured single-device,
|
|
# + 3 replicated [n_kv, ub] f16 masks (768 MiB) = 2098 MiB per device. The
|
|
# old floor reserved 1536 MiB and left ~560 MiB/device unbudgeted.
|
|
b = _backend(embd = 2560)
|
|
ctx, ub = 262144, 512
|
|
mask_mib = 3 * ub * 2 * ctx / MIB
|
|
assert mask_mib == pytest.approx(768.0, rel = 1e-6)
|
|
split_mib = b._compute_buffer_ctx_bytes(ctx, ub, "q8_0", layer_split = True) / MIB
|
|
assert split_mib >= 1330 + mask_mib
|
|
|
|
def test_deepseek4_rate_unchanged(self):
|
|
# Its own rate already carries the mask copies: 72000 - 65.5 KiB/tok measured
|
|
# = 4928 B/tok of margin against the 4608 a split adds.
|
|
b = _backend(embd = 4096, arch = "deepseek4")
|
|
assert b._compute_buffer_ctx_bytes(
|
|
131072, cache_type_kv = "f16", layer_split = True
|
|
) == b._compute_buffer_ctx_bytes(131072, cache_type_kv = "f16")
|
|
measured = TestContextBufferDSV4._MEASURED_1M_GIB * 1024**3 / 1048576
|
|
split_masks = (LlamaCppBackend._CTX_COMPUTE_SPLIT_MULT - 1) * (
|
|
512 * 2 * LlamaCppBackend._CTX_COMPUTE_F16_MASK_SAFETY
|
|
)
|
|
assert LlamaCppBackend._DSV4_CTX_COMPUTE_BYTES_PER_TOK - measured >= split_masks
|
|
|
|
|
|
class TestContextBufferInklingSplit:
|
|
"""Inkling's rates are single-device totals too, and the banded 8192 B/tok has only
|
|
~1.5x headroom over its 5.6 KiB/tok measurement, too little for a split's masks."""
|
|
|
|
_MEASURED_BANDED = 5734 # ~5.6 KiB/tok compute at ub 512 (see the constant)
|
|
_CTX = 1048576
|
|
_UB = 512
|
|
|
|
def _rate(
|
|
self,
|
|
ct,
|
|
layer_split,
|
|
ub = 512,
|
|
):
|
|
b = _backend(embd = 4096, arch = "inkling")
|
|
return (
|
|
b._compute_buffer_ctx_bytes(self._CTX, ub, cache_type_kv = ct, layer_split = layer_split)
|
|
/ self._CTX
|
|
)
|
|
|
|
def test_pre_fix_banded_split_reserve_was_short(self):
|
|
# The bug: the banded rate alone does not cover measured + 3 more masks.
|
|
measured_split = self._MEASURED_BANDED + 3 * self._UB * 2
|
|
assert LlamaCppBackend._INKLING_CTX_COMPUTE_BYTES_PER_TOK < measured_split
|
|
assert self._rate("f16", True) >= measured_split
|
|
|
|
@pytest.mark.parametrize("ct", ["f16", None, "q8_0"])
|
|
def test_split_adds_exactly_the_extra_mask_copies(self, ct):
|
|
delta = (LlamaCppBackend._CTX_COMPUTE_SPLIT_MULT - 1) * (
|
|
self._UB * 2 * LlamaCppBackend._CTX_COMPUTE_F16_MASK_SAFETY
|
|
)
|
|
assert self._rate(ct, True) == pytest.approx(self._rate(ct, False) + delta, rel = 1e-9)
|
|
|
|
def test_dense_fallback_delta_is_present_but_tiny(self):
|
|
# ~402 KiB/tok dwarfs the 4608 B/tok of masks, yet the masks are allocated on
|
|
# that path too, so charge them rather than argue about the margin.
|
|
single, split = self._rate("q8_0", False), self._rate("q8_0", True)
|
|
assert single < split <= single * 1.02
|
|
|
|
def test_split_delta_scales_with_ubatch(self):
|
|
assert self._rate("f16", True, ub = 1024) - self._rate("f16", False, ub = 1024) == (
|
|
pytest.approx(2 * (self._rate("f16", True) - self._rate("f16", False)), rel = 1e-9)
|
|
)
|
|
|
|
def test_single_device_rates_unchanged(self):
|
|
# No context is lost on a single GPU: the flag defaults off.
|
|
for ct in ("f16", "q8_0"):
|
|
assert self._rate(ct, False) == pytest.approx(
|
|
(
|
|
LlamaCppBackend._INKLING_CTX_COMPUTE_DENSE_BYTES_PER_TOK
|
|
if ct == "q8_0"
|
|
else LlamaCppBackend._INKLING_CTX_COMPUTE_BYTES_PER_TOK
|
|
),
|
|
rel = 1e-6,
|
|
)
|
|
|
|
|
|
class TestLayerSplitWiring:
|
|
"""``_cc_bytes`` in ``load_model`` learns the device count, so it is the one place
|
|
that can set ``layer_split``. Source-level: the closure needs a real load."""
|
|
|
|
def _cc_bytes_source(self):
|
|
import inspect
|
|
|
|
src = inspect.getsource(LlamaCppBackend.load_model).splitlines()
|
|
start = next(i for i, l in enumerate(src) if "def _cc_bytes(" in l)
|
|
indent = len(src[start]) - len(src[start].lstrip())
|
|
body = [src[start]]
|
|
for line in src[start + 1 :]:
|
|
if line.strip() and len(line) - len(line.lstrip()) <= indent:
|
|
break
|
|
body.append(line)
|
|
return "\n".join(body)
|
|
|
|
def test_forwards_layer_split_from_device_count(self):
|
|
assert "layer_split = n_gpus > 1" in self._cc_bytes_source()
|
|
|
|
def test_still_scales_by_device_count(self):
|
|
# Per device on top of the replication, not instead of it: n x the split rate.
|
|
assert "max(1, n_gpus) * self._compute_buffer_ctx_bytes" in self._cc_bytes_source()
|
|
|
|
|
|
class TestPipelineParallelPredicate:
|
|
"""The 4x step is llama.cpp's pipeline parallelism (ggml n_copies == 4), which
|
|
llama-context.cpp declines unless the split mode is layer, KV offload is on and the
|
|
tensor-override list is empty; charging it then wastes context. Causal check: on 2
|
|
GPUs a -ot matching nothing changes no placement yet takes the rate 8.00 -> 2.00."""
|
|
|
|
def _off(
|
|
self,
|
|
args = None,
|
|
env = None,
|
|
n_layers = None,
|
|
):
|
|
from core.inference.llama_cpp import _pipeline_parallel_disabled_by_args
|
|
return _pipeline_parallel_disabled_by_args(args, env = env or {}, n_layers = n_layers)
|
|
|
|
def test_plain_launch_keeps_the_step(self):
|
|
assert self._off([]) is False
|
|
assert self._off(None) is False
|
|
assert self._off(["-c", "131072", "--parallel", "4"]) is False
|
|
|
|
@pytest.mark.parametrize("flag", ["-ot", "--override-tensor"])
|
|
def test_any_tensor_override_disables(self, flag):
|
|
# Even a pattern matching nothing: has_tensor_overrides only checks non-empty.
|
|
assert self._off([flag, "zzz_matches_nothing=CUDA0"]) is True
|
|
|
|
@pytest.mark.parametrize("flag", ["-nkvo", "--no-kv-offload"])
|
|
def test_kv_offload_off_disables(self, flag):
|
|
assert self._off([flag]) is True
|
|
|
|
def test_env_tensor_override_disables(self):
|
|
assert self._off([], env = {"LLAMA_ARG_OVERRIDE_TENSOR": "exps=CPU"}) is True
|
|
assert self._off([], env = {"LLAMA_ARG_OVERRIDE_TENSOR": ""}) is False
|
|
|
|
@pytest.mark.parametrize("val", ["off", "0", "false", "disabled"])
|
|
def test_env_kv_offload_off_disables(self, val):
|
|
assert self._off([], env = {"LLAMA_ARG_KV_OFFLOAD": val}) is True
|
|
|
|
def test_env_kv_offload_on_keeps_the_step(self):
|
|
assert self._off([], env = {"LLAMA_ARG_KV_OFFLOAD": "1"}) is False
|
|
|
|
def test_unrelated_flags_do_not_disable(self):
|
|
assert self._off(["--kv-unified", "-fa", "on", "-ngl", "-1"]) is False
|
|
|
|
@pytest.mark.parametrize(
|
|
"flag", ["-otd", "--override-tensor-draft", "--spec-draft-override-tensor"]
|
|
)
|
|
def test_draft_override_does_not_disable(self, flag):
|
|
# -otd targets the draft model, not the main model's tensor_buft_overrides.
|
|
assert self._off([flag, "exps=CPU"]) is False
|
|
|
|
# -- KV offload is last-wins across env then CLI (arg.cpp parses env first) --
|
|
|
|
@pytest.mark.parametrize("flag", ["-kvo", "--kv-offload"])
|
|
def test_cli_kv_offload_reenable_beats_a_false_env(self, flag):
|
|
# The positive form exists, so this launch pipelines: 1x here would OOM it.
|
|
assert self._off([flag], env = {"LLAMA_ARG_KV_OFFLOAD": "0"}) is False
|
|
|
|
def test_last_kv_offload_flag_wins(self):
|
|
assert self._off(["-kvo", "-nkvo"]) is True
|
|
assert self._off(["-nkvo", "-kvo"]) is False
|
|
|
|
def test_kv_offload_env_junk_value_keeps_the_default(self):
|
|
assert self._off([], env = {"LLAMA_ARG_KV_OFFLOAD": "maybe"}) is False
|
|
|
|
# -- pipeline parallelism requires LLAMA_SPLIT_MODE_LAYER --
|
|
|
|
@pytest.mark.parametrize("mode", ["none", "row", "NONE", " row "])
|
|
def test_non_layer_split_mode_disables(self, mode):
|
|
assert self._off(["-sm", mode]) is True
|
|
assert self._off([f"--split-mode={mode}"]) is True
|
|
|
|
def test_explicit_layer_split_mode_keeps_the_step(self):
|
|
assert self._off(["-sm", "layer"]) is False
|
|
assert self._off(["-sm", "row", "-sm", "layer"]) is False # last-wins
|
|
|
|
def test_tensor_split_mode_keeps_the_step(self):
|
|
# The layer branch is an elif on tensor_parallel, so it is only reached
|
|
# after a downgrade -- which strips -sm and leaves the child pipelined.
|
|
assert self._off(["-sm", "tensor"]) is False
|
|
|
|
def test_layer_branch_is_only_reached_after_the_flag_is_stripped(self):
|
|
# Guards the assumption above.
|
|
import inspect
|
|
|
|
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
|
assert "iftensor_parallelandtp_gpus:" in compact
|
|
assert "extra_args=strip_split_mode_only(" in compact
|
|
assert "elifgpusandself._can_estimate_kv()andeffective_ctx>0:" in compact
|
|
|
|
def test_env_split_mode_is_ignored(self):
|
|
# load_model pops a non-layer inherited LLAMA_ARG_SPLIT_MODE on the layer path,
|
|
# so the child always runs -sm layer; honoring it would reserve 1x for a split.
|
|
assert self._off([], env = {"LLAMA_ARG_SPLIT_MODE": "row"}) is False
|
|
|
|
def test_layer_path_scrubs_a_non_layer_split_mode_env(self):
|
|
# Guards the assumption the test above rests on.
|
|
import inspect
|
|
|
|
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
|
assert 'if_inherited_smand_inherited_sm!="layer":' in compact
|
|
assert 'env.pop("LLAMA_ARG_SPLIT_MODE",None)' in compact
|
|
|
|
# -- -cmoe / -ncmoe set tensor_buft_overrides exactly like -ot --
|
|
|
|
@pytest.mark.parametrize("flag", ["-cmoe", "--cpu-moe"])
|
|
def test_cpu_moe_disables(self, flag):
|
|
assert self._off([flag]) is True
|
|
|
|
@pytest.mark.parametrize("flag", ["-ncmoe", "--n-cpu-moe"])
|
|
def test_n_cpu_moe_disables(self, flag):
|
|
assert self._off([flag, "8"]) is True
|
|
assert self._off([f"{flag}=8"]) is True
|
|
|
|
@pytest.mark.parametrize("flag", ["-ncmoe", "--n-cpu-moe"])
|
|
def test_n_cpu_moe_zero_keeps_the_step(self, flag):
|
|
# The handler loops N times, so 0 pushes no override at all.
|
|
assert self._off([flag, "0"]) is False
|
|
|
|
def test_env_cpu_moe_disables(self):
|
|
assert self._off([], env = {"LLAMA_ARG_CPU_MOE": "1"}) is True
|
|
# handler_void only fires on a truthy env value.
|
|
assert self._off([], env = {"LLAMA_ARG_CPU_MOE": "0"}) is False
|
|
assert self._off([], env = {"LLAMA_ARG_CPU_MOE": ""}) is False
|
|
|
|
def test_env_n_cpu_moe_disables(self):
|
|
assert self._off([], env = {"LLAMA_ARG_N_CPU_MOE": "4"}) is True
|
|
assert self._off([], env = {"LLAMA_ARG_N_CPU_MOE": "0"}) is False
|
|
assert self._off([], env = {"LLAMA_ARG_N_CPU_MOE": "not-a-number"}) is False
|
|
|
|
# -- a finite -ngl override loads a layer prefix, so pipelining is off --
|
|
|
|
@pytest.mark.parametrize("flag", ["-ngl", "--gpu-layers", "--n-gpu-layers"])
|
|
def test_finite_gpu_layers_below_the_count_disables(self, flag):
|
|
# User extras land after Studio's -ngl -1, so this last-wins.
|
|
assert self._off([flag, "1"], n_layers = 93) is True
|
|
assert self._off([f"{flag}=1"], n_layers = 93) is True
|
|
|
|
def test_all_layers_keeps_the_step(self):
|
|
# n_gpu_layers() is n_layer_all + 1 for any negative value.
|
|
assert self._off(["-ngl", "-1"], n_layers = 93) is False
|
|
|
|
def test_gpu_layers_above_the_count_keeps_the_step(self):
|
|
# 999 > n_layer_all, so llama.cpp still pipelines.
|
|
assert self._off(["-ngl", "999"], n_layers = 93) is False
|
|
|
|
def test_gpu_layers_at_the_boundary(self):
|
|
# Pipelining needs n_gpu_layers > n_layer_all, so equal is off; one above
|
|
# keeps the step because block_count can undercount n_layer_all.
|
|
assert self._off(["-ngl", "93"], n_layers = 93) is True
|
|
assert self._off(["-ngl", "94"], n_layers = 93) is False
|
|
|
|
def test_zero_gpu_layers_disables(self):
|
|
assert self._off(["-ngl", "0"], n_layers = 93) is True
|
|
|
|
def test_unknown_layer_count_keeps_the_step(self):
|
|
assert self._off(["-ngl", "1"]) is False
|
|
assert self._off(["-ngl", "1"], n_layers = 0) is False
|
|
|
|
def test_last_gpu_layers_flag_wins(self):
|
|
assert self._off(["-ngl", "1", "--gpu-layers", "-1"], n_layers = 93) is False
|
|
assert self._off(["-ngl", "-1", "--gpu-layers", "1"], n_layers = 93) is True
|
|
|
|
def test_malformed_gpu_layers_keeps_the_step(self):
|
|
# validate_extra_args rejects these upstream; ambiguous here means keep.
|
|
assert self._off(["-ngl", "abc"], n_layers = 93) is False
|
|
assert self._off(["-ngl", "-2"], n_layers = 93) is False
|
|
assert self._off(["-ngl"], n_layers = 93) is False
|
|
|
|
def test_wired_into_the_fit(self):
|
|
# The flag has to reach _cc_bytes, else the predicate is dead code.
|
|
import inspect
|
|
|
|
src = inspect.getsource(LlamaCppBackend.load_model)
|
|
compact = "".join(src.split())
|
|
assert "_pipeline_parallel_disabled_by_args(extra_args,n_layers=self._n_layers)" in compact
|
|
assert "layer_split = n_gpus > 1 and not _pipeline_parallel_off" in src
|
|
# The count is only real if the GGUF header was parsed first.
|
|
assert src.index("_read_gguf_metadata(model_path)") < src.index(
|
|
"_pipeline_parallel_disabled_by_args("
|
|
)
|
|
|
|
|
|
class TestPerDeviceSplitReserve:
|
|
"""The auto-context loop admits a GPU subset on the POOLED budget, but the per-device
|
|
reserve (flat layer overhead + this PR's enlarged context-compute copy) is replicated
|
|
on every card: in the sum a roomy card's spare VRAM covers a nearly full card's copy,
|
|
and the small card OOMs at launch. Exposed when the loop cannot start at one GPU
|
|
(``_auto_min_gpus >= 2``, set by every tensor -> layer downgrade); starting at one,
|
|
the pooled test charges n copies where the per-card test charges one, so size n is
|
|
reached only after n-1 failed, which bounds the smallest card below by the reserve -
|
|
the check is then provably redundant, homogeneous or not."""
|
|
|
|
_OH = LlamaCppBackend._PIPELINE_PER_DEVICE_OVERHEAD_MIB * MIB
|
|
_UB = 2048
|
|
# 48 GB / 24 GB cards, the small one mostly occupied. Its usable budget still
|
|
# clears the flat overhead alone, so _auto_min_gpus keeps counting it.
|
|
_HETEROGENEOUS = ([(0, 40_000), (1, 2_500)], {0: 49_152, 1: 24_576})
|
|
_HOMOGENEOUS = ([(0, 40_000), (1, 40_000)], {0: 49_152, 1: 49_152})
|
|
|
|
def _fit_backend(self, kv_per_tok = 20_000):
|
|
b = _backend(embd = 8192)
|
|
b._can_estimate_kv = lambda: True
|
|
b._estimate_kv_cache_bytes = lambda ctx, ct = None, **kw: max(0, ctx) * kv_per_tok
|
|
return b
|
|
|
|
def _drive(
|
|
self,
|
|
b,
|
|
gpus,
|
|
totals,
|
|
model_mib,
|
|
native_ctx,
|
|
min_gpus = 2,
|
|
enforce = True,
|
|
cap = True,
|
|
):
|
|
"""Mirror of load_model's auto-context subset loop over the production fit
|
|
and reserve helpers. Returns (gpu_indices, chosen_ctx)."""
|
|
frac = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
|
|
model_bytes = model_mib * MIB
|
|
|
|
def usable(g):
|
|
return g[1] - (1.0 - frac) * totals[g[0]]
|
|
|
|
ranked = sorted(gpus, key = usable, reverse = True)
|
|
for n in range(min_gpus, len(ranked) + 1):
|
|
subset = ranked[:n]
|
|
pool = sum(max(0.0, usable(g)) for g in subset)
|
|
ms = model_bytes + (n - 1) * self._OH
|
|
cc = lambda c, n = n: (
|
|
n * b._compute_buffer_ctx_bytes(c, self._UB, "f16", layer_split = n > 1)
|
|
)
|
|
capped = b._fit_context_to_vram(
|
|
native_ctx,
|
|
pool,
|
|
ms,
|
|
"f16",
|
|
n_ubatch = self._UB,
|
|
compute_ctx_bytes_fn = cc,
|
|
budget_frac = 1.0,
|
|
total_mib = None,
|
|
)
|
|
if (ms + b._estimate_kv_cache_bytes(capped) + cc(capped)) / MIB > pool:
|
|
continue
|
|
usable_mib = [usable(g) for g in subset]
|
|
reserve_at = lambda c, n = n: (self._OH if n > 1 else 0) + cc(c, n) // n
|
|
if enforce and not LlamaCppBackend._every_gpu_holds_reserve(
|
|
usable_mib, reserve_at(capped)
|
|
):
|
|
if not cap:
|
|
continue
|
|
capped = LlamaCppBackend._cap_ctx_to_per_device_reserve(
|
|
capped, usable_mib, reserve_at
|
|
)
|
|
if capped <= 0:
|
|
continue
|
|
if (ms + b._estimate_kv_cache_bytes(capped) + cc(capped)) / MIB > pool:
|
|
continue
|
|
return sorted(idx for idx, _ in subset), capped
|
|
return None, 0
|
|
|
|
def _drive_reduced(
|
|
self,
|
|
b,
|
|
gpus,
|
|
totals,
|
|
model_mib,
|
|
min_gpus = 2,
|
|
enforce = True,
|
|
):
|
|
"""Mirror of the Auto offload loop the native loop falls through to. Same
|
|
pooled admission, so it needs the same per-device gate.
|
|
|
|
Driven at the fit search floor rather than at ``_AUTO_OFFLOAD_CTX``: what
|
|
this exercises is the per-device reserve gate, and the floor is the lowest
|
|
context the loop above can still be admitted at, so it is the value that
|
|
makes the gate decide anything.
|
|
"""
|
|
frac = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
|
|
ctx = _FIT_MIN_CTX
|
|
|
|
def usable(g):
|
|
return g[1] - (1.0 - frac) * totals[g[0]]
|
|
|
|
ranked = sorted(gpus, key = usable, reverse = True)
|
|
for n in range(min_gpus, len(ranked) + 1):
|
|
subset = ranked[:n]
|
|
pool = sum(max(0.0, usable(g)) for g in subset)
|
|
cc = n * b._compute_buffer_ctx_bytes(ctx, self._UB, "f16", layer_split = n > 1)
|
|
ms = model_mib * MIB + (n - 1) * self._OH
|
|
if (ms + b._estimate_kv_cache_bytes(ctx) + cc) / MIB > pool:
|
|
continue
|
|
if enforce and not LlamaCppBackend._every_gpu_holds_reserve(
|
|
(usable(g) for g in subset),
|
|
(self._OH if n > 1 else 0) + cc // n,
|
|
):
|
|
continue
|
|
return sorted(idx for idx, _ in subset), ctx
|
|
return None, 0
|
|
|
|
def test_reduced_context_fallback_enforces_the_same_reserve(self):
|
|
# Card 1 sized one MiB under the reserve it replicates at 4096 ctx: the pooled
|
|
# budget still admits the pair, so dropping to 4096 pinned a card that OOMs.
|
|
b = self._fit_backend()
|
|
totals = {0: 49_152, 1: 24_576}
|
|
reserve_mib = (
|
|
self._OH + b._compute_buffer_ctx_bytes(4096, self._UB, "f16", layer_split = True)
|
|
) / MIB
|
|
gpus = [(0, 40_000), (1, round(reserve_mib - 1 + 0.03 * totals[1]))]
|
|
assert self._drive_reduced(b, gpus, totals, 20_480, enforce = False) == ([0, 1], 4096)
|
|
assert self._drive_reduced(b, gpus, totals, 20_480) == (None, 0)
|
|
|
|
def test_reduced_context_fallback_keeps_a_card_that_holds_it(self):
|
|
b = self._fit_backend()
|
|
totals = {0: 49_152, 1: 24_576}
|
|
reserve_mib = (
|
|
self._OH + b._compute_buffer_ctx_bytes(4096, self._UB, "f16", layer_split = True)
|
|
) / MIB
|
|
gpus = [(0, 40_000), (1, round(reserve_mib + 1 + 0.03 * totals[1]))]
|
|
assert self._drive_reduced(b, gpus, totals, 20_480) == ([0, 1], 4096)
|
|
|
|
def test_pooled_budget_hides_the_small_cards_shortfall(self):
|
|
# Pre-fix: the pair is admitted at native context even though card 1 has
|
|
# ~1.7 GiB usable and owes 1 GiB overhead + 6 GiB of replicated KQ mask.
|
|
b = self._fit_backend()
|
|
gpus, totals = self._HETEROGENEOUS
|
|
gpu_indices, ctx = self._drive(b, gpus, totals, 20_480, 262144, enforce = False)
|
|
assert gpu_indices == [0, 1] and ctx == 262144
|
|
reserve_mib = (
|
|
self._OH + b._compute_buffer_ctx_bytes(ctx, self._UB, "f16", layer_split = True)
|
|
) / MIB
|
|
card1_usable = 2_500 - 0.03 * 24_576
|
|
assert card1_usable < reserve_mib # would OOM card 1 at load
|
|
|
|
def test_subset_is_capped_not_rejected_when_a_card_cannot_hold_its_reserve(self):
|
|
# 1762.72 MiB usable on card 1 holds 31488, not the pooled 262144. Rejecting
|
|
# the subset instead drops auto to the 4096 fallback for no reason.
|
|
b = self._fit_backend()
|
|
gpus, totals = self._HETEROGENEOUS
|
|
assert self._drive(b, gpus, totals, 20_480, 262144, cap = False) == (None, 0)
|
|
assert self._drive(b, gpus, totals, 20_480, 262144) == ([0, 1], 31_488)
|
|
|
|
def test_homogeneous_gpus_are_unaffected(self):
|
|
b = self._fit_backend()
|
|
gpus, totals = self._HOMOGENEOUS
|
|
with_gate = self._drive(b, gpus, totals, 20_480, 262144)
|
|
without = self._drive(b, gpus, totals, 20_480, 262144, enforce = False)
|
|
assert with_gate == without == ([0, 1], 262144)
|
|
|
|
@pytest.mark.parametrize("model_mib", [8_192, 20_480, 30_720])
|
|
def test_no_op_when_the_loop_may_start_at_one_gpu(self, model_mib):
|
|
# _auto_min_gpus == 1: the n-1 subset having failed already bounds the
|
|
# smallest card below by the reserve, so the gate changes nothing.
|
|
b = self._fit_backend()
|
|
for gpus, totals in (self._HETEROGENEOUS, self._HOMOGENEOUS):
|
|
with_gate = self._drive(b, gpus, totals, model_mib, 262144, min_gpus = 1)
|
|
without = self._drive(b, gpus, totals, model_mib, 262144, min_gpus = 1, enforce = False)
|
|
assert with_gate == without
|
|
assert with_gate[0] is not None
|
|
|
|
def test_reserve_check_rejects_only_the_short_card(self):
|
|
reserve = 3072 * MIB # bytes in, MiB compared
|
|
assert LlamaCppBackend._every_gpu_holds_reserve([4000.0, 3072.0], reserve) is True
|
|
assert LlamaCppBackend._every_gpu_holds_reserve([40000.0, 3071.0], reserve) is False
|
|
assert LlamaCppBackend._every_gpu_holds_reserve([-10.0], reserve) is False
|
|
assert LlamaCppBackend._every_gpu_holds_reserve([], reserve) is False
|
|
|
|
def test_wired_into_the_auto_context_loop(self):
|
|
import inspect
|
|
|
|
compact = "".join(inspect.getsource(LlamaCppBackend.load_model).split())
|
|
# Native-context loop, the reduced-to-4096 fallback below it, and the
|
|
# Auto drafter-drop probe above them, which caps to the same reserve so
|
|
# it cannot price a drafter at a context the weakest card never holds.
|
|
assert compact.count("ifnotself._every_gpu_holds_reserve(") == 3
|
|
# Gated on the chosen context, and only reachable after the pooled test.
|
|
assert "_usable_mib=[_gpu_usable(g,pin_fraction)forginsubset]" in compact
|
|
assert "(_gpu_usable(g,pin_fraction)forginsubset)," in compact
|
|
assert "+_cc_bytes(c,n)//n)" in compact
|
|
assert "+_cc_bytes(effective_ctx,n_gpus)//n_gpus," in compact
|
|
# The cap runs only on gate failure, and the pooled price is redone after it.
|
|
assert (
|
|
compact.count(
|
|
"capped=self._cap_ctx_to_per_device_reserve("
|
|
"capped,_usable_mib,_reserve_at)ifcapped<=0:continue"
|
|
)
|
|
== 1
|
|
)
|
|
assert "kv=_kv_bytes(capped)footprint_mib=(_ms+kv+_mtp_bytes(capped)" in compact
|
|
|
|
|
|
class TestPerDeviceReserveCap:
|
|
"""Rejecting a subset outright costs context the smallest card could have held.
|
|
Reuses the gate class's fixtures; the cap only changes what "reject" means."""
|
|
|
|
_OH = TestPerDeviceSplitReserve._OH
|
|
_UB = TestPerDeviceSplitReserve._UB
|
|
_HETEROGENEOUS = TestPerDeviceSplitReserve._HETEROGENEOUS
|
|
_HOMOGENEOUS = TestPerDeviceSplitReserve._HOMOGENEOUS
|
|
_fit_backend = TestPerDeviceSplitReserve._fit_backend
|
|
_drive = TestPerDeviceSplitReserve._drive
|
|
|
|
def test_cap_is_exact_at_the_256_boundary(self):
|
|
b = self._fit_backend()
|
|
usable = 2_500 - 0.03 * 24_576 # 1762.72 MiB
|
|
reserve = lambda c: (
|
|
(self._OH + b._compute_buffer_ctx_bytes(c, self._UB, "f16", layer_split = True)) / MIB
|
|
)
|
|
assert reserve(31_488) == 1762.0 <= usable
|
|
assert reserve(31_744) == 1768.0 > usable
|
|
|
|
def test_capped_context_still_fits_the_pooled_budget(self):
|
|
b = self._fit_backend()
|
|
gpus, totals = self._HETEROGENEOUS
|
|
idx, ctx = self._drive(b, gpus, totals, 20_480, 262144)
|
|
assert idx == [0, 1] and ctx == 31_488
|
|
pool = sum(g[1] - 0.03 * totals[g[0]] for g in gpus)
|
|
cc = 2 * b._compute_buffer_ctx_bytes(ctx, self._UB, "f16", layer_split = True)
|
|
footprint = (20_480 * MIB + self._OH + b._estimate_kv_cache_bytes(ctx) + cc) / MIB
|
|
assert footprint <= pool
|
|
|
|
def test_homogeneous_gpus_are_unaffected_by_the_cap(self):
|
|
b = self._fit_backend()
|
|
gpus, totals = self._HOMOGENEOUS
|
|
assert self._drive(b, gpus, totals, 20_480, 262144) == ([0, 1], 262144)
|
|
|
|
def test_cap_floors_at_4096_and_still_rejects_below_it(self):
|
|
# usable 1118.72 < reserve(4096) == 1120.0: nothing to salvage.
|
|
b = self._fit_backend()
|
|
assert self._drive(
|
|
b, [(0, 40_000), (1, 1_856)], {0: 49_152, 1: 24_576}, 20_480, 262144
|
|
) == (None, 0)
|
|
|
|
def test_cap_keeps_a_card_that_holds_exactly_4096(self):
|
|
b = self._fit_backend()
|
|
assert self._drive(
|
|
b, [(0, 40_000), (1, 1_858)], {0: 49_152, 1: 24_576}, 20_480, 262144
|
|
) == ([0, 1], 4096)
|
|
|
|
def test_flat_arch_term_is_not_rate_inverted(self):
|
|
# deepseek4 carries a flat indexer term, so inverting the per-token rate
|
|
# answers 43341, whose reserve is 6048 MiB on a 4000 MiB card.
|
|
b = _backend(embd = 7168, arch = "deepseek4")
|
|
reserve = lambda c: self._OH + b._compute_buffer_ctx_bytes(c, 512, "q8_0", layer_split = True)
|
|
cap = LlamaCppBackend._cap_ctx_to_per_device_reserve(200_000, [4000.0], reserve)
|
|
assert cap == 13_312
|
|
assert reserve(cap) / MIB <= 4000.0 < reserve(cap + 256) / MIB
|
|
assert reserve(43_341) / MIB > 4000.0
|
|
|
|
def test_flat_arch_below_the_floor_is_infeasible(self):
|
|
b = _backend(embd = 7168, arch = "deepseek4")
|
|
reserve = lambda c: self._OH + b._compute_buffer_ctx_bytes(c, 512, "q8_0", layer_split = True)
|
|
assert LlamaCppBackend._cap_ctx_to_per_device_reserve(200_000, [2500.0], reserve) == 0
|
|
|
|
@pytest.mark.parametrize("model_mib", [8_192, 16_384, 20_480, 30_720])
|
|
def test_capping_never_loses_to_continuing_with_more_gpus(self, model_mib):
|
|
# A third small card cannot rescue the subset: it stays in the ranking, so
|
|
# the reserve there is no smaller. Capping at 2 beats falling through.
|
|
b = self._fit_backend()
|
|
gpus = [(0, 40_000), (1, 2_500), (2, 2_400)]
|
|
totals = {0: 49_152, 1: 24_576, 2: 24_576}
|
|
assert self._drive(b, gpus, totals, model_mib, 262144, cap = False) == (None, 0)
|
|
idx, ctx = self._drive(b, gpus, totals, model_mib, 262144)
|
|
assert idx == [0, 1] and ctx == 31_488
|
|
|
|
def test_helper_edges(self):
|
|
cap = LlamaCppBackend._cap_ctx_to_per_device_reserve
|
|
assert cap(262144, [], lambda c: 0) == 0
|
|
assert cap(1024, [4000.0], lambda c: 0) == 0 # ctx below the 4096 floor
|
|
assert cap(262144, [4000.0], lambda c: 0) == 262144 # free reserve, no cap
|
|
linear = lambda c: c * 16384 # 16 KiB per ctx token, so 2000 MiB buys 128000
|
|
best = cap(262144, [2000.0], linear)
|
|
assert best == 128_000 and best % 256 == 0
|
|
assert linear(best) / MIB <= 2000.0 < linear(best + 256) / MIB
|
|
|
|
|
|
class TestSplitRateRecheckAfterSelection:
|
|
"""``_select_gpus`` derives the device count FROM the footprint, so its callers can
|
|
only price the context-compute buffer at the single-device rate. A multi-GPU answer
|
|
then has to be re-priced at the split rate before it is pinned with
|
|
``use_fit = False``, or a high-context explicit request OOMs at launch. Synthetic
|
|
VRAM maps over the production helper: 24 GB cards at 0.97 (usable 23838 MiB each),
|
|
ctx 1M at ub 512 f16 -> 1536 MiB of compute per device single-device, 6144 MiB split,
|
|
so each card in a split owes 4608 MiB more than the first pass charged it."""
|
|
|
|
_OH = LlamaCppBackend._PIPELINE_PER_DEVICE_OVERHEAD_MIB * MIB
|
|
_UB = 512
|
|
_CTX = 1048576
|
|
_FRAC = LlamaCppBackend._GPU_PIN_VRAM_FRACTION
|
|
_CARD = 24_576
|
|
|
|
def _cards(self, n):
|
|
return [(i, self._CARD) for i in range(n)], {i: self._CARD for i in range(n)}
|
|
|
|
def _pin(
|
|
self,
|
|
total_mib,
|
|
n_cards,
|
|
recheck = True,
|
|
min_gpus = 1,
|
|
ctx = None,
|
|
):
|
|
"""Mirror of the explicit-context branch: total_mib is its weights + KV + MTP
|
|
footprint, to which each path adds one single-device compute copy."""
|
|
b = _backend(embd = 4096)
|
|
ctx = ctx or self._CTX
|
|
cc1 = b._compute_buffer_ctx_bytes(ctx, self._UB, "f16")
|
|
cc_split = b._compute_buffer_ctx_bytes(ctx, self._UB, "f16", layer_split = True)
|
|
gpus, totals = self._cards(n_cards)
|
|
return LlamaCppBackend._select_gpus_split_aware(
|
|
total_mib * MIB + cc1,
|
|
gpus,
|
|
usable_fraction = self._FRAC,
|
|
total_by_idx = totals,
|
|
per_device_overhead_bytes = self._OH + cc1,
|
|
min_gpus = min_gpus,
|
|
split_extra_bytes = (cc_split - cc1) if recheck else 0,
|
|
)
|
|
|
|
def test_pre_fix_pinned_a_pair_that_cannot_hold_the_split_rate(self):
|
|
# The bug, over the plain selector this branch used to call: the pair needs
|
|
# 44096 MiB of its 47677 MiB pool at the single-device rate, but 53312 at the
|
|
# split rate -- pinned ~5.5 GiB short, with no --fit fallback after -ngl -1.
|
|
b = _backend(embd = 4096)
|
|
cc1 = b._compute_buffer_ctx_bytes(self._CTX, self._UB, "f16")
|
|
ccs = b._compute_buffer_ctx_bytes(self._CTX, self._UB, "f16", layer_split = True)
|
|
gpus, totals = self._cards(2)
|
|
assert LlamaCppBackend._select_gpus(
|
|
40_000 * MIB + cc1,
|
|
gpus,
|
|
usable_fraction = self._FRAC,
|
|
total_by_idx = totals,
|
|
per_device_overhead_bytes = self._OH + cc1,
|
|
) == ([0, 1], False)
|
|
pool = 2 * (self._CARD - (1.0 - self._FRAC) * self._CARD)
|
|
assert (40_000 * MIB + ccs + self._OH + ccs) / MIB > pool
|
|
|
|
def test_recheck_falls_back_to_fit_when_no_subset_holds_it(self):
|
|
# Honest failure: --fit on degrades to CPU offload, matching this branch's
|
|
# documented behaviour, instead of pinning a launch that OOMs.
|
|
assert self._pin(40_000, 2) == (None, True)
|
|
|
|
def test_recheck_widens_the_subset_when_a_card_is_spare(self):
|
|
# Three cards: the first pass still answers 2, the re-check takes all 3.
|
|
assert self._pin(40_000, 3, recheck = False) == ([0, 1], False)
|
|
assert self._pin(40_000, 3) == ([0, 1, 2], False)
|
|
|
|
def test_single_gpu_pin_is_untouched(self):
|
|
assert self._pin(20_000, 2) == ([0], False)
|
|
assert self._pin(20_000, 2, recheck = False) == ([0], False)
|
|
|
|
def test_equal_cards_never_collapse_to_one_gpu(self):
|
|
# Every card clears the enlarged overhead, so the retry keeps min_gpus.
|
|
for total in range(24_000, 46_000, 2_000):
|
|
gi, use_fit = self._pin(total, 4)
|
|
assert use_fit or (gi is not None and len(gi) >= 2)
|
|
|
|
def test_collapse_to_one_gpu_is_repriced_without_the_delta(self):
|
|
# Unequal cards: only the big one clears overhead + delta, so _select_gpus cuts
|
|
# its usable-card count to one. A lone card is not a split and pays no delta,
|
|
# so charging it there sent a load that fits alone to --fit on (CPU offload).
|
|
gpus = [(0, 16 * 1024), (1, 3 * 1024)]
|
|
kw = dict(usable_fraction = 1.0, per_device_overhead_bytes = int(2.5 * GIB))
|
|
assert LlamaCppBackend._select_gpus(int(14 * GIB), gpus, min_gpus = 2, **kw) == (
|
|
[0, 1],
|
|
False,
|
|
)
|
|
assert LlamaCppBackend._select_gpus_split_aware(
|
|
int(14 * GIB), gpus, min_gpus = 2, split_extra_bytes = int(4.5 * GIB), **kw
|
|
) == ([0], False)
|
|
# Exactly the plain single-device answer, not a relaxed split.
|
|
assert LlamaCppBackend._select_gpus(int(14 * GIB), gpus, min_gpus = 1, **kw) == (
|
|
[0],
|
|
False,
|
|
)
|
|
|
|
def test_reprice_still_reports_fit_when_no_single_card_holds_it(self):
|
|
# 30 GiB over two 20 GiB cards: the split no longer fits and neither does one
|
|
# card, so the honest answer stays --fit on.
|
|
assert LlamaCppBackend._select_gpus_split_aware(
|
|
int(30 * GIB),
|
|
[(0, 20 * 1024), (1, 20 * 1024)],
|
|
usable_fraction = 1.0,
|
|
per_device_overhead_bytes = int(1 * GIB),
|
|
min_gpus = 2,
|
|
split_extra_bytes = int(15 * GIB),
|
|
) == (None, True)
|
|
|
|
def test_zero_step_reduces_to_plain_selection(self):
|
|
# llama.cpp declining pipeline parallelism makes the step 0 (_cc_split_extra
|
|
# reads the same layer_split gate), and the helper is then a pass-through.
|
|
b = _backend(embd = 4096)
|
|
cc1 = b._compute_buffer_ctx_bytes(self._CTX, self._UB, "f16")
|
|
gpus, totals = self._cards(3)
|
|
for total_mib in (20_000, 40_000, 90_000):
|
|
assert self._pin(total_mib, 3, recheck = False) == LlamaCppBackend._select_gpus(
|
|
total_mib * MIB + cc1,
|
|
gpus,
|
|
usable_fraction = self._FRAC,
|
|
total_by_idx = totals,
|
|
per_device_overhead_bytes = self._OH + cc1,
|
|
)
|
|
|
|
def test_small_context_is_unaffected(self):
|
|
# 4096 ctx: the 18 MiB of extra masks changes no decision.
|
|
assert self._pin(40_000, 2, ctx = 4096) == self._pin(40_000, 2, recheck = False, ctx = 4096)
|
|
|
|
def test_wired_into_every_call_site(self):
|
|
import ast
|
|
import inspect
|
|
import re
|
|
import textwrap
|
|
|
|
source = inspect.getsource(LlamaCppBackend.load_model)
|
|
load = "".join(source.split())
|
|
# Read the call sites out of the parse tree. Counting spellings said the same
|
|
# thing while it lasted, but it also reddened on a rename that changed nothing
|
|
# about the rule: the three sites price at three different contexts and the
|
|
# names they use for them are not the contract.
|
|
wired = [
|
|
ast.unparse(keyword.value)
|
|
for node in ast.walk(ast.parse(textwrap.dedent(source)))
|
|
if isinstance(node, ast.Call)
|
|
for keyword in node.keywords
|
|
if keyword.arg == "split_extra_bytes"
|
|
]
|
|
# Projector floor pin, explicit-context pin, reduced-slot retry, per-candidate
|
|
# re-fit. A fifth has to come here and say which context it prices at.
|
|
#
|
|
# Four, not the three the counting version asserted. It counted two spellings,
|
|
# `_cc_split_extra(effective_ctx)` and `_cc_split_extra(ctx),`, and the
|
|
# projector-floor site spells its context `_mm_floor_ctx`, so it was invisible
|
|
# to the check that claimed to cover every call site. It has been wired
|
|
# correctly the whole time; nothing was holding it there.
|
|
assert len(wired) == 4, wired
|
|
# Each passes the step at a context of its own, so none is exempt and none
|
|
# hardcodes one: `_cc_split_extra(4096)` would not match.
|
|
for expression in wired:
|
|
assert re.fullmatch(r"_cc_split_extra\(\w+\)", expression), expression
|
|
assert "gpu_indices,use_fit=self._select_gpus_split_aware(" in load
|
|
# The step rides _cc_bytes' pipelining gate, so it is 0 when llama.cpp declines.
|
|
assert "returnmax(0,_cc_bytes(ctx,2)//2-_cc_bytes(ctx))" in load
|
|
slots = "".join(inspect.getsource(LlamaCppBackend._slots_that_fit_on_gpu).split())
|
|
assert "self._select_gpus_split_aware(" in slots
|
|
assert "split_extra_bytes=split_extra_bytes," in slots
|
|
|
|
|
|
# ── The scratch rate keys off the LIGHTER axis ───────────────────────────────
|
|
#
|
|
# Since ggml-org/llama.cpp#23792 Studio no longer rewrites the requested type for the
|
|
# tensor attempt, so an asymmetric pair is reachable in the one mode with no --fit
|
|
# valve. The budget resolves ONE scalar, the heavier axis, for KV bytes; handing that
|
|
# to _compute_buffer_ctx_bytes prices a q4_0 K cache as if nothing were quantized,
|
|
# because the dequant branch gates on bytes/elem < 2.0.
|
|
|
|
|
|
class TestScratchTakesTheLighterAxis:
|
|
"""_planned_scratch_cache_type is the seam that keeps the two terms honest:
|
|
heavier axis for KV bytes, lighter axis for the dequant scratch."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"k,v,expected",
|
|
[
|
|
("f16", "f16", "f16"),
|
|
("q8_0", "q8_0", "q8_0"),
|
|
("q4_0", "f16", "q4_0"), # the shape the heavier scalar hides
|
|
("f16", "q4_0", "q4_0"), # and with the axes swapped
|
|
("q8_0", "q4_0", "q4_0"),
|
|
("f32", "q8_0", "q8_0"),
|
|
],
|
|
)
|
|
def test_it_picks_the_quantized_axis_whichever_side_it_is_on(self, k, v, expected):
|
|
extras = ["--cache-type-k", k, "--cache-type-v", v]
|
|
assert _planned_scratch_cache_type(None, extras) == expected
|
|
# And the budget still takes the heavier one, so the two disagree exactly
|
|
# when they should.
|
|
heavier = max(_planned_main_cache_types(None, extras), key = _kv_bytes_per_elem)
|
|
assert (heavier != expected) == (_kv_bytes_per_elem(k) != _kv_bytes_per_elem(v))
|
|
|
|
def test_a_managed_symmetric_request_leaves_both_terms_equal(self):
|
|
"""Studio emits one type on both axes, so nothing changes for the common
|
|
case -- this fix must not move the fit for a plain q8_0 load."""
|
|
for kv in ("f16", "q8_0", "q4_0", "iq4_nl"):
|
|
assert _planned_scratch_cache_type(kv, None) == kv
|
|
|
|
def test_extras_beat_the_managed_field_per_axis(self):
|
|
"""Extras are appended last and win per axis, so the scratch must follow
|
|
them, not the field the UI sent."""
|
|
assert _planned_scratch_cache_type("f16", ["--cache-type-k", "q4_0"]) == "q4_0"
|
|
|
|
def test_it_reads_the_inherited_env_when_nothing_else_sets_a_type(self):
|
|
env = {"LLAMA_ARG_CACHE_TYPE_K": "q4_0", "LLAMA_ARG_CACHE_TYPE_V": "f16"}
|
|
assert _planned_scratch_cache_type(None, None, env) == "q4_0"
|
|
|
|
@pytest.mark.parametrize("k,v", [("q4_0", "f16"), ("f16", "q4_0")])
|
|
def test_the_asymmetric_pair_selects_the_dequant_rate(self, k, v):
|
|
"""The point of the seam: at the same context the asymmetric pair must
|
|
cost what the quantized axis really allocates, not the KQ-mask floor."""
|
|
b = _backend()
|
|
extras = ["--cache-type-k", k, "--cache-type-v", v]
|
|
heavier = max(_planned_main_cache_types(None, extras), key = _kv_bytes_per_elem)
|
|
lighter = _planned_scratch_cache_type(None, extras)
|
|
|
|
heavy_rate = b._compute_buffer_ctx_bytes(131_072, 2048, heavier)
|
|
light_rate = b._compute_buffer_ctx_bytes(131_072, 2048, lighter)
|
|
|
|
assert light_rate > heavy_rate, (light_rate, heavy_rate)
|
|
# Same answer as a symmetric quantized cache: the scratch is per-tensor
|
|
# work on the quantized axis, not something the f16 axis discounts.
|
|
assert light_rate == b._compute_buffer_ctx_bytes(131_072, 2048, "q4_0")
|
|
|
|
|
|
class TestTensorFitPricesTheQuantizedAxis:
|
|
"""End to end through the planner: the advertised context must not exceed
|
|
what an honest per-axis price allows. Tensor mode has no --fit valve, so an
|
|
optimistic context OOMs at startup instead of spilling."""
|
|
|
|
@staticmethod
|
|
def _plan(
|
|
b,
|
|
cache_type,
|
|
scratch_type,
|
|
ub,
|
|
ngpu = 4,
|
|
per_gpu = 48_000,
|
|
):
|
|
return b._plan_tensor_parallel(
|
|
gpus = [(i, per_gpu) for i in range(ngpu)],
|
|
model_size = 60 * 1024**3,
|
|
target_ctx = 262_144,
|
|
max_target_ctx = 262_144,
|
|
total_by_idx = {i: per_gpu for i in range(ngpu)},
|
|
cache_type_kv = cache_type,
|
|
scratch_cache_type_kv = scratch_type,
|
|
n_parallel = 1,
|
|
swa_full = False,
|
|
kv_unified = True,
|
|
n_ubatch = ub,
|
|
flash_attn = False,
|
|
)
|
|
|
|
def test_an_asymmetric_pair_is_capped_like_its_quantized_axis(self):
|
|
"""-ctk q4_0 -ctv f16 at a raised micro-batch. Priced from the heavier
|
|
axis alone the planner advertises the full 262144; the quantized axis
|
|
cannot hold it."""
|
|
b = _backend_from_gguf_local()
|
|
optimistic = self._plan(b, "f16", None, 2048)[0]
|
|
honest = self._plan(b, "f16", "q4_0", 2048)[0]
|
|
|
|
assert honest < optimistic, (honest, optimistic)
|
|
# Still a real context, not the 2048 floor: the fix must not collapse the
|
|
# fit, only stop it over-advertising.
|
|
assert honest > 2048, honest
|
|
# It comes out BELOW a symmetric q4_0 load, which is right and worth
|
|
# pinning: the asymmetric pair pays f16 KV bytes on both axes (the heavier
|
|
# axis budgets storage) AND the full quantized dequant scratch. Both terms
|
|
# conservative is the point; neither one alone describes this launch.
|
|
assert honest < self._plan(b, "q4_0", "q4_0", 2048)[0]
|
|
|
|
def test_a_symmetric_request_is_unchanged(self):
|
|
"""Default and explicit scratch type agree when both axes match, so no
|
|
existing load moves."""
|
|
b = _backend_from_gguf_local()
|
|
for kv in ("f16", "q8_0", "q4_0"):
|
|
for ub in (512, 2048):
|
|
assert self._plan(b, kv, None, ub) == self._plan(b, kv, kv, ub)
|