* fix KVCache estimates for gemma4 style sliding window models
Signed-off-by: Datta Nimmaturi <venkatadattasainimmaturi@gmail.com>
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: add per-arch SWA pattern fallback + n_kv_heads mirror for PR #5225
The pattern-aware SWA estimator added in this PR only fires when the
GGUF carries `<arch>.attention.sliding_window_pattern`. Today's
Gemma-2 / Gemma-3 / Gemma-3n / gpt-oss / Phi-3 GGUFs ship
`attention.sliding_window` but not the pattern field (llama.cpp's
converter strips it), so the new branch is bypassed and we fall back to
the legacy 1/4-global heuristic on the most popular SWA arches in our
catalogue (gemma3 alone has 6+ variants in the unsloth/* top 30 by
downloads, plus gpt-oss-20b/120b).
Two additions on top of this PR:
1. `_SWA_PATTERN_DEFAULTS_BY_ARCH` table keyed by GGUF arch name. When
the GGUF reports a sliding window but no pattern, we synthesise the
pattern from the architecture's canonical period (gemma2=2,
gemma3=6, gemma3n=5, gpt_oss=2, phi3=1, cohere2=4). Periods sourced
from a survey of the top 150 unsloth/* HF configs against
`text_config.layer_types` and `Gemma*Config.sliding_window_pattern`.
2. Mirror `_n_kv_heads_by_layer` into the scalar `_n_kv_heads` (using
max as a conservative upper bound) when the head_count_kv array is
read. Without this, any non-SWA estimator path (GQA, legacy) on a
Gemma-4-style model falls through to `n_heads`, which can be many
times larger than the real per-layer KV head count. Also let
`_can_estimate_kv` accept the array directly as belt-and-suspenders.
End-to-end check on `unsloth/gemma-3-270m-it-Q4_K_M.gguf` (18 layers,
sliding_window=512, no pattern field): the parser now resolves the
pattern to period=6 (3 global, 15 SWA), matching the actual
Gemma3TextConfig default. KV estimate at 32k context drops from
141 MB (legacy 1/4) to 108 MB (per-layer), a 23% reduction that
directly translates into more headroom for `_fit_context_to_vram` and
fewer cases where the slider lands on the 4096 floor.
Tests: extended `test_kv_cache_estimation.py` with
`TestArchSwaPatternDefaults` covering the six tabled arches, an
unknown-arch negative, explicit-pattern precedence, and a
no-sliding-window negative; updated `test_array_fields_parsed` to
reflect the new mirror semantics; updated
`test_end_to_end_synthetic_swa` to use the period=6 expectation. All
102 tests in the kv-cache / context-fit / max-context suites pass.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: tighten arch SWA table to verified non-regressing entries
Audit of every unsloth/* HF model (1334 repos, all config.json fetched
in scripts/survey_all_unsloth.py) plus end-to-end checks against five
real GGUFs (gemma-3-270m, gemma-3-1b, qwen2.5-0.5b, phi-3.5-mini,
falcon-h1-0.5b, granite-4.1-8b) confirms:
* Pure-GQA arches (llama, qwen3, mistral3, glm4, llama4, ...) and the
qwen2 family with use_sliding_window=False all reach Path 4 GQA
cleanly. The llama.cpp converter strips `attention.sliding_window`
for qwen2/qwen2_vl/qwen2_5_vl when use_sliding_window=False, so the
SWA path never fires for them. Verified on Qwen2.5-0.5B-GGUF: no
sliding_window field in metadata.
* MLA arches (deepseek_v3/v32/v4, glm4_moe_lite, glm_moe_dsa, kimi_k25)
emit `kv_lora_rank` -> Path 1 fires correctly.
* Hybrid Mamba/Attn arches that emit both ssm.* and
full_attention_interval (qwen3_5, qwen3_5_moe, qwen3_next) -> Path 2
fires correctly.
Two table changes:
1. Drop the `phi3` entry. Phi-3 GGUFs emit
`phi3.attention.sliding_window=262144` but never emit
`attention.key_length`/`value_length`, so the SWA path is gated
off and the estimator falls to the legacy formula. The huge
sliding_window also means SWA layers and global layers cache
identical numbers of tokens at any practical context, so a fallback
would be a no-op anyway. The previous `phi3: 1` entry was also
semantically wrong: period=1 with the (i+1)%N!=0 rule produces
all-global, not the all-SWA you'd want for Phi-3.
2. Document the audit findings in the table comment, including the
two arches we deliberately skip (phi3, qwen2*) and the one
architecture family that is not a regression vs. main but is also
not yet optimal (mistral v0.1/v0.2 all-SWA every-layer cannot be
expressed with the period sentinel).
Tests: added `test_non_swa_arch_uses_full_attention_path` parametrized
over llama / qwen2 / qwen3 / mistral / mistral3 / glm4 / llama4 to
pin the invariant that pure-GQA arches never receive a synthetic SWA
pattern. Removed phi3 from the parametrize list of
`test_arch_default_pattern_applied`. All 108 tests pass.
Known separately tracked (not addressed here): falcon-h1 GGUFs ship
ssm.* + key_length but no full_attention_interval, so the hybrid
path 2 cannot fire and the estimator falls to GQA path 4, which
counts every block as an attention layer. Affects 8 unsloth/* repos
(~500 downloads). Same gap exists for granitemoehybrid-class GGUFs.
Worth a follow-up that adds either a HYBRID_ATTENTION_INTERVAL_BY_ARCH
table or a tensor-name probe.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: make SWA pattern resolver dynamic so new models work without code changes
The static `_SWA_PATTERN_DEFAULTS_BY_ARCH` dict only covered
architectures we knew about at PR-merge time. New SWA models would
need a code change here every time a new arch shipped, which doesn't
scale. Replaced with a 4-tier resolver so any newly-released model
that lands on Hugging Face with a normal `config.json` is covered
automatically:
Tier 0 (parser) -- explicit GGUF metadata if the converter emits it
(BOOL array or scalar period). Already supported.
Tier 1 (cache) -- $UNSLOTH_STUDIO_HOME/swa_cache.json. Populated by
previous Tier 3 fetches. Survives restarts.
Tier 2 (bootstrap) -- `_BOOTSTRAP_SWA_DEFAULTS` shipped with Studio.
Same five entries as the old static table
(gemma2/3/3n/gpt_oss/cohere2). Lets fully-offline
installs keep working for popular SWA arches
with zero network.
Tier 3 (HF fetch) -- pulls `config.json` from the GGUF's source HF
repo and reads `sliding_window_pattern` (int) or
`text_config.layer_types` (string array). Result
is cached to Tier 1 so subsequent loads are
offline-fast. Disabled by
`UNSLOTH_STUDIO_OFFLINE=1`. Network errors and
missing repos fall through silently.
Tier 4 (caller) -- legacy 1/4-global SWA estimate (unchanged).
The GGUF parser now also extracts a handful of `general.*` keys
(`source.huggingface.repository`, `source.url`, `source.repo_url`,
`base_model.0.repo_url`, `base_model.0.organization` + `.name`,
`organization` + `basename`) so the resolver has source-repo
candidates to try.
End-to-end smoke against a brand-new arch (`never_seen_before_arch`,
not in the bootstrap dict) pointing at `google/gemma-3-1b-it`:
resolver fetched the HF config, derived period=6, materialised the
26-layer mask with 4 global layers (indices 5/11/17/23), and wrote
`{"never_seen_before_arch": 6}` to the on-disk cache. Next load hits
Tier 1 with no network.
Tests: added `TestDynamicSwaResolver` with 10 tests covering each
tier (period derivation, aperiodic mask handling, URL parsing,
bootstrap precedence, cache precedence, HF fetch + persistence,
candidate fallback, offline env knob, network failure). All 118
tests in the kv-cache / context-fit / max-context suites pass.
The `_SWA_PATTERN_DEFAULTS_BY_ARCH` name was retired in favour of
`_BOOTSTRAP_SWA_DEFAULTS` to make the tier semantics explicit.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: add Tier 2.5 transformers introspection to SWA resolver
Slots a new tier between bootstrap and HF fetch that asks the
locally-installed `transformers` package directly. Two strategies, in
order, both offline-friendly:
a. Default-instantiate the matching `Config` class via
`CONFIG_MAPPING[arch]()` and read `sliding_window_pattern` /
`text_config.layer_types`. Drills into `text_config` for
multimodal wrappers.
b. `inspect.getsource(cfg_class)` regex-parse for
`sliding_window_pattern: int = N` defaults. Catches configs
whose constructor raises (missing required args), or where the
default is bound only in the __init__ signature. Walks
`cfg_class.sub_configs["text_config"]` too so multimodal wrappers
that delegate to a TextConfig still get inspected.
Resolver chain is now 5 tiers: GGUF metadata, on-disk cache,
bootstrap defaults, transformers introspection, HF Hub fetch, legacy
fallback. Tier 2.5 results are persisted to the same on-disk cache as
Tier 3 so subsequent loads skip the import overhead.
`_arch_aliases` normalises hyphen vs underscore variants (`falcon-h1`
vs `falcon_h1`) since GGUF and HF disagree for a handful of arches.
Cross-version verification (probe at `temp/swa_probe/`):
```
arch transformers 4.57.6 transformers 5.7.0
gemma3 6 6
gemma2 2 2
cohere2 4 4
gpt_oss 2 2
gemma3n 5 5
gemma4 ARCH-MISSING 6 <- new arch picked up automatically
falcon_h1 None [True]*32 <- per-layer mask used verbatim
phi3 None None
mistral None None
qwen2 1 (all-global) 1
llama None None
deepseek_v3 None None
```
The `gemma4` and `falcon_h1` rows are the headline: a brand-new arch
that lands in transformers (gemma4 is 5.x-only) is supported by the
resolver the moment a user upgrades the package, with zero edits to
this file. Same applies to any future arch with a `Config` class.
Tests: added `TestTransformersIntrospection` with 6 cases covering
arch-alias normalisation, real-arch resolution against the live
transformers, inspect.getsource fallback when default-init raises,
graceful behaviour when transformers is unavailable, unknown-arch
returns None, and Tier-2.5-before-Tier-3 ordering. Also adjusted the
existing Tier 3 failure test to mock Tier 2.5 out so it specifically
exercises the network-failure path. All 124 tests in the kv-cache /
context-fit / max-context suites pass.
Updated the module-level resolver comment from "4-tier" to "5-tier"
to document the new tier.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: consolidate verbose comments and docstrings in SWA resolver
Net -345 lines: -210 in llama_cpp.py, -357/+111 in
test_kv_cache_estimation.py. No behaviour change; only comments,
docstrings, and one tests-only `_SWA_FIELDS` helper to remove the
copy-pasted GGUF metadata dict from each resolver test.
Code changes only delete or shorten:
* 5-tier resolver header collapsed from a 38-line block diagram to
a 9-line summary; the rest is the function bodies.
* Bootstrap dict per-arch comments collapsed to one-line `Config`
references.
* `_swa_cache_path`, `_save_swa_cache`, `_period_from_layer_types`,
`_arch_aliases`, `_swa_entry_from_config_obj`,
`_resolve_swa_pattern`, `_resolve_swa_entry_from_transformers`
docstrings stripped to one line or removed when the body is
self-evident.
* Tier-by-tier inline comments inside `_resolve_swa_pattern` removed
(function body reads top-to-bottom in tier order).
* Path-3 SWA estimator comment shortened from a 12-line tier
breakdown to 3 lines.
* Parser fallback comment block (originally explained the resolver
in-line) trimmed to two lines pointing at the resolver.
* `_can_estimate_kv` legacy-clause comment shortened to one line.
* GGUF `general.*` WANTED block comment shortened to one line.
Test changes:
* Per-test docstrings dropped where the test name and body already
explain intent.
* Class-level docstrings reduced to one line.
* Common GGUF field dict factored to module-level `_SWA_FIELDS`.
* Multi-line URL/list assertions collapsed to one-liners.
All 124 tests pass.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: account for SWA cache double-buffering in path 3 estimate
Cross-check against llama.cpp ground truth (running llama-server with
--parallel 1 and reading the `llama_kv_cache: size = X MiB ( N cells,
M layers, ... )` log lines) showed the SWA path under-counted by
~20% on Gemma-3-shaped models:
GGUF pred MiB actual MiB ratio
gemma-3-270m-it-Q4_K_M 31.50 39.00 0.81
gemma-3-1b-it-Q2_K 43.00 54.00 0.80
Root cause: llama.cpp double-buffers the SWA cache so it can keep the
current and next windows during the shift, allocating
`2 * sliding_window` cells per SWA layer (capped at n_ctx). My formula
was using `min(n_ctx, sliding_window)` instead of
`min(n_ctx, 2 * sliding_window)`. Verified directly:
llama_kv_cache_iswa: creating SWA KV cache, size = 1024 cells
llama_kv_cache: size = 15.00 MiB (1024 cells, 15 layers, ...)
with `gemma3.attention.sliding_window = 512` -> 2 * 512 = 1024 cells.
Fix: introduce `swa_cells = min(n_ctx, 2 * swa)` in path 3 and use
that for both the per-layer-pattern branch and the legacy
1/4-global fallback.
Re-run after the fix:
GGUF pred MiB actual MiB ratio
gemma-3-270m-it-Q4_K_M 39.00 39.00 1.000
gemma-3-1b-it-Q2_K 54.00 54.00 1.000
qwen2.5-0.5b-instruct-q4_k_m 96.00 96.00 1.000
Phi-3.5-mini-instruct-Q4_K_M 3072.00 3072.00 1.000
Falcon-H1-0.5B-Instruct-Q4_K_M 144.00 144.00 1.000
granite-4.1-8b-Q3_K_M 1280.00 1280.00 1.000
All 5 paths now match llama.cpp's actual allocation exactly under
single-sequence inference (Studio's default).
Tests: updated `test_gemma3`, `test_gpt_oss`,
`test_gemma4_per_layer_swa_metadata`, `test_ctx_smaller_than_window`,
`test_odd_layer_count`, and `test_end_to_end_synthetic_swa` to use
the doubled SWA cell count. All 124 tests in the kv-cache /
context-fit / max-context suites pass.
* studio: tolerate truncated GGUF input so resolver fallback still runs
Wraps each iteration of the GGUF KV-pair loop in a try/except that
breaks out cleanly on `struct.error` or `UnicodeDecodeError`, instead
of letting the outer try eat the exception and skip the SWA resolver
fallback at the end.
The motivating use case is reading the GGUF metadata via an HF Hub
HTTP byte-range fetch. The first ~128 KiB of a typical GGUF contains
all the metadata we need (arch, block_count, attention.*, sliding
window, ssm, MLA fields, plus the tokenizer config) -- but for models
with large tokenizer vocabs (Gemma 3 has 262144 tokens) the tokenizer
arrays spill past the 128 KiB boundary. The truncation used to bubble
out as `unpack requires a buffer of 8 bytes`, abandoning the resolver
fallback and leaving us with no SWA pattern (so the SWA path fell
through to the legacy 1/4 estimate).
Verified end to end against `unsloth/gemma-3-1b-it-GGUF`:
Range-fetch first 128 KiB of `gemma-3-1b-it-Q2_K.gguf` over HTTP
(HTTP 206 Partial Content), parse:
arch = gemma3
block_count = 26
attention.sliding_window = 512
sliding_window_pattern = set (4 global) <- via Tier 2 bootstrap
KV @ ctx=8192 = 54.00 MiB <- matches llama.cpp
ground truth
This means Studio can preview KV-cache requirements (and therefore
auto-context fit) for any HF GGUF without downloading the weights.
All 124 tests pass.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: thread llama-server KV flags through the estimator
Adds keyword-only knobs to _estimate_kv_cache_bytes and _fit_context_to_vram
that mirror the llama-server CLI options that change KV memory:
--swa-full (swa_full) SWA layers cache the full n_ctx
instead of 2 * sliding_window cells
--parallel N (n_parallel) number of server slots
--kv-unified (kv_unified) single shared KV buffer; when off,
multiplies KV by n_parallel
--ctx-checkpoints (ctx_checkpoints) per-slot SWA snapshots, each one
sliding-window of state per SWA layer
--kv-offload (kv_on_gpu) when off, KV lives in CPU RAM and is
not subtracted from the VRAM budget
Defaults preserve the previous behavior (swa_full=False, n_parallel=1,
kv_unified=True, ctx_checkpoints=0, kv_on_gpu=True) so existing call sites
are unaffected. All five paths (MLA, hybrid, SWA pattern, SWA fallback,
GQA, legacy) now apply the per-slot replication factor; the SWA paths
also honor swa_full and add the checkpoint term when applicable.
Tests: TestServerFlags (17 cases) covers every flag, the no-op cases, the
swa_full + ctx_checkpoints interaction, slot multiplication on each path,
and the kv_on_gpu shortcut in _fit_context_to_vram.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: account for shared_kv_layers (Gemma 3n / Gemma 4)
Gemma 3n and Gemma 4 set <arch>.attention.shared_kv_layers in the GGUF
metadata (convert_hf_to_gguf.py lines 7578 and 7712). The trailing N
layers of the model reuse KV from earlier layers and don't allocate
their own cache, so n_layers in the per-layer formulas overcounted by
exactly that many blocks. For google/gemma-3n-E4B-it (35 layers, 15
shared), this puts ~43% of the KV estimate back on the table.
Changes:
- _read_gguf_metadata parses <arch>.attention.shared_kv_layers into
self._shared_kv_layers; init / unload / reparse all reset it.
- _estimate_kv_cache_bytes computes n_layers_kv = max(1, n_layers -
shared_kv_layers) and substitutes it for n_layers in:
Path 1 (MLA), Path 3 (SWA pattern loop bound and the no-pattern
fallback), Path 4 (GQA), Path 5 (legacy). Path 2 (hybrid) keeps
n_layers since hybrid + shared_kv combined isn't a thing today and
the semantics would need to specify which attention layers are
shared.
- max(1, ...) floor protects against pathological GGUFs where shared
>= n_layers.
- Composes naturally with --swa-full, --kv-unified / --parallel,
--ctx-checkpoints, and the per-layer SWA pattern from the dynamic
resolver. When the field is unset (every other arch) the math is
byte-identical to before.
Tests: TestSharedKVLayers (13 cases) covers each path's drop, the
no-op-when-unset case, the floor at one layer, composition with the
server-flag knobs, and lifecycle reset. test_end_to_end_synthetic_shared_kv_round_trip
exercises the full GGUF parse -> estimate path on a synthetic gemma3n_text
blob. Existing TestLifecycle tests extended to cover the new field.
Full suite: 132 passing.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: only stub httpx in tests when the real lib is missing
The unit suite stubs httpx unconditionally so tests can run on a minimal
Python install. Surfaced during a fresh-venv simulation: when the stub
is installed via setdefault on a system that DOES have httpx,
huggingface_hub.errors fails to import HTTPError / Response at module
load time, which the transformers introspection tier swallows via its
bare except. Result: TestTransformersIntrospection passes in venvs
where httpx happened to be imported first (workspace) and silently
fails in venvs where it doesn't (fresh uv venv).
Switch to "only stub when real lib unavailable", and round out the stub
with HTTPError, RequestError, and Response so any test environment
without httpx still gets a complete enough surface for huggingface_hub
to import.
* studio: per-layer-type --parallel N memory accounting for SWA
Empirical verification against llama-server (see
workspace_5/temp/sim_pr5225/probe_parallel_full_matrix.py and
verify_parallel_matches_server.py) showed the prior whole-cache
slot_factor multiplication in _estimate_kv_cache_bytes was wrong for
n_parallel > 1. The actual rule, verified bit-exact across the full
(parallel x ctx) grid for both SWA and pure-GQA models:
* non-SWA layers: total cells = n_ctx, partitioned across slots
(per-slot ctx = n_ctx / parallel). Total memory
is CONSTANT in n_parallel.
* SWA layers: per-slot cells = 2 * sliding_window (clamped at
n_ctx and at per_slot_ctx when ctx is split among
many slots). Total memory grows LINEARLY in
n_parallel.
* --kv-unified: no measurable difference to total memory; both
modes yield the same byte total in measured cases.
Retained as accepted-but-ignored kwarg for API
forward-compat.
Closed form (Path 3 with per-layer pattern):
total_kv = sum_global_layers(n_ctx * n_kv * (k+v) * bpe)
+ parallel * sum_swa_layers(
min(2*sliding_window, n_ctx, n_ctx//parallel)
* n_kv_layer * (k_swa + v_swa) * bpe
)
+ parallel * checkpoint_extra_per_slot (when ctx_checkpoints > 0)
Changes to _estimate_kv_cache_bytes:
- Path 3 (SWA pattern): accumulate global_bytes and swa_bytes_per_slot
separately; final result = global_bytes + slots * (swa_bps + cp_bps).
- Path 3 (no-pattern fallback): same split using the 1/4-global heuristic.
- Paths 1 / 2 / 4 / 5: drop the slot_factor multiplication. Non-SWA
caches don't scale with --parallel.
- swa_full=True: SWA cells = per_slot_ctx (was n_ctx), so slots
cancels out and total stays constant. Matches llama-server's
--swa-full --parallel N output exactly.
Production wiring fix in start():
- Seven internal calls to _estimate_kv_cache_bytes / _fit_context_to_vram
used the default n_parallel=1, even though load_model accepts the
caller's n_parallel value (forwarded to llama-server via --parallel
on the command line). Pass n_parallel through all seven so VRAM
budgeting is correct when an operator sets parallel slots above 1.
Studio's default ships at 1 so production today is unaffected;
this completes the wiring for operators who tune it.
Tests:
- TestParallelSWAScaling (10 new cases): closed-form invariants per
path, swa_full + parallel collapse, kv_unified no-op proof,
per-slot SWA cell clamping, and the empirical Gemma-3 270m formula
(24 + parallel * 15 MiB at ctx=8192) baked from the verifier.
- TestServerFlags: rewrote 4 assertions and renamed 2 to reflect the
per-layer rule; non-SWA paths now correctly assert constancy.
- TestSharedKVLayers::test_composes_with_n_parallel: rewrote to assert
only the SWA portion of the unshared layers scales.
Backward compatibility: at n_parallel=1 the output is bit-identical to
before this change (verified across 120,960 sweep combinations and the
141-test suite in both workspace and fresh-uv-venv environments).
Verifier output at --parallel in {1,2,4,8} x ctx in {4096,8192,16384}
shows ratio 1.000 against llama-server for both SWA and pure-GQA
models (24/24 cells exact match).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: accept new estimator kwargs in load-time test stubs
`test_llama_cpp_context_fit.py` and `test_llama_cpp_max_context_threshold.py`
patch `_estimate_kv_cache_bytes` with constant per-token stubs and then
call the real `_fit_context_to_vram`. After 29dcf96e threaded the
llama-server flag kwargs (`swa_full`, `n_parallel`, `kv_unified`,
`ctx_checkpoints`) through `_fit_context_to_vram`, the production method
forwards them to the stubbed estimator and the old positional-only stubs
raise `TypeError`.
These two suites exercise the load-time fit decision and the max-context
threshold property with a constant per-token KV cost; SWA / parallel-slot
accounting is intentionally out of scope, so the stubs absorb the new
kwargs and ignore them. No production change.
Restores both files to fully passing: 15/15 in `test_llama_cpp_context_fit`
and 8/8 in `test_llama_cpp_max_context_threshold`. Combined with the
existing 141/141 in `test_kv_cache_estimation`, the three KV-cache test
modules are 164/164 green.
---------
Signed-off-by: Datta Nimmaturi <venkatadattasainimmaturi@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
248 lines
8 KiB
Python
248 lines
8 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.
|
|
|
|
``/api/inference/status.max_context_length`` is what the ctx slider in
|
|
the chat settings sheet reads to decide when to render the "Exceeds
|
|
estimated VRAM capacity. The model may use system RAM." warning:
|
|
|
|
ctxDisplayValue > ggufMaxContextLength → show warning
|
|
|
|
For models whose weights fit on some GPU subset, the warning threshold
|
|
is the largest ctx that fits fully in VRAM (the binary-search cap from
|
|
``_fit_context_to_vram``). For models whose weights exceed 90% of every
|
|
GPU subset's free memory, the warning must fire as soon as the user
|
|
drags above the 4096 spec default (otherwise a user loading e.g.
|
|
MiniMax-M2.7 on a 97 GB GPU sees a slider up to 196608 with no
|
|
indication that any value above 4096 will trigger ``--fit on`` and
|
|
degrade performance).
|
|
|
|
These tests pin both cases. No GPU probing, no subprocess, no 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 external dependencies 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,
|
|
},
|
|
)
|
|
sys.modules.setdefault("httpx", _httpx_stub)
|
|
|
|
from core.inference.llama_cpp import 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 the ceiling-probe block from load_model and return the final
|
|
``max_available_ctx`` value 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 * 0.90:
|
|
best_cap = max(best_cap, capped)
|
|
if best_cap > 0:
|
|
max_available_ctx = best_cap
|
|
else:
|
|
max_available_ctx = min(4096, native_ctx_for_cap)
|
|
|
|
return max_available_ctx
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Weights exceed every GPU subset's VRAM (MiniMax-M2.7-like)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMaxContextLengthForWeightsExceedVRAM:
|
|
"""The UI ``max_context_length`` threshold must fall back to 4096 so
|
|
the warning fires as soon as the user drags above the spec default.
|
|
"""
|
|
|
|
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 == 4096
|
|
|
|
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 == 4096
|
|
|
|
def test_native_below_fallback_is_preserved(self):
|
|
"""If the model's native ctx is itself smaller than 4096, do not
|
|
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 > 4096
|
|
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 > 4096
|
|
|
|
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
|