unsloth/studio/backend/tests/test_llama_cpp_max_context_threshold.py
oobabooga 1f475d7f11
Studio: improve Auto context selection and share remembered model settings (#9492)
* feat(studio): make Auto context explicit

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Stop server hydration from dropping remembered settings

* Write the hydrated record against stored settings, not the running model

* Count the llama-server tuning group as a non-default config

* Judge hydrated arguments on the list the merge keeps

* Anchor the UI context ceiling at the Auto offload fallback

The safe zone this branch publishes as max_context_length was still pinned to a
literal 4096, with a comment saying it sits there so the slider warns above the
fallback. Raising the fallback to 8192 put it below instead, so every Auto load
that reaches this branch now exceeds the ceiling it published: measured over
300,000 planner points, 109,732 of 109,732 affected loads. The chat settings
sheet reads that as "Context length exceeds the estimated VRAM capacity (4,096
tokens)" and advises lowering the context or leaving it on Auto, when Auto is
what produced the value, while the picker stays silent on the same load because
its warning is gated on the context not being Auto.

Anchor it at _AUTO_OFFLOAD_CTX so the two move together.

Three test mirrors had hand-copied the old literal. test_llama_cpp_max_context_
threshold.py re-implements this whole block and asserted == 4096, which is why
nothing went red when production drifted; it now tracks the constant.
test_compute_buffer.py's reduced-loop mirror drove a hardcoded 4096 under a
docstring naming "the reduced-to-4096 loop", and reads _FIT_MIN_CTX now, which
is the floor it was actually exercising. test_mmproj_placement_policy.py's
offload test was genuinely failing on this branch and passing on the merge base,
asserting -c 4096 against an 8192 launch; the placement is unchanged either way,
so it asserts the constant.

test_auto_offload_ctx_invariants.py pins both halves: the ceiling follows the
constant, and the constant stays at or above the fit search floor. That second
one is load-bearing and was previously implicit in the two being the same
literal. The offload re-check can only award GPU residency below the floor, so
raising the constant is placement-neutral only while it stays above it; below,
the re-check hands over a device and flips --fit off. The floor is the min_ctx
default on _fit_context_to_vram and _cap_ctx_to_per_device_reserve rather than
_FIT_MIN_CTX, since neither auto call site passes the argument, so the test pins
those defaults too.

Also corrects the projector comment, which said Auto reaches --fit on only once
4096 will not place without noting that it then raises the context to 8192.

* Give the context slider thumb a name and a spoken value

Two accessibility gaps, both invisible on screen and both reproduced in
Chromium 151, Firefox 153 and WebKit 26.5 against the built component.

Radix puts role="slider" on the Thumb, but the wrapper spreads its props onto
Root, so aria-label="Context Length" landed on a plain div and the actual
control had no accessible name at all. Radix only synthesises a label of its
own for multi-thumb ranges, so a single-thumb slider had none. Forward
aria-label and aria-labelledby to the thumb.

Radix does not synthesise aria-valuetext either, so a slider whose positions
mean something other than their number announces the number and nothing else.
With Auto at position 0 the leftmost stop read as "0", the one value on that
track that is not a context length. Add an optional thumbValueText so a caller
can say what a position means, and use it for "Auto, currently 8,192 tokens"
against "4,096 tokens" for an explicit pin.

Measured before and after on all three engines: role=slider name=None
valuenow=0 valuetext=None becomes name="Context Length" valuenow=0
valuetext="Auto, currently 8,192 tokens".

* Cover the Auto offload context across platforms and GPU vendors

The 4096 to 8192 change touches one branch of the placement chain, so what
needed proving was which cells reach that branch and that none of them move.
108 cells: Linux, WSL2, Windows and macOS against NVIDIA single and multi, AMD
Vulkan, AMD ROCm, an AMD APU and a Vulkan iGPU with total 0, Apple Metal and
CPU only, each at three model shapes, plus manual placement and the ROCm arch
gate. Arm, site, --fit, devices and whether the offload re-check awarded
residency are identical in all 108 against the merge base; 48 differ in the
context alone.

