* studio: reserve MTP draft VRAM in GGUF auto-fit Auto-fit advertised a context (for example ~110k for the Qwen3.6-27B MTP GGUF) that fit on paper but OOMed mid-generation or during tool calls once MTP speculative decoding was active. The MTP draft path's VRAM was reserved as a flat 5% of total VRAM, which tracks neither of the two real costs: the MTP head keeps its own attention KV cache that grows with context, and the speculative verification buffer grows with --spec-draft-n-max. On the hybrid Mamba/attention Qwen3.6 models the main KV is small, so auto-fit happily kept a near-native context while the draft path pushed the load over budget at runtime. Replace the flat fraction with a byte-accurate, context- and n_max-aware reserve sized from GGUF dims: draft KV from nextn_predict_layers and the attention dims at f16 (llama.cpp's MTP draft context uses f16 KV regardless of the main cache type), plus a verify buffer per embedding-unit per draft token. The reserve is evaluated per candidate context inside the fit binary search and added to every pin/fit check, including the tensor-parallel planner and its even-split decision. Coefficients were calibrated against llama-server VRAM measurements on the Qwen3.6-27B MTP GGUF (RMS 14 MiB). The flat fraction remains as a fallback when GGUF dims are unavailable, so non-MTP loads are unchanged. The budget now also engages when the user wires MTP through extra args (--spec-type draft-mtp, including chains), reads the effective draft depth from --spec-draft-n-max or the legacy --draft-max with extras taking precedence over the first-class field, reserves a separate drafter's weights when supplied via --model-draft/--spec-draft-model/-md, and mirrors _build_speculative_flags so it never reserves for MTP the launch resolver will not emit (needs a head/drafter and a binary that supports --spec-type mtp). Adds tests/test_mtp_vram_budget.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: total-based VRAM budget + deterministic compute-graph buffer Build on the byte-accurate MTP reserve with three changes that make the GGUF auto-fit budget deterministic across architectures and recover usable context, especially for MTP models on a single tight card. 1. Total-based budget. Cap GPU occupancy at a fraction of TOTAL VRAM rather than a fraction of FREE VRAM, and raise the fraction from 0.90 to 0.95: budget = free - (1 - 0.95) * total (per GPU, summed for a pool) The reserve is now absolute (a fixed slice of the card) instead of shrinking as the GPU fills, so a partly-used GPU keeps a constant cushion for compute/CUDA/verify buffers instead of over-promising context and spilling to CPU at runtime. _get_gpu_memory() reads memory.total alongside memory.free; _fit_context_to_vram, _select_gpus and the load_model pool loops thread the totals through. Multi-GPU layer-split pools sum(free_i - 0.05*total_i); tensor mode reserves per device. 2. Deterministic compute-graph buffer. Replace the flat 5 GB/device tensor reserve (a magic constant that over-reserved about 8x on a 27B model) with _estimate_compute_buffer_bytes, sized from GGUF dims and the launch flags: out = n_vocab * n_ubatch * 4 # vocab-width output buffer act = 4 * n_embd * n_ubatch * 4 # activation scratch pipeline_per_device = act + out * (n_parallel - 1) tensor_per_device = 2*act + out * n_parallel The buffer is context-independent and scales with --parallel (serving slots), not with how the model is split across GPUs. It is now reserved in BOTH multi-GPU paths (layer split folds one buffer into the pooled footprint; tensor mode reserves it per device). The flat 5 GB stays only as a fallback when vocab/embedding dims are unavailable. Calibrated against llama-server measurements (parallel 1/2/4/8 give 36/492/1388/3220 MiB on a single GPU; about 600 MiB/device tensor); the estimate is a small upper bound. 3. GGUF parsing. Read vocab size (tokenizer tokens array length) and feed_forward_length for the compute-buffer estimate. Effect on the Qwen3.6-27B MTP Q6_K case (MTP on): a single 32 GB card at about 31 GB free advertises f16 23k to 64k, q8_0 44k to 115k, q4_0 82k to 200k; 2x 24 GB tensor mode recovers the full 262k window for f16 (was about 134k). Validated on hardware: 1x 32 GB f16 at 64768 loads at 29.3 GB / 120 t/s; 2x 23 GB tensor f16 at 262144 loads at 22.2 GB/device / 98 t/s; both within 0.4% of the estimate. Adds test_compute_buffer.py and updates the KV/context-fit/MTP-budget tests for the 0.95 constant and the new budget. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: tighten comments in the VRAM auto-fit changes Condense the docstrings and inline comments added by this PR (internal backend helpers): drop restated-signature docstrings, fold multi-line block comments to one or two lines, and remove notes that just repeat the code. No behavior change (AST-verified comment/docstring-only via comment_tools.py); the backend test suite is unchanged and green. * studio: address review findings in the VRAM auto-fit budget Five fixes from a parallel-reviewer pass on this PR; all confirmed against the real functions and covered by new tests. - Tensor mode now honors the total-based VRAM cap. _plan_tensor_parallel took total_by_idx and budgets each GPU at free - (1-frac)*total, mirroring the layer-split paths; previously it fit against raw free and could spend the 5% safety cushion on a partly-used multi-GPU box (reproduced ~3.3 GB over). - Draft K and V cache types are parsed and accounted independently. A one-sided override (e.g. --cache-type-k-draft q4_0, V left f16) no longer applies the small quant to both axes and under-reserves the f16 axis. The embedded-head formula sizes per axis; the separate-drafter path uses the heavier type so it never under-reserves. - The compute-graph buffer honors a user --ubatch / --ubatch-size / -ub override (parsed and threaded into every _estimate_compute_buffer_bytes call and the tensor planner); it previously always assumed the 512 default, under-reserving up to ~8x at --ubatch 4096. - GPU ranking uses the usable budget (free - (1-frac)*total) instead of raw free in _select_gpus and both auto-context subset loops, so a more-used large card no longer outranks a less-used small card that has more usable room. Adds regression tests for each (tensor total cap, ubatch reserve scaling, split K/V no-under-reserve, --ubatch parser, usable-ranking GPU selection). Full targeted backend suite green (321 passed). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: gate tensor-parallel admission on usable VRAM budget The tensor-parallel GPU admission filters still used raw free VRAM after the total-based budget landed, an asymmetric fix: a partly-used large card can clear the per-device compute-buffer reserve on raw free while its usable budget (free - (1-frac)*total) does not, so the planner admitted it and the even split could emit a near-zero weight slice for a GPU that should have been excluded. - _plan_tensor_parallel: admit GPUs by usable budget, not raw free (move the _usable helper above the filter). - load_model: admit the tensor set by _gpu_usable, and downgrade to layer split when the pooled usable budget cannot hold weights plus per-device compute buffers (the planner can only floor the context, not stop an overcommitted launch). Adds regression tests: planner drops a GPU whose usable budget is below the reserve, and a source-level check that load_model admits on the usable budget and carries the pooled-weight downgrade. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: size the MTP reserve for the user's overriding drafter A user --model-draft passed in extra_args is appended last and wins at the llama-server launch, but the VRAM budget preferred Studio's auto-detected drafter (mtp_draft_path or extras), so a larger custom drafter was under-reserved. Flip the precedence to extras-first, matching the draft-depth (n_max) resolution two lines above. Adds a source-level regression test. * studio: account for MTP reserve in tensor gate, restore 2-col GPU probe Two issues found by re-review of the prior fix: - The tensor-parallel capacity gate only checked the model weights against the pooled budget, not the MTP reserve. A separate-drafter MTP load whose weights fit but weights + drafter do not could still launch overcommitted in tensor mode. Add the non-shrinkable MTP reserve (drafter weights + floor draft KV, or the flat 2 GiB fallback when dims are unavailable) to the gate. - The nvidia-smi probe was switched to a three-column query (index,free,total) for the total-based budget but required exactly three columns, so a driver or mock returning the legacy two-column "index,free" was dropped and the probe fell through to the real GPUs. Accept two columns (total 0) and treat an unknown total as the legacy free*fraction in _select_gpus. Tests: tensor gate asserts the MTP term is included; _get_gpu_memory parses both two- and three-column output; the existing two-column GPU-detection mocks pass again. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: keep the VRAM cushion in tensor planning when GPU totals are unknown _plan_tensor_parallel fell back to raw free VRAM when a GPU's total was unavailable (a two-column nvidia-smi probe reporting total 0), while _select_gpus and the load_model ranking both fall back to free*fraction. That let tensor planning spend the 5% cushion the rest of the fit preserves and over-advertise context in exactly that path. Align the fallback to free*_CTX_FIT_VRAM_FRACTION. Updates the no-totals planner test expectations (now free*frac) and adds a regression test that total 0 keeps the cushion. * studio: honor LLAMA_ARG_* env overrides and HF draft flags in the VRAM budget The budget parsed llama-server flags only from the request's extra_args, but the child process inherits Studio's full environment (child_env_without_native_path_secret copies os.environ), and llama-server honors LLAMA_ARG_* env vars for the same options. So a service-level override the child acts on was invisible to the fit, which could then advertise a context/GPU set that OOMs at load. - _extra_args_n_ubatch: fall back to LLAMA_ARG_UBATCH (drives the compute buffer; an unseen 4096 vs the 512 default under-reserves ~8x). - _extra_args_mtp_draft_path: also recognize the HF draft-repo flags (--spec-draft-hf/-hfd/-hfrd/--hf-repo-draft) and fall back to LLAMA_ARG_SPEC_DRAFT_MODEL / LLAMA_ARG_SPEC_DRAFT_HF_REPO. An HF repo isn't a local file so it can't be sized, but recognizing it routes to the flat reserve instead of mis-sizing Studio's auto/embedded drafter. - _extra_args_draft_cache_types: fall back to LLAMA_ARG_SPEC_DRAFT_CACHE_TYPE_K/_V per axis. CLI extra_args win over env (they are appended last at launch). Each parser takes an injectable env for deterministic tests. Adds env-fallback and HF-flag tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: review polish - drop non-flag --ubatch, harden GPU probe, document buffer Non-blocking items from a second review pass; no behavior change in the common path: - _extra_args_n_ubatch: drop --ubatch; the binary only accepts --ubatch-size/-ub, so parsing --ubatch implied support it does not have (it would over-reserve for a launch that fails on the unknown flag). - _get_gpu_memory: skip a malformed nvidia-smi line instead of letting one bad line raise and drop the whole NVIDIA probe to the torch fallback. - _estimate_compute_buffer_bytes: document that the per-slot output-buffer model assumes a small n_outputs_max (chat decode); it would under-count for embeddings / --logits-all / reranking, which Studio does not run on this path. * studio: honor LLAMA_ARG_SPEC_TYPE when deciding the MTP reserve _extra_args_requests_mtp only checked extra_args, but the child inherits Studio's env and llama-server honors LLAMA_ARG_SPEC_TYPE. So a service-level LLAMA_ARG_SPEC_TYPE=draft-mtp would run MTP while the fit skipped the draft reserve and could advertise a context/GPU set that OOMs at load. Recognize the env value (CLI still wins). Completes the env-override coverage alongside ubatch, draft model, and draft cache types. Adds an env regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: reserve VRAM for non-MTP model-based draft modes too The draft reserve only engaged for MTP. A user passing a non-MTP model-based draft mode (--spec-type draft-simple / draft-eagle3) with a --model-draft loads a separate draft model whose weights + KV consume GPU memory, but the fit reserved nothing and could OOM at load. Engage the existing drafter reserve for those modes when extras (or LLAMA_ARG_SPEC_TYPE) name a drafter; ngram-* load no model and are unaffected. Purely additive (reserves where there was none). Adds parser + gate tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: floor quantized embedded MTP draft KV at f16; fix two test issues Address PR review feedback (three findings): 1. Quantized embedded MTP draft KV was underpriced. The embedded head is a single draft layer, so llama.cpp cannot amortize quantized-KV overhead over many layers the way the main model does: a quantized draft KV (e.g. --spec-draft-type-k q4_0) actually fits LESS context than f16, not more (ggml-org/llama.cpp#24102, where a collaborator recommends f16 for the draft KV). Pricing q4_0 at 0.5625 of an element (~28% of f16) under-reserved, so a quantized override could advertise a context that shrinks or OOMs at load. Floor the embedded draft KV bytes-per-element at f16 (quantized types priced as f16, f32 still its full 4 bytes). The separate multi-layer drafter, where quantization does amortize, keeps the user's real type. 2. test_load_model_reserves_for_non_mtp_draft_modes asserted an exact one-line source substring that pre-commit black wrapped across lines, breaking CI. Strip whitespace before matching so the check survives any line-wrapping. 3. test_compute_buffer.py installed a partial httpx stub via setdefault that, if collected before test_kv_cache_estimation.py, leaked into sys.modules without HTTPError/Response and could break the transformers introspection tier by collection order. Adopt the sister file's pattern: only stub when real httpx is absent, and include the full symbol set. Updates the affected draft-KV tests to assert the f16 floor. * studio: guard httpx stub in test_mtp_vram_budget too test_mtp_vram_budget.py installed a partial httpx stub via setdefault that, like test_compute_buffer.py before it, lacked HTTPError/Response and could leak into sys.modules ahead of tests that need huggingface_hub/transformers, breaking the introspection tier by collection order. Apply the same guard used by test_kv_cache_estimation.py: only stub when real httpx is absent, with the full symbol set. * studio: per-device layer-split reserve, effective spec-type, drafter weights, KV restore Address PR review feedback (four findings in the auto-fit budget): A. Reserve the per-device layer-split overhead. A layer (pipeline) split allocates a fixed per-device overhead (CUDA context + per-device compute scratch) on every participating GPU, beyond the slot-scaling compute buffer that is conserved across the split. Measured ~0.9 GB/device on the Qwen3.6-27B GGUF (b9625), independent of --parallel: layer-split TOTAL VRAM grew +894 MiB (parallel=8) / +946 MiB (parallel=1) per extra GPU, ~linear to +2.6 GB at 4 GPUs. The fit folded a single compute buffer for all subset sizes, so a k-GPU layer split was short by ~(k-1)*0.9 GB and could pin a context that fits the pool on paper but OOMs a device. Reserve (k-1) * _PIPELINE_PER_DEVICE_OVERHEAD_MIB per subset in the layer-split fit; k=1 adds nothing, so single-GPU sizing (and the validated benchmark rows) is unchanged. B. Track the effective --spec-type. _extra_args_requests_mtp returned true on the first MTP-ish --spec-type and consulted LLAMA_ARG_SPEC_TYPE even when a CLI --spec-type was present, contrary to llama.cpp (last CLI value wins; a CLI flag overrides the env). So `--spec-type draft-mtp --spec-type ngram-mod` or a non-MTP CLI value with a stale MTP env over-reserved a drafter the launch won't load (shrinking context / selecting extra GPUs). Route both detectors through a new _effective_spec_type helper. C. Keep known drafter weights in the fallback reserve. When a separate drafter's KV metadata can't be sized, _estimate_mtp_overhead_bytes returned None and discarded the drafter's known weight bytes, falling back to the flat 5% reserve; a drafter larger than that cushion could launch over budget and OOM. Reserve the known weights even when KV sizing fails (None only when nothing is known). D. Restore quantized KV on tensor->layer-split downgrade. The tensor attempt drops a quantized KV cache (tensor mode aborts on it). When the GPU-count or capacity gate then downgrades to layer split -- which supports quantized KV -- the dropped type was lost and the launch used f16, using more VRAM and shrinking context. Remember the dropped type and restore it on downgrade (the launch re-emits it from the var). Adds regression tests for each. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: per-device overhead in GPU pin, skip CPU draft, gate env spec-type Address PR review feedback (three follow-up findings): F1. Reserve the per-device layer-split overhead in the pin path too. The earlier per-device reserve was added to the auto-context fit loops but not to _select_gpus, which the explicit-ctx and file-size-only paths use to PIN GPUs with -ngl -1 (no --fit fallback). A 2+ GPU pin within ~1 GiB/extra-GPU of the budget could OOM a device at load. Add a per_device_overhead_bytes arg to _select_gpus so a k-GPU pin must hold model + (k-1)*overhead; pass the pipeline overhead at both pin call sites. Single-GPU pins are unchanged. F2. Don't charge a CPU-offloaded drafter against the GPU budget. A user passing --spec-draft-ngl 0 or --spec-draft-device none/cpu keeps the separate draft model's weights + KV on CPU, but the budget still charged the full drafter GGUF size, auto-reducing context or downgrading GPU selection. Detect the CPU-offload flags and drop the separate drafter (and its flat fallback) from the budget; an embedded head follows the main -ngl and is unaffected. F3. Consult LLAMA_ARG_SPEC_TYPE only when it can reach the child. llama-server's CLI args override env, and _build_speculative_flags emits a --spec-type/--spec-default for every UI mode except "off". So a stale MTP env on a non-MTP model (auto mode) made the fit reserve MTP that the emitted --spec-default disables, shrinking context / picking extra GPUs. Gate the env consult on "no user --spec-type and UI mode off"; the MTP-model auto path still engages via Studio's own detection. Adds regression tests for each. * studio: drafter budget precedence and --spec-default in effective spec-type Two spec-precedence fixes surfaced by an independent multi-reviewer pass: R3. Size the drafter the launch actually loads. _mtp_draft_for_budget consulted LLAMA_ARG_SPEC_DRAFT_MODEL (via _extra_args_mtp_draft_path's env fallback) before Studio's resolved mtp_draft_path, but _build_speculative_flags emits --model-draft mtp_draft_path, which overrides the env at launch. With a stale (smaller) env drafter, the budget under-reserved and could OOM. Order the budget by what actually launches: CLI extras --model-draft (appended last, wins), then Studio's emitted mtp_draft_path (when MTP engages and the user doesn't own --spec-type), then the env drafter. R4. Treat --spec-default as a CLI spec override in _effective_spec_type. It only recognized --spec-type, so extras=["--spec-default"] with LLAMA_ARG_SPEC_TYPE= draft-mtp fell through to the env and over-reserved MTP, even though the CLI --spec-default overrides the env to a non-MTP default. Recognize it as a CLI spec flag (resolves to "default", non-MTP) that suppresses the env fallback. Adds regression tests for each. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: refine MTP draft reserve (parallel slots, last-wins, KV cushion, ranking) Address PR review feedback (five follow-up findings, all edges of this session's earlier MTP/auto-fit changes): G1. Price the separate drafter's KV per --parallel slot. _mtp_draft_kv_bytes called the drafter's _estimate_kv_cache_bytes with the default n_parallel=1, but the drafter is served under the main model's slot count; a sliding-window drafter (Gemma) grows KV per slot and was under-reserved. Thread n_parallel through the draft KV / overhead estimate and the fit closure. G2. Honor last-wins for the draft-offload flags. _extra_args_draft_offloaded_to_cpu returned True on the first CPU value, so --spec-draft-ngl 0 --spec-draft-ngl -1 (final = GPU) wrongly dropped the drafter reserve while the server kept it on GPU -> OOM. Decide on the final value of each flag only. G3. Keep the flat cushion when only the drafter weights could be sized. The weights fallback installs mtp_overhead_fn, which made callers drop the flat MTP reserve, leaving the still-unsized draft KV with no cushion. Keep the flat fraction on in that weights-only case, on top of the byte-accurate weights. G4. Rank auto/cap GPU subsets by the active budget fraction. The ranking used a hard-coded 0.95 while the fit tests _pin_fraction (lowered by the flat MTP reserve); on mixed-total GPUs that could order subsets differently and pick a worse plan. Rank with the same fraction the fit uses. G5. Keep the embedded-head flat reserve under a draft CPU-offload flag. F2's not-_draft_on_cpu guard also dropped the reserve for an embedded MTP head, which is part of the main model and stays on GPU regardless of --spec-draft-ngl. Only suppress the flat reserve for a CPU-offloaded separate drafter (no embedded head). Adds regression tests for each. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: keep GPU on non-integer total, keep tensor flat reserve for weights-only Two review findings: - _get_gpu_memory dropped a whole GPU when nvidia-smi reported a non-integer memory.total ("N/A" on some drivers / MIG / vGPU): index, free and total were parsed in one try/except that skipped the line on any ValueError, so the GPU vanished from the probe and the load could silently spill to CPU. Parse index and free (required) first, then total separately, defaulting to 0 (the fit then uses the free*frac path for that GPU). Adds N/A and bad-free test cases. - Tensor planning skipped the flat MTP reserve for a weights-only drafter (file size known, KV unsizable): the capacity gate used the byte floor whenever mtp_overhead_fn was set, so it reserved only the drafter weights and no draft KV. Tensor mode has no --fit valve, so that could overcommit and OOM. Keep the flat reserve (never below the byte floor) in the weights-only case too, mirroring the layer-split _mtp_kv_unsized handling. Adds a regression test. (A third suggestion -- fold --batch-size into the compute-buffer reserve -- was checked on hardware and declined: -b 8192 -ub 512 used identical VRAM to the default at -c 64000, so the logical batch does not size the graph buffer; the estimate correctly uses the physical micro-batch.) * studio: budget the main KV from LLAMA_ARG_CACHE_TYPE env when Studio emits none The child inherits LLAMA_ARG_CACHE_TYPE_K / LLAMA_ARG_CACHE_TYPE_V, but Studio emits --cache-type-k/-v only when the param or extras set the type. When neither does, a heavier env type (f32) reaches the child while the auto-fit budget assumed the f16 default, under-reserving the main KV and risking OOM at the advertised context. This is the one main-KV axis that lacked the env-aware handling the other axes already have (spec-type, draft model, draft cache type, ubatch). load_model now adopts the heavier of the two env types when it exceeds f16 (only f32 does), and the launch re-emits it so child and budget stay byte-consistent. Quantized env types are <= f16 and remain safely over-reserved by the default, so they are left untouched (no change). A single value is used because the budget's KV estimate has one cache_type_kv knob, matching parse_cache_override's existing key/value collapse. Adds _env_main_cache_type_for_budget plus regression tests covering f32 adoption, the K/V heavier-of collapse, quantized/unknown no-ops, and the load_model source precedence. * studio: budget tensor parallel when LLAMA_ARG_SPLIT_MODE env selects it Studio emits --split-mode tensor only on its tensor branch; the default layer-split path emits nothing and resolve_tensor_parallel consults only extras. The child inherits LLAMA_ARG_SPLIT_MODE, so a tensor env on a layer-split plan silently runs the child tensor-parallel (heavier per-device compute buffer) while the budget reserved only the layer-split per-device overhead, under- reserving on multi-GPU. load_model now flips the plan to tensor when extras do not set a split mode and the env selects tensor, so Studio plans, reserves, and emits tensor consistently. The flip is one-directional (guarded on not tensor_parallel and no extras split-mode) so an existing tensor plan is never downgraded and extras keep precedence. Other env modes (layer/row/none) are not a runtime-heavier surprise and are left untouched. Adds _env_split_mode_is_tensor plus unit and load_model source-level tests. * studio: reconcile inherited llama.cpp env with the budgeted launch decision Addresses a review pass over the VRAM auto-fit work. The budget now sizes the right amount, but the child process inherits LLAMA_ARG_* env (see child_env_without_native_path_secret), and a few axes could still run the child in a mode Studio neither chose nor budgeted. Mixed known/unknown GPU totals over-advertised the pooled layer-split budget. _pool_budget_mib pooled free and total separately, so an unknown-total GPU (MIG/vGPU/N/A) contributed its full free with no cushion when mixed with known-total GPUs (~(1-frac)*free over-advertise, about 500 MiB in a two-GPU case). It now sums each GPU's own usable budget, and the layer-split fit calls take that as an absolute budget (budget_frac=1.0, total_mib=None) so the fit and the footprint check agree. All-known-total pools are unchanged. LLAMA_ARG_SPLIT_MODE=tensor survived a tensor-to-layer downgrade. The downgrade only stripped CLI extras, so the inherited env still ran the child tensor while Studio budgeted layer split. When the final decision is layer split, a non-layer inherited split mode (and any paired LLAMA_ARG_TENSOR_SPLIT) is now cleared from the child env. Inherited quantized LLAMA_ARG_CACHE_TYPE_K/_V crashed tensor mode. Tensor mode aborts on a quantized KV cache; Studio drops a quantized cache_type_kv for the tensor attempt but the inherited env reached the child anyway. When the final decision is tensor split, a quantized cache-type env is now cleared so the child uses the tensor-safe default that was budgeted. Env-derived cache budget no longer mutates the emitted launch flags. An env-only main KV type now informs the budget only; it is not re-emitted, so an asymmetric K=f32,V=f16 env reaches the child as set instead of being rewritten to symmetric --cache-type-k/-v f32. Adds source-level regression tests for all four and confirms the documented single-GPU/tensor/pipeline numbers are byte-identical before and after. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: tighten comments in the VRAM auto-fit code Compress the verbose docstrings and inline comments added by this work to succinct 2-4 line versions, drop restated/obvious ones, and cut duplicated rationale across the two tensor-downgrade branches. Keeps the non-obvious intent (env-inheritance precedence, the #24102 embedded-draft floor, the per-device overhead and pool-budget rationale) while removing roughly 120 lines of comment text from llama_cpp.py. Also trims the few longest test-comment blocks; concise per-test scenario notes are left intact. No logic change: verified with comment_tools.py check --strip-docstrings (code- only signature unchanged vs the prior commit) and the full backend suite still passes (824). * studio: lock in env-drafter engagement for the separate-draft reserve A review suggested an env-provided LLAMA_ARG_SPEC_DRAFT_MODEL would skip the draft reserve and OOM. It does not: the gate's _extra_args_mtp_draft_path(extra_args) call defaults env=None, which consults os.environ, so an env-only drafter still sets _user_draft_via_extras and is sized via _env_draft_for_budget. Add a source guard that the gate keeps the env-inclusive form (not extras-only env={}) and a behavioral test mirroring the reviewed scenario, so a future cleanup can't regress it. No production change. * studio: carry the unsized MTP reserve and env split/offload into tensor planning Addresses a review pass over the multi-GPU and env-inheritance paths. Tensor planner dropped the unsized draft-KV cushion. When a separate drafter has known weights but unreadable KV metadata, _plan_tensor_parallel receives a non-None weights-only mtp_overhead_fn and applied the flat 2 GiB reserve only for the no-fn case, so its binary search spent the unsized-KV cushion on context and over-advertised. Add mtp_flat_reserve_bytes (subtracted from the pooled budget and the even-split check), and pass it from load_model whenever _mtp_kv_unsized. The layer path and the tensor pre-gate already kept this cushion. Stale LLAMA_ARG_TENSOR_SPLIT survived in tensor mode. When the planner picks an even split it emits no --tensor-split, so an inherited tensor-split env reached the child and overrode the budgeted split. The layer downgrade branch cleared it; the tensor branch now does too. Env-only draft CPU offload was ignored. _extra_args_draft_offloaded_to_cpu checked extras but not LLAMA_ARG_N_GPU_LAYERS_DRAFT, so an env-offloaded drafter was still charged GPU budget and under-advertised context. It now consults that env (the device flag has no env), called with env=os.environ. Layer-split compute buffer had no fallback when GGUF dims are missing. The estimate returns 0 then, so the layer path folded no buffer while the tensor path falls back to the flat reserve. Use the flat reserve for the layer path too (a safe upper bound, since the tensor buffer >= the layer one). All four are gated on conditions the documented benchmarks don't hit; the single-GPU/tensor/pipeline reconfirm numbers are byte-identical, and the full backend suite passes (830) with regression tests for each fix. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: share the env-aware tensor decision across load and dedup matchers A review pass found the inherited-LLAMA_ARG_SPLIT_MODE=tensor flip lived only in load_model, so the two duplicate-load matchers disagreed with it. Consolidate the decision into _effective_tensor_parallel (extras + toggle, then flip on when extras set no split mode and the child inherits a tensor split env). load_model, the backend matcher (_already_in_target_state) and the route matcher (_request_matches_loaded_settings) now all call it. Before, an env-driven tensor server compared against resolve_tensor_parallel (env-blind) in both matchers, so a follow-up load that should dedup was seen as a mismatch and the healthy server was needlessly killed and reloaded. Also finish the tensor cache-type handling: when the tensor attempt drops a quantized KV it now re-adopts a heavier inherited env cache type (f32) for the budget, mirroring the initial adoption; and the two layer-split downgrades clear _cache_type_from_env so the restored quantized type is actually re-emitted rather than left to a stale inherited env. All gated on inherited env the documented benchmarks don't set; the single-GPU, tensor and pipeline reconfirm numbers are byte-identical, and the full backend suite passes (832) with unit + source regression tests for the shared helper and the route matcher. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: complete the env-aware tensor/spec handling across all paths A second review pass found the env-aware tensor/MTP handling was applied asymmetrically: some paths inherited LLAMA_ARG_* env, others didn't. Three real follow-ups, plus a small consolidation so the env semantics live in one place. 1. Tensor fallback ignored the inherited tensor env. load_with_tensor_fallback computed its retry gate with the env-blind resolve_tensor_parallel, so an env-only tensor load (toggle off, no --split-mode extra) that crashed on a tensor-incompatible GGUF re-raised instead of retrying layer split. It now uses the env-aware decision; and since the inherited env would otherwise re-engage tensor on the retry (CLI args persist, the env does too), the retry forces --split-mode layer (CLI wins over env) so it can't re-crash. 2. Duplicate-load matchers looped reloads after a tensor->layer downgrade. Both matchers compared the env-expanded tensor decision against the loaded server, but load_model may downgrade tensor to layer (capacity/buffer) and scrub the child env. The still-set parent env then made every identical request look like a mismatch, killing and reloading a healthy layer server. Add _tensor_parallel_matches_loaded, which only lets an inherited tensor env raise a match against a server that actually launched tensor; a downgraded server matches the same request (an identical load would downgrade the same way). 3. MTP binary-capability fallback leaked an inherited LLAMA_ARG_SPEC_TYPE. When the binary lacks MTP, _emit_mtp degraded but emitted no spec flag, so an inherited LLAMA_ARG_SPEC_TYPE=draft-mtp still reached the child and attempted MTP the gate had budgeted off. It now emits --spec-default (CLI wins over env) like the sibling no-head / non-MTP fallbacks. Consolidation: moved _env_split_mode_is_tensor / _effective_tensor_parallel into llama_server_args.py (with the new _tensor_parallel_matches_loaded) so the lightweight tensor_fallback module can share them without importing llama_cpp; llama_cpp re-exports them for back-compat. All gated on inherited env the documented benchmarks don't set; the single-GPU, tensor and pipeline reconfirm numbers are byte-identical, and the full backend suite passes (883) with regression tests for each fix. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: budget the heavier axis of asymmetric --cache-type-k/-v extras A review pass found the explicit-extras counterpart of the env cache-type fix. load_model adopts the heavier inherited LLAMA_ARG_CACHE_TYPE_K/_V env for the reserve, but the explicit-extras path used resolve_cache_type_kv, which collapses both axes to one last-wins value. So extras such as --cache-type-k f32 --cache-type-v f16 (lighter axis last) budgeted f16 for both axes while the child allocates f32 on K, over-advertising context and re-opening the OOM path this PR closes. Add parse_cache_override_per_axis (keeps the K/V last-wins values apart) and _extra_args_main_cache_type_for_budget (the heavier of the two by bytes/elem), and budget from it. The user's extras are appended last and win per axis at the child, so this only raises the reserve; the emitted command and the asymmetric child cache are unchanged, and the common single-axis / symmetric cases resolve to the same type as before. Reconfirm numbers (single-GPU table, tensor, pipeline) are byte-identical, and the full backend suite passes (892) with per-axis parser and heavier-axis budget regression tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: fix tensor-safety masking and strip inherited HF drafter selectors A review pass found two more env/extras edge cases on the speculative and tensor cache paths. Tensor-safety could miss a quantized axis. The previous change budgets the heavier-by-bytes cache type, but that masks a quantized axis paired with a heavier one: --cache-type-k f16 --cache-type-v q4_0 resolves to f16, so the tensor-safety block did not fire and the q4_0 axis survived into tensor mode, which aborts on quantized KV. Test each explicit --cache-type-k/-v axis (not just the budget type) so any quantized axis drops the cache for the tensor attempt. Inherited HF drafter selectors were not stripped. _extra_args_mtp_draft_path treats --spec-draft-hf / -hfd / -hfrd / --hf-repo-draft as drafter selectors, but _SPEC_FLAGS only stripped the local --model-draft selectors, so on an inherited- extras Apply a stale HF drafter survived and last-wins-overrode Studio's re-derived spec choice. Add the HF aliases to _SPEC_FLAGS. The per-drafter tuning knobs (--spec-draft-type-*, -ngld, --spec-draft-device) are intentionally left in place: the VRAM budget reads them via the same parsers the child honors, so they stay consistent on inherit, and stripping them would silently move a CPU-offloaded drafter back onto the GPU. A third flagged item -- that the HF draft env var should be LLAMA_ARG_HFD_REPO -- was a false positive from a stale manpage; the bundled binary's common/arg.cpp sets LLAMA_ARG_SPEC_DRAFT_HF_REPO for --spec-draft-hf, which the code already uses, so it is left unchanged. Reconfirm numbers (single-GPU table, tensor, pipeline) are byte-identical, and the full backend suite passes (899) with regression tests for both fixes. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: preserve asymmetric cache on tensor downgrade and skip CPU-drafter reserve A review pass found two more tensor-path edges, one a regression from the per-axis cache change. Tensor-to-layer downgrade collapsed asymmetric cache extras. The per-axis tensor-safety check strips an asymmetric --cache-type-k/-v (tensor rejects quantized KV), but the downgrade restored only the scalar heavier type, so a layer fallback silently rewrote --cache-type-k q4_0 --cache-type-v f16 to symmetric f16/f16 even though layer split supports the original. Save the original extras before the tensor strip and restore them verbatim (minus the user --split-mode) on both downgrade points; the budget still uses the heavier scalar, the child gets the real asymmetric cache. Before the per-axis change this case happened to survive (last-wins was f16, untouched), so this restores that. Tensor mode reserved GPU VRAM for a CPU-offloaded drafter. The layer path drops the flat MTP reserve when the only drafter is a separate CPU one with no embedded head, but the tensor capacity gate and planner still charged it, under-advertising context. Gate the tensor reserve on the same condition via _mtp_reserves_gpu. Reconfirm numbers (single-GPU table, tensor, pipeline) are byte-identical (both fixes are gated on conditions the benchmarks don't hit), and the full backend suite passes (901) with regression tests for each. * studio: drop now-unused llama_server_args imports from llama_cpp The refactor re-pointed load_model and the matchers off resolve_tensor_parallel / resolve_cache_type_kv and moved the env split-mode helper into llama_server_args, leaving those three names imported but unused in llama_cpp. The repo's import-hoist safety-net lint blocks that, so drop them; the env split-mode test now imports _env_split_mode_is_tensor from its real home (llama_server_args). --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1568 lines
55 KiB
Python
1568 lines
55 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 MTP auto-detection path (llama.cpp #22673).
|
|
|
|
Pins three contracts: name-based detector, user-override detector, and
|
|
the _already_in_target_state mirror that prevents needless reloads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import os
|
|
import struct
|
|
import sys
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
_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_stub = _types.ModuleType("httpx")
|
|
for _exc in (
|
|
"ConnectError",
|
|
"TimeoutException",
|
|
"ReadTimeout",
|
|
"ReadError",
|
|
"RemoteProtocolError",
|
|
"CloseError",
|
|
):
|
|
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
|
|
_httpx_stub.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None})
|
|
_httpx_stub.Client = type(
|
|
"C",
|
|
(),
|
|
{
|
|
"__init__": lambda s, **kw: None,
|
|
"__enter__": lambda s: s,
|
|
"__exit__": lambda s, *a: None,
|
|
},
|
|
)
|
|
sys.modules.setdefault("httpx", _httpx_stub)
|
|
|
|
import pytest
|
|
|
|
from core.inference.llama_cpp import (
|
|
LlamaCppBackend,
|
|
_GPU_OFFLOAD_OVERRIDE_FLAGS,
|
|
_THREAD_OVERRIDE_FLAGS,
|
|
_backfill_usage_from_timings,
|
|
_build_ngram_mod_flags,
|
|
_canonicalize_spec_mode,
|
|
_extra_args_set_any_flag,
|
|
_extra_args_set_spec_type,
|
|
_is_mtp_model_name,
|
|
)
|
|
|
|
|
|
# Synthetic GGUF helper (mirrors test_gguf_metadata.py).
|
|
|
|
_GGUF_MAGIC = 0x46554747
|
|
_VTYPE_STRING = 8
|
|
_VTYPE_UINT32 = 4
|
|
|
|
|
|
def _enc_string(s: str) -> bytes:
|
|
b = s.encode("utf-8")
|
|
return struct.pack("<Q", len(b)) + b
|
|
|
|
|
|
def _enc_kv_string(key: str, value: str) -> bytes:
|
|
return _enc_string(key) + struct.pack("<I", _VTYPE_STRING) + _enc_string(value)
|
|
|
|
|
|
def _enc_kv_uint32(key: str, value: int) -> bytes:
|
|
return _enc_string(key) + struct.pack("<I", _VTYPE_UINT32) + struct.pack("<I", value)
|
|
|
|
|
|
def _write_minimal_gguf(
|
|
path: Path,
|
|
*,
|
|
arch: str,
|
|
nextn: int | None,
|
|
extra_uint32: dict[str, int] | None = None,
|
|
) -> Path:
|
|
"""Header-only GGUF with arch + optional nextn_predict_layers."""
|
|
extra_uint32 = dict(extra_uint32 or {})
|
|
body = _enc_kv_string("general.architecture", arch)
|
|
kv_count = 1
|
|
if nextn is not None:
|
|
body += _enc_kv_uint32(f"{arch}.nextn_predict_layers", nextn)
|
|
kv_count += 1
|
|
for k, v in extra_uint32.items():
|
|
body += _enc_kv_uint32(k, v)
|
|
kv_count += 1
|
|
header = struct.pack("<IIQQ", _GGUF_MAGIC, 3, 0, kv_count)
|
|
path.write_bytes(header + body)
|
|
return path
|
|
|
|
|
|
# _is_mtp_model_name helper.
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"identifier",
|
|
[
|
|
"unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
"unsloth/Qwen3.6-35B-A3B-MTP-GGUF",
|
|
"unsloth/qwen3.6-27b-mtp-gguf",
|
|
"unsloth/Qwen3.6-27B-Mtp-GGUF",
|
|
"unsloth/Qwen3.6-27B-MTP-GGUF:UD-Q4_K_XL",
|
|
],
|
|
)
|
|
def test_is_mtp_model_name_detects_marker_in_identifier(identifier):
|
|
assert _is_mtp_model_name(identifier) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"identifier",
|
|
[
|
|
"unsloth/Qwen3-27B-GGUF",
|
|
"unsloth/Llama-3.1-8B-Instruct-GGUF",
|
|
"google/gemma-3-4b-it",
|
|
# mtp inside an org name should not match.
|
|
"mtp-research/foo",
|
|
"MTPower/bar",
|
|
],
|
|
)
|
|
def test_is_mtp_model_name_does_not_overmatch(identifier):
|
|
assert _is_mtp_model_name(identifier) is False
|
|
|
|
|
|
def test_is_mtp_model_name_handles_none():
|
|
assert _is_mtp_model_name(None) is False
|
|
assert _is_mtp_model_name(None, None) is False
|
|
assert _is_mtp_model_name("", "") is False
|
|
|
|
|
|
def test_is_mtp_model_name_detects_marker_in_filename(tmp_path):
|
|
gguf = tmp_path / "Qwen3.6-27B-MTP-Q4_K_M.gguf"
|
|
gguf.write_bytes(b"")
|
|
assert _is_mtp_model_name("local-model", str(gguf)) is True
|
|
|
|
|
|
def test_is_mtp_model_name_filename_case_insensitive(tmp_path):
|
|
gguf = tmp_path / "qwen3.6-35b-a3b-mtp-q4_k_m.gguf"
|
|
gguf.write_bytes(b"")
|
|
assert _is_mtp_model_name(None, str(gguf)) is True
|
|
|
|
|
|
def test_is_mtp_model_name_ignores_non_mtp_filename(tmp_path):
|
|
gguf = tmp_path / "Qwen3.6-27B-Q4_K_M.gguf"
|
|
gguf.write_bytes(b"")
|
|
assert _is_mtp_model_name("local-model", str(gguf)) is False
|
|
|
|
|
|
# _already_in_target_state MTP promotion.
|
|
|
|
|
|
class _FakeProcess:
|
|
"""Minimal stand-in so is_loaded returns True."""
|
|
|
|
def terminate(self):
|
|
pass
|
|
|
|
def wait(self, timeout = None):
|
|
return 0
|
|
|
|
def kill(self):
|
|
pass
|
|
|
|
def poll(self):
|
|
return 0
|
|
|
|
|
|
def _mtp_backend(**overrides):
|
|
"""MTP-named GGUF backend that's already running with draft-mtp."""
|
|
backend = LlamaCppBackend()
|
|
backend._process = _FakeProcess()
|
|
backend._healthy = True
|
|
backend._model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF"
|
|
backend._hf_variant = "Q4_K_M"
|
|
backend._requested_n_ctx = 8192
|
|
backend._cache_type_kv = None
|
|
backend._speculative_type = "draft-mtp"
|
|
# Fixture simulates Auto having auto-promoted to draft-mtp. Tests
|
|
# override _requested_spec_mode for a forced mode or the
|
|
# user---spec-type-extra-args path.
|
|
backend._requested_spec_mode = "auto"
|
|
backend._chat_template_override = None
|
|
backend._is_vision = False
|
|
backend._extra_args = None
|
|
backend._extra_args_source = None
|
|
backend._gguf_path = None
|
|
for key, value in overrides.items():
|
|
setattr(backend, key, value)
|
|
return backend
|
|
|
|
|
|
def test_already_in_target_state_matches_when_request_omits_spec_for_mtp_model():
|
|
# Duplicate /load with no spec must match a running draft-mtp backend.
|
|
backend = _mtp_backend()
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_matches_when_request_uses_default_for_mtp_model():
|
|
backend = _mtp_backend()
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = "default",
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_auto_request_matches_auto_backend_for_non_mtp_model():
|
|
# In the requested-mode round-trip model, Auto-vs-Auto matches regardless
|
|
# of model name. The resolved emission (--spec-default vs draft-mtp) is
|
|
# handled by the load path and reflected in _speculative_type; the
|
|
# short-circuit only cares whether the *intent* changed.
|
|
backend = _mtp_backend(
|
|
_model_identifier = "unsloth/Qwen3.6-27B-GGUF",
|
|
_speculative_type = "default",
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_explicit_off_still_mismatches_mtp_backend():
|
|
backend = _mtp_backend()
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = "off",
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
# User override via extra_args (unsloth run / unsloth studio run).
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra_args",
|
|
[
|
|
["--spec-type", "none"],
|
|
["--spec-type", "ngram-mod"],
|
|
["--spec-type", "draft-mtp"],
|
|
["--spec-type=none"],
|
|
["--top-k", "20", "--spec-type", "ngram-simple", "--seed", "42"],
|
|
["--spec-default"],
|
|
],
|
|
)
|
|
def test_extra_args_set_spec_type_detects_user_override(extra_args):
|
|
assert _extra_args_set_spec_type(extra_args) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra_args",
|
|
[
|
|
None,
|
|
[],
|
|
# Scalar tuning knobs compose safely with auto-emitted --spec-type.
|
|
["--spec-draft-n-max", "4"],
|
|
["--spec-ngram-mod-n-match", "32"],
|
|
["--draft-max", "32"],
|
|
["--top-k", "20", "--seed", "42"],
|
|
],
|
|
)
|
|
def test_extra_args_set_spec_type_passes_on_non_spec_type_args(extra_args):
|
|
assert _extra_args_set_spec_type(extra_args) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra_args",
|
|
[
|
|
["-ngl", "12"],
|
|
["--gpu-layers", "12"],
|
|
["--n-gpu-layers=12"],
|
|
["-fit", "off"],
|
|
["--fit=off"],
|
|
],
|
|
)
|
|
def test_extra_args_detect_gpu_offload_overrides(extra_args):
|
|
assert _extra_args_set_any_flag(extra_args, _GPU_OFFLOAD_OVERRIDE_FLAGS) is True
|
|
|
|
|
|
@pytest.mark.parametrize("extra_args", [["-t", "8"], ["--threads=8"]])
|
|
def test_extra_args_detect_thread_overrides(extra_args):
|
|
assert _extra_args_set_any_flag(extra_args, _THREAD_OVERRIDE_FLAGS) is True
|
|
|
|
|
|
def test_windows_full_offload_flags_use_current_llama_server_args():
|
|
src = inspect.getsource(LlamaCppBackend.load_model)
|
|
stale_checkpoint_flag = "--checkpoint-" + "every-n-tokens"
|
|
assert '"--cache-ram"' in src
|
|
assert '"--ctx-checkpoints"' in src
|
|
assert '"--no-cache-prompt"' in src
|
|
assert stale_checkpoint_flag not in src
|
|
|
|
|
|
def test_load_model_sets_threads_once():
|
|
src = inspect.getsource(LlamaCppBackend.load_model)
|
|
assert src.count('cmd.extend(["--threads", str(') == 1
|
|
|
|
|
|
def test_llama_cpp_annotations_stay_python39_safe():
|
|
src = inspect.getsource(LlamaCppBackend.generate_chat_completion)
|
|
helper_src = inspect.getsource(_extra_args_set_any_flag)
|
|
assert "Generator[str | dict" not in src
|
|
assert "set[str] | frozenset[str]" not in helper_src
|
|
|
|
|
|
def test_already_in_target_state_user_spec_type_override_matches_clean_backend():
|
|
# User --spec-type none suppressed auto-MTP; repeat /load must not re-promote.
|
|
backend = _mtp_backend(
|
|
_speculative_type = None,
|
|
_requested_spec_mode = None,
|
|
_extra_args = ["--spec-type", "none"],
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = ["--spec-type", "none"],
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_local_file_mtp_match(tmp_path):
|
|
# Local-file load: -MTP marker comes from the filename.
|
|
gguf = tmp_path / "Qwen3.6-35B-A3B-MTP-Q4_K_M.gguf"
|
|
gguf.write_bytes(b"")
|
|
backend = _mtp_backend(
|
|
_model_identifier = "local-qwen-mtp",
|
|
_gguf_path = str(gguf),
|
|
_hf_variant = None,
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = str(gguf),
|
|
model_identifier = "local-qwen-mtp",
|
|
hf_variant = None,
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_vision_mtp_match():
|
|
# llama.cpp #22673: MTP is compatible with mmproj. A vision MTP load
|
|
# with auto/default spec must match a backend already running draft-mtp.
|
|
backend = _mtp_backend(_is_vision = True)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = True,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_vision_mtp_default_matches():
|
|
backend = _mtp_backend(_is_vision = True)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = "default",
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = True,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_vision_off_matches_vision_backend():
|
|
# Vision loads drop speculative decoding at the route level (req -> "off").
|
|
# _already_in_target_state compares canonical requested modes; a vision
|
|
# backend with _requested_spec_mode="off" matches req "off" or None+vision.
|
|
backend = _mtp_backend(
|
|
_model_identifier = "unsloth/Qwen3-VL-4B-Instruct-GGUF",
|
|
_is_vision = True,
|
|
_speculative_type = None,
|
|
_requested_spec_mode = "off",
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3-VL-4B-Instruct-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = "off",
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = True,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# GGUF-metadata-based detection (nextn_predict_layers).
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"arch, nextn",
|
|
[
|
|
# Verified against real Unsloth MTP GGUFs (qwen35 / qwen35moe).
|
|
("qwen35", 1),
|
|
("qwen35moe", 1),
|
|
# Future-proofing: any arch + n>0 should match.
|
|
("qwen3moe", 2),
|
|
("hypothetical_future_arch", 4),
|
|
],
|
|
)
|
|
def test_read_gguf_metadata_captures_nextn_predict_layers(tmp_path, arch, nextn):
|
|
gguf = _write_minimal_gguf(
|
|
tmp_path / "model.gguf",
|
|
arch = arch,
|
|
nextn = nextn,
|
|
extra_uint32 = {f"{arch}.block_count": 4},
|
|
)
|
|
backend = LlamaCppBackend()
|
|
backend._read_gguf_metadata(str(gguf))
|
|
assert backend._nextn_predict_layers == nextn
|
|
|
|
|
|
def test_read_gguf_metadata_leaves_nextn_unset_for_non_mtp_arch(tmp_path):
|
|
gguf = _write_minimal_gguf(
|
|
tmp_path / "model.gguf",
|
|
arch = "qwen3",
|
|
nextn = None,
|
|
extra_uint32 = {"qwen3.block_count": 4},
|
|
)
|
|
backend = LlamaCppBackend()
|
|
backend._read_gguf_metadata(str(gguf))
|
|
assert backend._nextn_predict_layers is None
|
|
|
|
|
|
def test_read_gguf_metadata_zero_nextn_is_falsy(tmp_path):
|
|
# bool(0) is False, so the spec block short-circuits.
|
|
gguf = _write_minimal_gguf(
|
|
tmp_path / "model.gguf",
|
|
arch = "qwen35",
|
|
nextn = 0,
|
|
extra_uint32 = {"qwen35.block_count": 4},
|
|
)
|
|
backend = LlamaCppBackend()
|
|
backend._read_gguf_metadata(str(gguf))
|
|
assert backend._nextn_predict_layers == 0
|
|
assert bool(backend._nextn_predict_layers) is False
|
|
|
|
|
|
def test_unload_resets_nextn_predict_layers():
|
|
# MTP state from a previous load must not bleed into the next load.
|
|
backend = LlamaCppBackend()
|
|
backend._nextn_predict_layers = 1
|
|
backend.unload_model()
|
|
assert backend._nextn_predict_layers is None
|
|
|
|
|
|
# llama-server capability probe.
|
|
|
|
|
|
def _make_fake_llama_server(path: Path, help_text: str) -> Path:
|
|
"""Bash stub that prints `help_text` on --help."""
|
|
path.write_text(f"#!/usr/bin/env bash\ncat <<'EOF'\n{help_text}\nEOF\n")
|
|
path.chmod(0o755)
|
|
return path
|
|
|
|
|
|
_NEEDS_BASH = pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason = "fake llama-server is a bash stub; Windows has no direct executor",
|
|
)
|
|
|
|
|
|
def _clear_caps_cache():
|
|
LlamaCppBackend._capability_cache.clear()
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_server_capabilities_detects_draft_mtp(tmp_path):
|
|
# Original naming from llama.cpp #22673.
|
|
fake = _make_fake_llama_server(
|
|
tmp_path / "llama-server",
|
|
"--spec-type none,draft-simple,draft-eagle3,draft-mtp,"
|
|
"ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,ngram-cache",
|
|
)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["found"] is True
|
|
assert caps["mtp_token"] == "draft-mtp"
|
|
assert caps["supports_mtp"] is True
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_server_capabilities_uses_binary_library_env(tmp_path, monkeypatch):
|
|
fake = _make_fake_llama_server(
|
|
tmp_path / "llama-server",
|
|
"--spec-type none,mtp,ngram-simple\n",
|
|
)
|
|
captured = {}
|
|
|
|
monkeypatch.setattr(
|
|
"core.inference.llama_cpp.child_env_without_native_path_secret",
|
|
lambda: {"LD_LIBRARY_PATH": "/already-there"},
|
|
)
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["env"] = kwargs.get("env")
|
|
return _types.SimpleNamespace(stdout = "--spec-type none,mtp,ngram-simple\n", stderr = "")
|
|
|
|
monkeypatch.setattr("core.inference.llama_cpp.subprocess.run", fake_run)
|
|
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
|
|
assert caps["found"] is True
|
|
assert caps["supports_mtp"] is True
|
|
assert captured["cmd"] == [str(fake), "--help"]
|
|
assert captured["env"] is not None
|
|
ld_dirs = captured["env"]["LD_LIBRARY_PATH"].split(os.pathsep)
|
|
assert str(fake.parent) in ld_dirs
|
|
assert "/already-there" in ld_dirs
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_server_capabilities_detects_renamed_mtp(tmp_path):
|
|
# Renamed upstream: draft-mtp -> mtp.
|
|
fake = _make_fake_llama_server(
|
|
tmp_path / "llama-server",
|
|
"--spec-type [none|mtp|ngram-cache|ngram-simple|ngram-map-k|ngram-map-k4v|ngram-mod]",
|
|
)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["mtp_token"] == "mtp"
|
|
assert caps["supports_mtp"] is True
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_server_capabilities_reports_outdated_binary(tmp_path):
|
|
# Pre-MTP llama.cpp: only ngram variants.
|
|
fake = _make_fake_llama_server(
|
|
tmp_path / "llama-server",
|
|
"--spec-type none,ngram-simple,ngram-mod",
|
|
)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["found"] is True
|
|
assert caps["mtp_token"] is None
|
|
assert caps["supports_mtp"] is False
|
|
|
|
|
|
def test_probe_server_capabilities_handles_missing_binary():
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities("/no/such/llama-server")
|
|
assert caps["found"] is False
|
|
assert caps["supports_mtp"] is False
|
|
assert caps["supports_cache_ram"] is False
|
|
assert caps["supports_ctx_checkpoints"] is False
|
|
assert caps["supports_no_cache_prompt"] is False
|
|
|
|
|
|
# ngram-mod flag flavor detection (new vs legacy llama-server).
|
|
|
|
# Help-text fixtures mirror the actual `llama-server --help` block
|
|
# layout (flag on its own line; description indented underneath).
|
|
_POST_RENAME_HELP = """\
|
|
--spec-draft-n-max N number of tokens to draft for speculative decoding (default: 16)
|
|
(env: LLAMA_ARG_SPEC_DRAFT_N_MAX)
|
|
--spec-draft-n-min N minimum number of draft tokens to use for speculative decoding (default: 0)
|
|
(env: LLAMA_ARG_SPEC_DRAFT_N_MIN)
|
|
--spec-draft-p-min, --draft-p-min P minimum speculative decoding probability (greedy) (default: 0.75)
|
|
(env: LLAMA_ARG_SPEC_DRAFT_P_MIN)
|
|
--spec-ngram-mod-n-min N minimum number of ngram tokens (default: 48)
|
|
--spec-ngram-mod-n-max N maximum number of ngram tokens (default: 64)
|
|
--spec-ngram-mod-n-match N ngram-mod lookup length (default: 24)
|
|
--spec-type none,draft-simple,draft-mtp,ngram-mod comma-separated list of types of speculative decoding to use
|
|
(env: LLAMA_ARG_SPEC_TYPE)
|
|
--draft, --draft-n, --draft-max N the argument has been removed. use --spec-draft-n-max or --spec-ngram-mod-n-max
|
|
(env: LLAMA_ARG_DRAFT_MAX)
|
|
--draft-min, --draft-n-min N the argument has been removed. use --spec-draft-n-min or --spec-ngram-mod-n-min
|
|
(env: LLAMA_ARG_DRAFT_MIN)
|
|
--spec-ngram-size-n N the argument has been removed. use the respective --spec-ngram-*-size-n or --spec-ngram-mod-n-match
|
|
"""
|
|
|
|
_LEGACY_HELP = """\
|
|
--draft, --draft-n, --draft-max N number of tokens to draft for speculative decoding (default: 8)
|
|
(env: LLAMA_ARG_DRAFT_MAX)
|
|
--draft-min, --draft-n-min N minimum number of draft tokens to use for speculative decoding (default: 0)
|
|
(env: LLAMA_ARG_DRAFT_MIN)
|
|
--spec-ngram-size-n N ngram lookup length (default: 24)
|
|
--spec-type none,ngram-mod,ngram-simple comma-separated list of types of speculative decoding to use
|
|
"""
|
|
|
|
_CACHE_FLAGS_HELP = """\
|
|
--cache-ram N store prompt cache in RAM (default: 0)
|
|
--ctx-checkpoints N number of context checkpoints (default: 0)
|
|
--no-cache-prompt do not reuse prompt cache
|
|
"""
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_detects_post_rename_ngram_mod_flavor(tmp_path):
|
|
fake = _make_fake_llama_server(tmp_path / "llama-server", _POST_RENAME_HELP)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["found"] is True
|
|
assert caps["ngram_mod_flavor"] == "new"
|
|
assert caps["supports_ngram_mod"] is True
|
|
assert caps["spec_draft_n_max_flag"] == "--spec-draft-n-max"
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_detects_legacy_ngram_mod_flavor(tmp_path):
|
|
fake = _make_fake_llama_server(tmp_path / "llama-server", _LEGACY_HELP)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["found"] is True
|
|
assert caps["ngram_mod_flavor"] == "legacy"
|
|
assert caps["supports_ngram_mod"] is True
|
|
assert caps["spec_draft_n_max_flag"] == "--draft-max"
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_ignores_removal_stub_descriptions(tmp_path):
|
|
# Post-rename binary: legacy flags present but with "argument has been
|
|
# removed" descriptions; must not be detected as legacy.
|
|
fake = _make_fake_llama_server(tmp_path / "llama-server", _POST_RENAME_HELP)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["ngram_mod_flavor"] == "new"
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_no_ngram_mod_on_minimal_binary(tmp_path):
|
|
# Pre-anything: neither set present.
|
|
fake = _make_fake_llama_server(
|
|
tmp_path / "llama-server",
|
|
"--spec-type none\n--threads N\n",
|
|
)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["ngram_mod_flavor"] is None
|
|
assert caps["supports_ngram_mod"] is False
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_detects_windows_cache_flags(tmp_path):
|
|
fake = _make_fake_llama_server(tmp_path / "llama-server", _CACHE_FLAGS_HELP)
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["supports_cache_ram"] is True
|
|
assert caps["supports_ctx_checkpoints"] is True
|
|
assert caps["supports_no_cache_prompt"] is True
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_reports_windows_cache_flags_absent_for_older_binary(tmp_path):
|
|
fake = _make_fake_llama_server(tmp_path / "llama-server", "--threads N\n")
|
|
_clear_caps_cache()
|
|
caps = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps["supports_cache_ram"] is False
|
|
assert caps["supports_ctx_checkpoints"] is False
|
|
assert caps["supports_no_cache_prompt"] is False
|
|
|
|
|
|
def test_build_ngram_mod_flags_new():
|
|
flags = _build_ngram_mod_flags({"ngram_mod_flavor": "new"})
|
|
assert flags == [
|
|
"--spec-ngram-mod-n-match",
|
|
"24",
|
|
"--spec-ngram-mod-n-min",
|
|
"48",
|
|
"--spec-ngram-mod-n-max",
|
|
"64",
|
|
]
|
|
|
|
|
|
def test_build_ngram_mod_flags_legacy():
|
|
flags = _build_ngram_mod_flags({"ngram_mod_flavor": "legacy"})
|
|
assert flags == ["--spec-ngram-size-n", "24", "--draft-min", "48", "--draft-max", "64"]
|
|
|
|
|
|
def test_build_ngram_mod_flags_empty_when_unsupported():
|
|
assert _build_ngram_mod_flags({"ngram_mod_flavor": None}) == []
|
|
assert _build_ngram_mod_flags(None) == []
|
|
assert _build_ngram_mod_flags({}) == []
|
|
|
|
|
|
def test_build_ngram_mod_flags_respects_custom_values():
|
|
flags = _build_ngram_mod_flags({"ngram_mod_flavor": "new"}, n_match = 16, n_min = 24, n_max = 32)
|
|
assert flags == [
|
|
"--spec-ngram-mod-n-match",
|
|
"16",
|
|
"--spec-ngram-mod-n-min",
|
|
"24",
|
|
"--spec-ngram-mod-n-max",
|
|
"32",
|
|
]
|
|
|
|
|
|
@_NEEDS_BASH
|
|
def test_probe_server_capabilities_caches_by_mtime(tmp_path):
|
|
# Same (path, mtime) -> cache hit. Bumped mtime -> re-probe.
|
|
fake = _make_fake_llama_server(
|
|
tmp_path / "llama-server",
|
|
"--spec-type none,ngram-mod",
|
|
)
|
|
_clear_caps_cache()
|
|
caps1 = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps1["supports_mtp"] is False
|
|
|
|
import os
|
|
import time
|
|
|
|
_make_fake_llama_server(
|
|
fake,
|
|
"--spec-type none,draft-mtp,ngram-mod",
|
|
)
|
|
new_mtime = int(time.time()) + 2
|
|
os.utime(fake, (new_mtime, new_mtime))
|
|
caps2 = LlamaCppBackend.probe_server_capabilities(str(fake))
|
|
assert caps2["mtp_token"] == "draft-mtp"
|
|
assert caps2["supports_mtp"] is True
|
|
|
|
|
|
# spec_draft_n_max plumbing (first-class --spec-draft-n-max override).
|
|
|
|
|
|
def test_already_in_target_state_matches_when_draft_n_max_unset():
|
|
# None on the request means "platform default"; matches any backend.
|
|
backend = _mtp_backend(_spec_draft_n_max = None)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
spec_draft_n_max = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_matches_when_draft_n_max_equals_backend():
|
|
backend = _mtp_backend(_spec_draft_n_max = 4)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
spec_draft_n_max = 4,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_mismatches_when_draft_n_max_differs():
|
|
backend = _mtp_backend(_spec_draft_n_max = 4)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
spec_draft_n_max = 8,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_draft_n_max_ignored_when_not_mtp():
|
|
# ngram-mod backend; spec_draft_n_max is MTP-only and must not force
|
|
# a reload against a non-MTP active spec.
|
|
backend = _mtp_backend(
|
|
_speculative_type = "ngram-mod",
|
|
_requested_spec_mode = "ngram",
|
|
_spec_draft_n_max = None,
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.6-27B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = "ngram-mod",
|
|
spec_draft_n_max = 8,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# Sub-3B MTP gate -- tiny dense models regress with the MTP draft head, so
|
|
# load_model falls back to ngram-mod (when the binary supports it) instead of
|
|
# draft-mtp. The reload-skip mirror must follow the same fallback so a sub-3B
|
|
# reload-with-default doesn't bounce a correctly-configured ngram-mod/off backend.
|
|
|
|
|
|
def _patch_probe(monkeypatch, ngram_supported):
|
|
"""Force probe_server_capabilities to a deterministic result so tests
|
|
don't depend on whatever llama-server is on PATH."""
|
|
fake = {
|
|
"found": True,
|
|
"mtp_token": "draft-mtp",
|
|
"supports_mtp": True,
|
|
"ngram_mod_flavor": "new" if ngram_supported else None,
|
|
"supports_ngram_mod": bool(ngram_supported),
|
|
"spec_draft_n_max_flag": "--spec-draft-n-max",
|
|
}
|
|
monkeypatch.setattr(
|
|
LlamaCppBackend,
|
|
"probe_server_capabilities",
|
|
classmethod(lambda cls, binary = None: fake),
|
|
)
|
|
monkeypatch.setattr(
|
|
LlamaCppBackend,
|
|
"_find_llama_server_binary",
|
|
classmethod(lambda cls: "/fake/llama-server"),
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_sub_3b_falls_back_to_ngram_mod_when_supported(monkeypatch):
|
|
# 0.8B MTP request -- load_model would have promoted to ngram-mod (no MTP
|
|
# head); reload check must match a ngram-mod backend.
|
|
_patch_probe(monkeypatch, ngram_supported = True)
|
|
backend = _mtp_backend(
|
|
_model_identifier = "unsloth/Qwen3.5-0.8B-MTP-GGUF",
|
|
_speculative_type = "ngram-mod",
|
|
_spec_draft_n_max = None,
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.5-0.8B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_sub_3b_falls_back_to_off_when_no_ngram(monkeypatch):
|
|
# 0.8B + binary lacks ngram-mod -> fall back to off.
|
|
_patch_probe(monkeypatch, ngram_supported = False)
|
|
backend = _mtp_backend(
|
|
_model_identifier = "unsloth/Qwen3.5-0.8B-MTP-GGUF",
|
|
_speculative_type = None,
|
|
_spec_draft_n_max = None,
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.5-0.8B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_4b_mtp_request_promotes_as_before(monkeypatch):
|
|
# 4B is above the 3B threshold -> auto-promote still applies.
|
|
_patch_probe(monkeypatch, ngram_supported = True)
|
|
backend = _mtp_backend(
|
|
_model_identifier = "unsloth/Qwen3.5-4B-MTP-GGUF",
|
|
_speculative_type = "draft-mtp",
|
|
_spec_draft_n_max = None,
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.5-4B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_already_in_target_state_2b_falls_back_to_ngram_below_threshold(monkeypatch):
|
|
# 2.0B is below the 3B threshold -> ngram-mod fallback, not draft-mtp.
|
|
# Clean-bench shows 2B regresses with draft-mtp.
|
|
_patch_probe(monkeypatch, ngram_supported = True)
|
|
backend = _mtp_backend(
|
|
_model_identifier = "unsloth/Qwen3.5-2B-MTP-GGUF",
|
|
_speculative_type = "ngram-mod",
|
|
_spec_draft_n_max = None,
|
|
)
|
|
assert (
|
|
backend._already_in_target_state(
|
|
gguf_path = None,
|
|
model_identifier = "unsloth/Qwen3.5-2B-MTP-GGUF",
|
|
hf_variant = "Q4_K_M",
|
|
n_ctx = 8192,
|
|
cache_type_kv = None,
|
|
speculative_type = None,
|
|
chat_template_override = None,
|
|
extra_args = None,
|
|
is_vision = False,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# usage backfill from timings (Studio UI t/s widget fix).
|
|
|
|
|
|
def test_backfill_usage_from_timings_fills_when_completion_tokens_zero():
|
|
out = _backfill_usage_from_timings(
|
|
{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
{"prompt_n": 42, "predicted_n": 128, "predicted_per_second": 100.0},
|
|
)
|
|
assert out["completion_tokens"] == 128
|
|
assert out["prompt_tokens"] == 42
|
|
assert out["total_tokens"] == 170
|
|
|
|
|
|
def test_backfill_usage_from_timings_fills_when_usage_missing():
|
|
out = _backfill_usage_from_timings(
|
|
None,
|
|
{"prompt_n": 42, "predicted_n": 128, "predicted_per_second": 100.0},
|
|
)
|
|
assert out["completion_tokens"] == 128
|
|
assert out["prompt_tokens"] == 42
|
|
assert out["total_tokens"] == 170
|
|
|
|
|
|
def test_backfill_usage_from_timings_preserves_real_usage():
|
|
# Non-zero completion_tokens means llama-server reported correctly;
|
|
# do not overwrite.
|
|
real = {"prompt_tokens": 50, "completion_tokens": 200, "total_tokens": 250}
|
|
out = _backfill_usage_from_timings(real, {"predicted_n": 999, "prompt_n": 999})
|
|
assert out is real
|
|
assert out["completion_tokens"] == 200
|
|
|
|
|
|
def test_backfill_usage_from_timings_passthrough_when_timings_empty():
|
|
assert _backfill_usage_from_timings(None, None) is None
|
|
assert _backfill_usage_from_timings(None, {}) is None
|
|
usage = {"completion_tokens": 0}
|
|
# No timings.predicted_n -> nothing to fill, return as-is.
|
|
assert _backfill_usage_from_timings(usage, {"prompt_ms": 5.0}) is usage
|
|
|
|
|
|
# ── _canonicalize_spec_mode (pure) ─────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value, expected",
|
|
[
|
|
# New canonical values pass through unchanged.
|
|
("auto", "auto"),
|
|
("mtp", "mtp"),
|
|
("ngram", "ngram"),
|
|
("mtp+ngram", "mtp+ngram"),
|
|
("off", "off"),
|
|
("ngram-simple", "ngram-simple"),
|
|
# Legacy wire values map onto the new vocabulary.
|
|
("default", "auto"),
|
|
("draft-mtp", "mtp"),
|
|
("ngram-mod", "ngram"),
|
|
# Comma-chained legacy values (e.g. from persisted state) collapse
|
|
# to the right canonical mode.
|
|
("ngram-mod,draft-mtp", "mtp+ngram"),
|
|
("draft-mtp,ngram-mod", "mtp+ngram"),
|
|
("draft-mtp,mtp", "mtp"),
|
|
("ngram-mod,ngram", "ngram"),
|
|
# Case and whitespace are ignored.
|
|
(" AUTO ", "auto"),
|
|
("MTP", "mtp"),
|
|
("MTP+Ngram", "mtp+ngram"),
|
|
# None / empty / whitespace pass through as None.
|
|
(None, None),
|
|
("", None),
|
|
(" ", None),
|
|
# Non-string inputs collapse to None.
|
|
(42, None),
|
|
(True, None),
|
|
# Unknown strings fall back to "auto" (safe default).
|
|
("bogus", "auto"),
|
|
],
|
|
)
|
|
def test_canonicalize_spec_mode(value, expected):
|
|
assert _canonicalize_spec_mode(value) == expected
|
|
|
|
|
|
# ── _build_speculative_flags resolver matrix ──────────────────────
|
|
|
|
|
|
def _resolver_backend(
|
|
monkeypatch,
|
|
*,
|
|
ngram_supported = True,
|
|
mtp_token = "draft-mtp",
|
|
):
|
|
"""Backend with a deterministic probe so the resolver is hermetic."""
|
|
fake = {
|
|
"found": True,
|
|
"mtp_token": mtp_token,
|
|
"supports_mtp": bool(mtp_token),
|
|
"ngram_mod_flavor": "new" if ngram_supported else None,
|
|
"supports_ngram_mod": bool(ngram_supported),
|
|
"spec_draft_n_max_flag": "--spec-draft-n-max",
|
|
}
|
|
monkeypatch.setattr(
|
|
LlamaCppBackend,
|
|
"probe_server_capabilities",
|
|
classmethod(lambda cls, binary = None: fake),
|
|
)
|
|
backend = LlamaCppBackend()
|
|
backend._nextn_predict_layers = None
|
|
return backend
|
|
|
|
|
|
def _flags_dict(flags):
|
|
"""Parse the spec-flag list into a {flag: value} dict; collapses repeated
|
|
flags by keeping the last (only --spec-type can repeat, and never does
|
|
in our resolver)."""
|
|
out = {}
|
|
i = 0
|
|
while i < len(flags):
|
|
token = flags[i]
|
|
if i + 1 < len(flags) and not flags[i + 1].startswith("--"):
|
|
out[token] = flags[i + 1]
|
|
i += 2
|
|
else:
|
|
out[token] = True
|
|
i += 1
|
|
return out
|
|
|
|
|
|
_MTP_MODEL = "unsloth/Qwen3.6-27B-MTP-GGUF"
|
|
_NON_MTP_MODEL = "unsloth/Qwen3-7B-Instruct-GGUF"
|
|
_SUB_3B_MTP_MODEL = "unsloth/Qwen3.5-0.8B-MTP-GGUF"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"requested, gpus, model, expect_spec_type, expect_n_max, expect_ngram_knobs",
|
|
[
|
|
# ── auto + MTP model + 3B+: GPU = mtp only, CPU = chain ──
|
|
("auto", True, _MTP_MODEL, "draft-mtp", "2", False),
|
|
("auto", False, _MTP_MODEL, "ngram-mod,draft-mtp", "3", True),
|
|
# ── auto + non-MTP: emit --spec-default ──
|
|
("auto", True, _NON_MTP_MODEL, None, None, False),
|
|
("auto", False, _NON_MTP_MODEL, None, None, False),
|
|
# ── auto + sub-3B MTP: fallback to ngram-mod ──
|
|
("auto", True, _SUB_3B_MTP_MODEL, "ngram-mod", None, True),
|
|
("auto", False, _SUB_3B_MTP_MODEL, "ngram-mod", None, True),
|
|
# ── mtp forced: MTP-only on BOTH platforms ──
|
|
("mtp", True, _MTP_MODEL, "draft-mtp", "2", False),
|
|
("mtp", False, _MTP_MODEL, "draft-mtp", "3", False),
|
|
# ── mtp forced on sub-3B: engage anyway ──
|
|
("mtp", True, _SUB_3B_MTP_MODEL, "draft-mtp", "2", False),
|
|
# ── mtp forced on non-MTP: default back (no head/drafter) ──
|
|
("mtp", True, _NON_MTP_MODEL, None, None, False),
|
|
# ── ngram forced: ngram-mod alone on BOTH platforms ──
|
|
("ngram", True, _MTP_MODEL, "ngram-mod", None, True),
|
|
("ngram", False, _MTP_MODEL, "ngram-mod", None, True),
|
|
("ngram", True, _NON_MTP_MODEL, "ngram-mod", None, True),
|
|
# ── mtp+ngram forced: chain on BOTH platforms ──
|
|
("mtp+ngram", True, _MTP_MODEL, "ngram-mod,draft-mtp", "2", True),
|
|
("mtp+ngram", False, _MTP_MODEL, "ngram-mod,draft-mtp", "3", True),
|
|
("mtp+ngram", True, _SUB_3B_MTP_MODEL, "ngram-mod,draft-mtp", "2", True),
|
|
# ── mtp+ngram forced on non-MTP: keep ngram, drop draft-mtp ──
|
|
("mtp+ngram", True, _NON_MTP_MODEL, "ngram-mod", None, True),
|
|
# ── off: nothing emitted ──
|
|
("off", True, _MTP_MODEL, None, None, False),
|
|
("off", False, _MTP_MODEL, None, None, False),
|
|
# ── legacy values round-trip to the canonical emission ──
|
|
("default", True, _MTP_MODEL, "draft-mtp", "2", False),
|
|
("draft-mtp", True, _MTP_MODEL, "draft-mtp", "2", False),
|
|
("ngram-mod", True, _MTP_MODEL, "ngram-mod", None, True),
|
|
("ngram-mod,draft-mtp", False, _MTP_MODEL, "ngram-mod,draft-mtp", "3", True),
|
|
# ── ngram-simple: pass through ──
|
|
("ngram-simple", True, _MTP_MODEL, "ngram-simple", None, False),
|
|
],
|
|
)
|
|
def test_build_speculative_flags_matrix(
|
|
monkeypatch, requested, gpus, model, expect_spec_type, expect_n_max, expect_ngram_knobs
|
|
):
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = requested,
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = model,
|
|
model_path = None,
|
|
gpus = gpus,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
parsed = _flags_dict(flags)
|
|
if expect_spec_type is None:
|
|
assert "--spec-type" not in parsed
|
|
else:
|
|
assert parsed.get("--spec-type") == expect_spec_type
|
|
if expect_n_max is None:
|
|
assert "--spec-draft-n-max" not in parsed
|
|
else:
|
|
assert parsed.get("--spec-draft-n-max") == expect_n_max
|
|
if expect_ngram_knobs:
|
|
assert "--spec-ngram-mod-n-match" in parsed
|
|
assert "--spec-ngram-mod-n-min" in parsed
|
|
assert "--spec-ngram-mod-n-max" in parsed
|
|
else:
|
|
assert "--spec-ngram-mod-n-match" not in parsed
|
|
|
|
|
|
def test_build_speculative_flags_user_extra_args_owns_spec_type(monkeypatch):
|
|
# User --spec-type in extra_args bypasses the dropdown entirely.
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = "mtp", # would normally force MTP
|
|
spec_draft_n_max = None,
|
|
extra_args = ["--spec-type", "ngram-mod"],
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
# Resolver emits nothing -- the user's extra_args carries the --spec-type,
|
|
# and the resolver records requested_spec_mode = None.
|
|
assert flags == []
|
|
assert backend.requested_spec_mode is None
|
|
assert backend.speculative_type is None
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["auto", "mtp", "ngram", "mtp+ngram", "off"])
|
|
def test_build_speculative_flags_round_trips_requested_mode(monkeypatch, mode):
|
|
# The status round-trip is the contract that lets the UI dropdown
|
|
# restore its picked value after reload / refresh.
|
|
backend = _resolver_backend(monkeypatch)
|
|
backend._build_speculative_flags(
|
|
speculative_type = mode,
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
assert backend.requested_spec_mode == mode
|
|
|
|
|
|
def test_build_speculative_flags_user_draft_n_max_override(monkeypatch):
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = "mtp",
|
|
spec_draft_n_max = 5,
|
|
extra_args = None,
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
parsed = _flags_dict(flags)
|
|
assert parsed.get("--spec-draft-n-max") == "5"
|
|
assert backend.spec_draft_n_max == 5
|
|
|
|
|
|
def test_build_speculative_flags_mtp_token_missing_emits_spec_default(monkeypatch):
|
|
# Outdated llama-server with no MTP support: forced MTP must degrade (warned)
|
|
# and emit --spec-default so an inherited LLAMA_ARG_SPEC_TYPE=draft-mtp (CLI
|
|
# wins over env) can't make the child attempt MTP the gate budgeted off.
|
|
backend = _resolver_backend(monkeypatch, mtp_token = None)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = "mtp",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
assert "--spec-type" not in flags
|
|
assert "--spec-default" in flags
|
|
# Degraded to non-speculative; the user's choice is still reflected.
|
|
assert backend.speculative_type == "default"
|
|
assert backend.requested_spec_mode == "mtp"
|
|
assert backend.spec_fallback_reason == "binary_no_mtp"
|
|
|
|
|
|
def test_forced_mtp_on_non_mtp_model_defaults_back(monkeypatch):
|
|
# Forcing MTP on a model with no head/drafter must NOT emit draft-mtp:
|
|
# llama-server aborts on it ("failed to measure MTP context memory")
|
|
# rather than no-op'ing. Default back to --spec-default instead.
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = "mtp",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _NON_MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
assert "--spec-type" not in flags
|
|
assert "--spec-default" in flags
|
|
assert backend.speculative_type == "default"
|
|
assert backend.requested_spec_mode == "mtp"
|
|
|
|
|
|
def test_forced_mtp_ngram_on_non_mtp_model_keeps_ngram(monkeypatch):
|
|
# mtp+ngram on a non-MTP model drops the doomed draft-mtp chain but keeps
|
|
# the ngram half, which needs no head.
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = "mtp+ngram",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _NON_MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
parsed = _flags_dict(flags)
|
|
assert parsed.get("--spec-type") == "ngram-mod"
|
|
assert backend.speculative_type == "ngram-mod"
|
|
assert backend.requested_spec_mode == "mtp+ngram"
|
|
|
|
|
|
# ── Full named-repo resolver matrix (the shipping Studio families) ─────
|
|
#
|
|
# Locks auto / off / forced-mtp routing for every Qwen3.5 (MTP + plain) and
|
|
# gemma-4 (regular + QAT) GGUF repo, including the giant MoEs that stay
|
|
# resolver-only (122B-A10B / 397B-A17B). Expectations are derived from the
|
|
# same signals load_model uses -- _extract_model_size_b (active>effective>
|
|
# total, so E2B->2, A3B->3, A10B->10, A17B->17), _is_mtp_model_name, and the
|
|
# separate-drafter flag -- so each row mirrors what the loader emits on a
|
|
# B200 (GPU default, n=2). gemma carries no -MTP marker; its MTP comes from
|
|
# the root mtp-*.gguf drafter, modelled here by passing mtp_draft_path.
|
|
#
|
|
# auto_spec: "draft-mtp" = head/drafter engaged (>=3B MTP, or any size with a
|
|
# separate drafter); "ngram-mod" = embedded sub-3B drop (zero-VRAM); None =
|
|
# non-MTP -> llama-server --spec-default.
|
|
|
|
_GEMMA_DRAFTER = "/snap/mtp-gemma-4-it.gguf" # stand-in separate drafter
|
|
|
|
_REAL_REPO_MATRIX = [
|
|
# repo, drafter, auto_spec, auto_ngram_knobs
|
|
("unsloth/Qwen3.5-0.8B-MTP-GGUF", None, "ngram-mod", True),
|
|
("unsloth/Qwen3.5-2B-MTP-GGUF", None, "ngram-mod", True),
|
|
("unsloth/Qwen3.5-4B-MTP-GGUF", None, "draft-mtp", False),
|
|
("unsloth/Qwen3.5-9B-MTP-GGUF", None, "draft-mtp", False),
|
|
("unsloth/Qwen3.5-27B-MTP-GGUF", None, "draft-mtp", False),
|
|
("unsloth/Qwen3.5-35B-A3B-MTP-GGUF", None, "draft-mtp", False),
|
|
("unsloth/Qwen3.5-122B-A10B-MTP-GGUF", None, "draft-mtp", False),
|
|
("unsloth/Qwen3.5-397B-A17B-MTP-GGUF", None, "draft-mtp", False),
|
|
("unsloth/Qwen3.5-0.8B-GGUF", None, None, False),
|
|
("unsloth/Qwen3.5-2B-GGUF", None, None, False),
|
|
("unsloth/Qwen3.5-4B-GGUF", None, None, False),
|
|
("unsloth/Qwen3.5-9B-GGUF", None, None, False),
|
|
# E2B is 2B but ships a separate drafter -> exempt from the sub-3B drop.
|
|
("unsloth/gemma-4-E2B-it-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-E4B-it-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-12b-it-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-26B-A4B-it-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-31B-it-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-E2B-it-qat-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-E4B-it-qat-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-12b-it-qat-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-26B-A4B-it-qat-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
("unsloth/gemma-4-31B-it-qat-GGUF", _GEMMA_DRAFTER, "draft-mtp", False),
|
|
]
|
|
|
|
|
|
def _resolve_real(monkeypatch, repo, drafter, mode):
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = mode,
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = repo,
|
|
model_path = None,
|
|
gpus = True, # B200 default
|
|
binary = "/fake/llama-server",
|
|
mtp_draft_path = drafter,
|
|
)
|
|
return backend, flags, _flags_dict(flags)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"repo, drafter, auto_spec, auto_ngram_knobs",
|
|
_REAL_REPO_MATRIX,
|
|
ids = [r[0].split("/")[-1] for r in _REAL_REPO_MATRIX],
|
|
)
|
|
def test_real_repo_auto_routing(monkeypatch, repo, drafter, auto_spec, auto_ngram_knobs):
|
|
# Auto is the default mode the dropdown ships with.
|
|
backend, flags, parsed = _resolve_real(monkeypatch, repo, drafter, "auto")
|
|
if auto_spec is None:
|
|
# Non-MTP: no draft-mtp, hand off to llama-server's own default.
|
|
assert "--spec-type" not in parsed
|
|
assert "--spec-default" in flags
|
|
assert backend.speculative_type == "default"
|
|
elif auto_spec == "draft-mtp":
|
|
assert parsed.get("--spec-type") == "draft-mtp"
|
|
assert parsed.get("--spec-draft-n-max") == "2"
|
|
assert backend.speculative_type == "draft-mtp"
|
|
# gemma ships a separate drafter; Qwen bakes the head into the GGUF.
|
|
assert (
|
|
(parsed.get("--model-draft") == drafter) if drafter else ("--model-draft" not in parsed)
|
|
)
|
|
else: # ngram-mod (sub-3B MTP drop)
|
|
assert parsed.get("--spec-type") == "ngram-mod"
|
|
assert "--model-draft" not in parsed # draft head dropped
|
|
assert backend.speculative_type == "ngram-mod"
|
|
if auto_ngram_knobs:
|
|
assert "--spec-ngram-mod-n-match" in parsed
|
|
assert backend.requested_spec_mode == "auto"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"repo, drafter",
|
|
[(r[0], r[1]) for r in _REAL_REPO_MATRIX],
|
|
ids = [r[0].split("/")[-1] for r in _REAL_REPO_MATRIX],
|
|
)
|
|
def test_real_repo_off_emits_nothing(monkeypatch, repo, drafter):
|
|
# Off must suppress speculative decoding for every family.
|
|
backend, flags, _ = _resolve_real(monkeypatch, repo, drafter, "off")
|
|
assert flags == []
|
|
assert backend.speculative_type is None
|
|
assert backend.requested_spec_mode == "off"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"repo, drafter",
|
|
[(r[0], r[1]) for r in _REAL_REPO_MATRIX],
|
|
ids = [r[0].split("/")[-1] for r in _REAL_REPO_MATRIX],
|
|
)
|
|
def test_real_repo_forced_mtp_never_aborts(monkeypatch, repo, drafter):
|
|
# Forcing MTP on the dropdown: real MTP models (name marker or separate
|
|
# drafter) engage draft-mtp even below 3B; non-MTP models default back to
|
|
# --spec-default instead of emitting a draft-mtp llama-server will abort on.
|
|
backend, flags, parsed = _resolve_real(monkeypatch, repo, drafter, "mtp")
|
|
is_real_mtp = _is_mtp_model_name(repo) or bool(drafter)
|
|
if is_real_mtp:
|
|
assert parsed.get("--spec-type") == "draft-mtp"
|
|
assert backend.speculative_type == "draft-mtp"
|
|
assert (
|
|
(parsed.get("--model-draft") == drafter) if drafter else ("--model-draft" not in parsed)
|
|
)
|
|
else:
|
|
assert "--spec-type" not in parsed
|
|
assert "--spec-default" in flags
|
|
assert backend.speculative_type == "default"
|
|
assert backend.requested_spec_mode == "mtp"
|
|
|
|
|
|
# ── Sub-3B separate-drafter exemption (Gemma) ─────────────────────────
|
|
#
|
|
# The sub-3B MTP drop is an embedded-head cost (Qwen). A separate drafter
|
|
# (Gemma's root mtp-*.gguf) is a cheap standalone model that wins below 3B
|
|
# (B200 Q4_K_XL: gemma-4-E2B draft-mtp n=2 = 1.21x vs OFF), so it is exempt.
|
|
|
|
|
|
def test_sub3b_gemma_separate_drafter_engages_mtp(monkeypatch):
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = "auto",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = "unsloth/gemma-4-E2B-it-GGUF", # 2B
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
mtp_draft_path = "/snap/mtp-gemma-4-E2B-it.gguf", # separate drafter
|
|
)
|
|
parsed = _flags_dict(flags)
|
|
assert parsed.get("--spec-type") == "draft-mtp"
|
|
assert parsed.get("--model-draft") == "/snap/mtp-gemma-4-E2B-it.gguf"
|
|
assert "--spec-ngram-mod-n-match" not in parsed
|
|
assert backend.speculative_type == "draft-mtp"
|
|
|
|
|
|
def test_sub3b_qwen_embedded_head_still_drops_to_ngram(monkeypatch):
|
|
backend = _resolver_backend(monkeypatch)
|
|
flags = backend._build_speculative_flags(
|
|
speculative_type = "auto",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = "unsloth/Qwen3.5-2B-MTP-GGUF", # 2B, embedded head
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
mtp_draft_path = None, # no separate drafter
|
|
)
|
|
parsed = _flags_dict(flags)
|
|
assert parsed.get("--spec-type") == "ngram-mod"
|
|
assert "--model-draft" not in parsed
|
|
assert backend.speculative_type == "ngram-mod"
|
|
|
|
|
|
def test_auto_mode_drops_mtp_exempts_separate_drafter():
|
|
from core.inference.llama_cpp import _auto_mode_drops_mtp
|
|
|
|
assert _auto_mode_drops_mtp("auto", 2.0) is True
|
|
assert _auto_mode_drops_mtp("auto", 2.0, has_separate_drafter = True) is False
|
|
assert _auto_mode_drops_mtp("auto", 4.0) is False
|
|
assert _auto_mode_drops_mtp("mtp", 2.0) is False # forced engages regardless
|
|
|
|
|
|
# ── spec_fallback_reason (drives the "update llama.cpp" UI hint) ───────
|
|
|
|
|
|
def test_spec_fallback_reason_set_when_binary_lacks_mtp(monkeypatch):
|
|
# Outdated llama-server with no mtp token: a forced MTP request can't emit
|
|
# draft-mtp, so record the reason for the UI update affordance.
|
|
backend = _resolver_backend(monkeypatch, mtp_token = None)
|
|
backend._build_speculative_flags(
|
|
speculative_type = "mtp",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
assert backend.spec_fallback_reason == "binary_no_mtp"
|
|
|
|
|
|
def test_spec_fallback_reason_none_when_mtp_engages(monkeypatch):
|
|
backend = _resolver_backend(monkeypatch)
|
|
backend._build_speculative_flags(
|
|
speculative_type = "auto",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
assert backend.speculative_type == "draft-mtp"
|
|
assert backend.spec_fallback_reason is None
|
|
|
|
|
|
def test_spec_fallback_reason_reset_on_off(monkeypatch):
|
|
# A subsequent off load must clear a stale reason.
|
|
backend = _resolver_backend(monkeypatch, mtp_token = None)
|
|
backend._build_speculative_flags(
|
|
speculative_type = "mtp",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
assert backend.spec_fallback_reason == "binary_no_mtp"
|
|
backend._build_speculative_flags(
|
|
speculative_type = "off",
|
|
spec_draft_n_max = None,
|
|
extra_args = None,
|
|
model_identifier = _MTP_MODEL,
|
|
model_path = None,
|
|
gpus = True,
|
|
binary = "/fake/llama-server",
|
|
)
|
|
assert backend.spec_fallback_reason is None
|