mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
* 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>
257 lines
8.1 KiB
Python
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
|