Worth recording from that run: sys.platform on its own changes nothing here,
the four platform blocks are byte-identical. Platform reaches placement only
through the numbers the probe produces, which is why the Windows ROCm cell is
called out separately: rocm_windows_free_is_untrusted caps free at total minus
reserved, so on one card reporting 16000 MiB free with 6000 reserved, Linux
awards residency and Windows reaches the offload branch. Same hardware, and
Windows AMD users meet the new value where Linux AMD users do not.

Metal is pinned rather than changed. It still floors Auto at 4096 while
discrete GPUs now get 8192, so an offloading model gets half the context on a
Mac. That asymmetry is left as it is.

The coupling file measures the floor rather than asserting it: with the constant
swept below the fit floor the re-check starts awarding residency again (five
awards at 256 down to one at 3072 on a 20 GB card, zero at 4096 and above), so
the invariant is pinned against behaviour and not just against two numbers.

Platforms other than Linux and vendors other than NVIDIA are the repo's existing
monkeypatch seams, not hardware. These cover what placement does with the
numbers a probe hands it, not whether the probes are right on a real Windows
ROCm or Apple Silicon box.

* Cover the remembered-settings schema in both directions

Declaring the four tuning fields on the route moves them from browser-only to a
server row that older clients can also write, so both upgrade and downgrade
paths need pinning.

Backend: a row written before these fields existed loads unchanged; a row
carrying a key this build does not know is dropped rather than raising; and the
one that matters, a PUT from an older frontend that simply omits the four
removes them from the row, because a write replaces the entry and the row has no
version stamp of its own. That loss reaches the command line. Nothing restores
it automatically: hydration writes browser storage but does not re-mirror, so
the values come back only on the user's next explicit save. Worth knowing before
this lands rather than after, since before this change there was nothing on the
server to lose.

Also covered: spec_draft_cache_type is silently dropped unless the speculative
mode uses a separate drafter, and the key folding a browser and the server must
agree on, nine cases across Windows drive, UNC, WSL and POSIX paths, repo ids
and quant suffixes, asserted identical from both ends.

Frontend: v0 through v5 records all load with every field they carried and are
re-stamped at the oldest version that understands them; a record stamped beyond
this build is not read, written, deleted, listed or evicted, and its bytes are
unchanged afterwards; a localStorage that throws on both get and set degrades
instead of propagating; and eviction terminates rather than spinning when the
budget cannot be met without touching a future record.

Two behaviour changes are pinned as such. A tuning-only config is no longer
judged default, so it is now stored where it was previously dropped on the way
to storage, and it is now also uploaded by the one-time server backfill, which
went from zero PUTs to one on the same input.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let hydration clear a local record, not only overwrite one

savePerModelConfig says "no settings" by deleting the entry, so a merge that
comes out default is a clear that has to be written, not a write to skip. The
adopt block gated the call on the merge being non-default, which is exactly the
case that needed to travel.

Reachable shape: a model whose only remembered setting is its extra arguments.
Clearing them on another origin leaves the row as an explicit empty list, the
merge is then a default config, the write was skipped, and this browser kept the
old flag. model-selector's quick select reads local storage through
resolveInitialConfig without opening the panel, so the next launch got the flag
back that was just cleared.

Dropping the guard is enough: savePerModelConfig already no-ops when there is no
entry to delete.

* Tighten the comments on the tests added here

* Announce Auto's context only once there is one

For an unloaded model activeLoadedContext is null, so contextInputValue falls
back to the offload constant and the thumb announced "Auto, currently 8,192
tokens". That number is the fallback used to seed the input if the user starts
typing, not a selection: Auto may still fit the model's native context, and
before a load nothing has been selected at all. Only a screen-reader user got
told otherwise, which is why it reads fine on screen.

Say "Auto" until a fitted context exists, and keep the number afterwards.
Confirmed on Chromium, Firefox and WebKit against the built component.

* Stop an older client erasing the tuning group it never sent

A save replaces the stored entry, and the row has no version stamp, so a payload
from a build that predates these four fields is indistinguishable from a user
clearing them. During an ordinary upgrade that is enough to delete load mode,
draft-cache dtype, checkpoints and cache RAM: a cached bundle in one tab, or
another LAN client still on the old build, wipes settings it never knew to send.
The exposure is new, because before these fields were forwarded there was
nothing on the server to lose.

A blanket carry-over would be simpler and wrong: the panel clears one of these
by sending nothing for it, so preserving every omission trades a mixed-version
window for a field nobody can ever unset. Instead the client says whether it
mirrors the group. A build that does clears by omission as before; anyone else
keeps what is stored. The flag defaults to false so an old payload, which cannot
set it, gets the safe answer, and it is excluded from saved_fields alongside
fill_absent_fields since a bool would otherwise make every payload look
non-empty and break "no fields means remove".

Same shape as the llama_extra_args carry-over already in this route.

The two tests that pinned the erasure are flipped rather than deleted, and are
joined by one holding the clear path open and one holding the flag out of the
removal calculation.

* Bound the tuning carry-over by the removal verdict and the alias set

Two defects in the preservation added last commit, both mine.

It gated on payload.remove rather than is_removal. The documented legacy clear
is a payload carrying only model_id, which leaves remove None while is_removal
is true, so the carry-over rebuilt a non-empty row: the request succeeded and
load mode, checkpoints and cache RAM kept applying.

And it looked only under the id that was sent. A cached repo has two spellings
and a save under one retires the other, but an alias is not an ordinary folded
match, so the lookup missed and the tuning went down with the row being cleared.
It now walks the same spellings as the extra-args carry-over beside it: the sent
id, the bare repo id, the legacy standalone-gguf key, and the cached repo
aliases, stopping once every field is accounted for.

* Clear what the hydration write evicted

savePerModelConfig evicts other models to stay inside the 500-entry and 1 MiB
budget, silently, and still reports success. The save handler collects those and
clears their mirrored fields; the hydration write goes through the same budget
and collected nothing, so a model dropped by opening someone else's panel kept
applying its server row to API loads while quick select read defaults for it,
with nothing in the UI able to forget it. That is the exact case the evicted
parameter was added for.

Same treatment as the save path: not a Forget, only the mirrored fields go.

Two source-shape assertions move with the call, which now spans lines. Both
still assert the write happens; one also pins the eviction sweep landing before
the block returns.

* Take the tuning carry-over as a unit from the row a load resolves to

A load stops at the first non-empty override row rather than merging across
spellings, so tuning sitting in a row the qualified key shadows never applies.
Filling one field of the winning row from a later fallback promoted a dormant
value into an active one, as a side effect of a save about something else. Take
the group from the first row that exists and stop there.

* Fix two typecheck errors in the server-tuning compatibility test

tsc -p tsconfig.test.json is a second pass over the test tree and caught both:
an as const case table produced a readonly llamaExtraArgs tuple that
Partial<PerModelConfig> will not accept, and an unused fetch handler parameter.

* Read the cached row a load resolves to, and notice a failed hydration write

Two fixes to code added earlier in this branch.

The tuning carry-over walked its candidate list in collection order, leading with
the id the payload sent. A lookup reads the concrete load path before the
advertised repo id, so on a cache upgraded from a build that keyed rows by path
the snapshot row is the live one. Saving under the repo id took the unit off the
dormant row and then retired the snapshot row, replacing tuning that was applying
with tuning that never had. Order the candidates the way a load resolves them,
via a predicate shared with the resolver so the two cannot drift.

The hydration write ignored savePerModelConfig's result while having already
marked the state saved. A full or unavailable store, or a record from a newer
build, returns false without throwing, leaving the panel claiming the server
settings were remembered while quick select and background loads still read the
stale record. Feed the result back so it stays a pending change.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-23 11:59:28 -07:00

257 lines
8.1 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 the ``max_context_length`` warning-threshold semantics.
The ctx slider in the chat settings sheet reads
``/api/inference/status.max_context_length`` to decide when to render the
"Exceeds estimated VRAM capacity. The model may use system RAM." warning:
ctxDisplayValue > ggufMaxContextLength → show warning
When weights fit on some GPU subset, the threshold is the largest ctx that
fits fully in VRAM (the binary-search cap from ``_fit_context_to_vram``).
When weights exceed 90% of every GPU subset's free memory, the warning must
fire as soon as the user drags above what Auto itself selects (otherwise
loading e.g. MiniMax-M2.7 on a 97 GB GPU shows a slider up to 196608 with no
hint that any larger value triggers ``--fit on`` and degrades performance).
The threshold therefore tracks ``_AUTO_OFFLOAD_CTX`` and is not a literal.
Anchoring it below that constant is worse than having no warning: Auto's own
context then exceeds the ceiling Auto published, so every load in this branch
warns about itself while advising the user to leave it on Auto.
These tests pin both cases. No GPU probing, subprocess, or GGUF I/O.
Cross-platform: Linux, macOS, Windows, WSL.
"""
from __future__ import annotations
import sys
import types as _types
from pathlib import Path
import pytest
# Stub heavy / unavailable deps before importing the module under test.
# Same pattern as test_kv_cache_estimation.py.
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# loggers
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
# structlog
_structlog_stub = _types.ModuleType("structlog")
sys.modules.setdefault("structlog", _structlog_stub)
# httpx
_httpx_stub = _types.ModuleType("httpx")
for _exc_name in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
):
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
class _FakeTimeout:
def __init__(self, *a, **kw):
pass
_httpx_stub.Timeout = _FakeTimeout
_httpx_stub.Client = type(
"Client",
(),
{
"__init__": lambda self, **kw: None,
"__enter__": lambda self: self,
"__exit__": lambda self, *a: None,
},
)
# Only when the real library is absent. sys.modules holds what has been IMPORTED, not
# what is installed, so setdefault does not defer to a real httpx that nothing in this
# process has touched yet: the stub wins and shadows it for the whole session. This stub
# has no Response, and starlette.testclient reads httpx.Response at import, so every
# module collected afterwards that reaches fastapi.testclient or routes.inference dies.
try:
import httpx # noqa: F401
except ImportError:
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import (
_AUTO_OFFLOAD_CTX,
_CTX_FIT_VRAM_FRACTION,
LlamaCppBackend,
)
# Helpers
GIB = 1024**3
def _make_backend(native_ctx = 131072):
inst = LlamaCppBackend.__new__(LlamaCppBackend)
inst._context_length = native_ctx
inst._n_layers = 80
inst._n_kv_heads = 8
inst._n_heads = 64
inst._embedding_length = 8192
inst._kv_key_length = 128
inst._kv_value_length = 128
inst._kv_lora_rank = None
inst._sliding_window = None
inst._sliding_window_pattern = None
inst._ssm_inner_size = None
inst._full_attention_interval = None
inst._key_length_mla = None
inst._n_kv_heads_by_layer = None
inst._kv_key_length_swa = None
inst._kv_value_length_swa = None
return inst
def _compute_max_available_ctx(
native_ctx,
model_gib,
gpus,
kv_per_token_bytes = 325_000,
):
"""Run load_model's ceiling-probe block and return the final
``max_available_ctx`` the backend would assign to ``_max_context_length``.
"""
inst = _make_backend(native_ctx = native_ctx)
model_size = int(model_gib * GIB)
inst._estimate_kv_cache_bytes = (
lambda n, _t = None, **_kw: 0 if n <= 0 else n * kv_per_token_bytes
)
inst._can_estimate_kv = lambda: True
context_length = inst._context_length
effective_ctx = context_length
max_available_ctx = context_length
cache_type_kv = None
native_ctx_for_cap = context_length
ranked_for_cap = sorted(gpus, key = lambda g: g[1], reverse = True)
best_cap = 0
for n_gpus in range(1, len(ranked_for_cap) + 1):
subset = ranked_for_cap[:n_gpus]
pool_mib = sum(free for _, free in subset)
capped = inst._fit_context_to_vram(
native_ctx_for_cap,
pool_mib,
model_size,
cache_type_kv,
)
kv = inst._estimate_kv_cache_bytes(capped, cache_type_kv)
total_mib = (model_size + kv) / (1024 * 1024)
if total_mib <= pool_mib * _CTX_FIT_VRAM_FRACTION:
best_cap = max(best_cap, capped)
if best_cap > 0:
max_available_ctx = best_cap
else:
max_available_ctx = min(_AUTO_OFFLOAD_CTX, native_ctx_for_cap)
return max_available_ctx
# Weights exceed every GPU subset's VRAM (MiniMax-M2.7-like)
class TestMaxContextLengthForWeightsExceedVRAM:
"""UI ``max_context_length`` must fall back to the Auto offload context so
the warning fires as soon as the user drags above what Auto selects.
"""
def test_minimax_like(self):
"""131 GB weights, single 97 GB GPU, native ctx 196608."""
got = _compute_max_available_ctx(
native_ctx = 196608,
model_gib = 131,
gpus = [(0, 97_000)],
)
assert got == _AUTO_OFFLOAD_CTX
def test_multi_gpu_all_subsets_fail(self):
"""400 GB weights across a 4x80 GB pool (320 GB total, still too small)."""
got = _compute_max_available_ctx(
native_ctx = 131072,
model_gib = 400,
gpus = [(0, 80_000), (1, 80_000), (2, 80_000), (3, 80_000)],
)
assert got == _AUTO_OFFLOAD_CTX
def test_native_below_fallback_is_preserved(self):
"""If native ctx is itself below the fallback, don't advertise a larger
value than the model supports."""
got = _compute_max_available_ctx(
native_ctx = 2048,
model_gib = 200,
gpus = [(0, 80_000)],
)
assert got == 2048
# Fittable models (regression guard)
class TestMaxContextLengthForFittableModels:
"""The existing best-cap behaviour must be unchanged."""
def test_small_model_fits_easily(self):
"""8 GB model on 24 GB GPU: should auto-pick a large ctx."""
got = _compute_max_available_ctx(
native_ctx = 131072,
model_gib = 8,
gpus = [(0, 24_000)],
kv_per_token_bytes = 8192,
)
assert got > _AUTO_OFFLOAD_CTX
assert got <= 131072
def test_medium_model_multi_gpu(self):
"""60 GB model split across 2 GPUs: picks a fitting ctx."""
got = _compute_max_available_ctx(
native_ctx = 131072,
model_gib = 60,
gpus = [(0, 40_000), (1, 40_000)],
kv_per_token_bytes = 8192,
)
assert got > _AUTO_OFFLOAD_CTX
def test_tiny_model_on_huge_gpu_near_native(self):
"""2 GB model, 80 GB GPU, negligible KV: should approach native."""
got = _compute_max_available_ctx(
native_ctx = 131072,
model_gib = 2,
gpus = [(0, 80_000)],
kv_per_token_bytes = 64,
)
assert got >= 131072 - 256 # rounded to 256 boundary
# Property plumbing
class TestMaxContextLengthProperty:
def test_falls_back_to_native_when_unset(self):
inst = _make_backend(native_ctx = 131072)
inst._max_context_length = None
assert inst.max_context_length == 131072
def test_returns_stored_value_when_set(self):
inst = _make_backend(native_ctx = 131072)
inst._max_context_length = 4096
assert inst.max_context_length == 4096