mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-18 13:24:01 +00:00
* Studio: pin GGUF to CPU on virtualised Metal, clamp parallel slots under MTP Two GGUF inference defects found while driving the desktop app end to end. 1. On a virtualised Apple GPU every offloaded layer returns corrupt tokens. On macos-14/15 runners the same model, quant and binary emit an ordered walk through the character set with offload and correct English at gpu_layers=0, while MLX on the same machine stays coherent. _metal_device_is_paravirtual detects the case from MLX's device name, falling back to system_profiler, and load_model pins gpu_layers=0 only there. Physical Apple Silicon never reports "Paravirtual" and keeps full offload. 2. llama.cpp's draft-mtp path serves one sequence, but Studio still asked for the default 4 parallel slots, so concurrent chats against unsloth/Qwen3.5-2B-MTP-GGUF shared tokens across replies. Clamp --parallel to 1 when MTP is selected, both from an explicit --spec-type in extra_args and from the resolved spec flags. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the CPU pin actually stick, and stop the clamps misfiring Review found five real defects in the first version. Four of them meant the fix looked applied and did nothing. The pin was defeatable. Only manual mode strips offload flags at the route, so an Auto request still carries `-ngl 99` into load_model, and user extras are appended after the managed `--gpu-layers 0` where llama.cpp's last-wins parser takes them. The log said "Forcing gpu_layers=0" while the launch offloaded anyway. Offload flags are now stripped when the fallback fires. The pin also missed everything that does not read --gpu-layers. clip.cpp picks its backend from mmproj_use_gpu, which defaults true, so a vision encoder kept running the corrupt path; and common_base_params_to_speculative overwrites n_gpu_layers for a separate drafter, whose -1 default means auto. Both are now pinned, gated on a --help probe like the other optional flags here, since an older build would reject the unknown argument and refuse to start. Repeat Auto loads tore down a healthy server. The duplicate-load check ran before the guard, so it compared the raw request against the normalized state the previous launch recorded, missed the fast path every time, and killed a working CPU server. The guard now settles placement above that check. The MTP backstop could clamp launches that are not MTP. When the user owns --spec-type, _build_speculative_flags returns nothing, and judging that empty list falls through to LLAMA_ARG_SPEC_TYPE. Measured: _extra_args_requests_mtp([], {"LLAMA_ARG_SPEC_TYPE": "draft-mtp"}) is True. Both clamps now pass env = {} and the backstop skips user-owned spec types. The training guard sized 4 slots while load_model clamped to 1, so a load that fits could still be refused with a 409. It now mirrors the clamp, as it already does for --kv-unified. Rejected one suggestion: applying the fallback before the training guard would take the manual exemption and skip the guard, which on unified memory can OOM a concurrent training run. Also corrected the MTP comment. It claimed draft-mtp serves one sequence and that concurrent chats share tokens. Upstream keeps MTP state per sequence and acceptance is gated on target logits, so committed text stays correct. The clamp is still right, because the model card ships -np 1 and split_equal ubatch reordering against raw-index nextn reads collapses draft acceptance above one slot. Tests: guard 8 -> 15, parallel slots +2, both asserting the negative case so the fixes cannot over-apply. Full related suite goes 98 -> 100 failures against clean main, the two additions being the new tests themselves, which fail there for the same pre-existing pollution that already fails their five untouched test_training_guard siblings; the file passes 43/43 in isolation. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Key the companion pins on the hardware, and make them outlive user extras Round two of review, plus a defect I introduced in round one. The guard was keyed on the request, not the hardware. A user picking Manual with 0 layers on the same virtualised Mac never set _paravirtual_cpu_forced, so the mmproj and drafter pins were silently dropped, and neither of those reads --gpu-layers so nothing else covered them. Manual + 0 is a first-class UI selection and exactly what someone with corrupt output reaches for. The detector now gates the block; only the placement rewrite is skipped when there is nothing left to rewrite. Both companion pins were also undoable. --mmproj-offload and -ngld are real positive flags a user can pass, the pass-through extras are appended after the managed flags, and llama.cpp is last-wins. llama.cpp's seen_args only warns and keys on the literal alias, so "--spec-draft-ngl 0 ... -ngld 99" does not even warn, it just takes 99. The strip cannot help: _SPEC_FLAGS deliberately excludes drafter knobs. Both pins now emit after the extras. The drafter pin also reads extra_args alongside spec_flags, since a user-owned --spec-type makes _build_speculative_flags return nothing and their --model-draft was invisible. The capability gate I added in round one was itself broken. It probed '--spec-draft-ngl' or '-ngld' and then emitted the literal --spec-draft-ngl, which only exists from llama.cpp b8955, so an older build would answer "supported" and then refuse to start on a name it does not know -- the exact failure the gate exists to prevent. The '-ngld' half was dead code besides: the help-block parser records long forms only. The probe now walks --spec-draft-ngl, --gpu-layers-draft and --n-gpu-layers-draft and records which one the build has, mirroring spec_draft_n_max_flag. Declared the new tensor_parallel drop site in _ALLOWED_TP_DROP_GUARDS. The allowlist is there so a new drop is conscious rather than silent; this one drops nothing real, since a paravirtual Mac has a single emulated device. Rejected one item: the training guard cannot know a UI-selected mode resolves to MTP, and clamping on the requested mode would under-reserve KV on the four paths where _build_speculative_flags drops MTP, trading a conservative 409 for a training-run OOM. Tests: guard and parallel-slot files 61 passed in isolation, mtp detection 275 passed, tp regression 42 passed. Full related suite is 100 failures, byte identical to the PR-head baseline. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Neutralise a pass-through --split-mode on the virtualised-Mac CPU fallback --split-mode is a GPU placement flag, and the paravirtual fallback has already pinned the load to CPU, but it is not inert at --gpu-layers 0. llama.cpp builds a buffer-type list for every device in model->devices before any layer is assigned (llama-model.cpp: the make_gpu_buft_list loop runs at :1270, the i_gpu_start/act_gpu_layers split is not computed until :1315), so n_gpu_layers never reaches that step. -sm row therefore throws "device <X> does not support split buffers" on every backend except SYCL, and -sm tensor throws "LLAMA_SPLIT_MODE_TENSOR not implemented for architecture" on every arch llm_arch_supports_sm_tensor excludes. Either one turns the CPU rescue into a server that refuses to start. The zero-offload mask cannot save it: it only writes CUDA/HIP visibility vars, so the Metal device stays in the list on the one platform this fallback fires on. Overridden rather than stripped. llama.cpp is last-wins, so appending the default "layer" neutralises the mode without rewriting extra_args. Stripping would: the route's duplicate-load comparator compares the stored extras verbatim and the UI does not round-trip the extras box, so a rewrite would leave the two permanently unequal and turn every later Apply into a real model swap. Emitted after the pass-through extras for the same reason as the mmproj and drafter pins, and before the env block, since _zero_offload_keeps_gpu_visible reads the finished cmd. --tensor-split needs no treatment: splits is built but never indexed once act_gpu_layers is 0, and parsing only bounds it against llama_max_devices(). 16 tests, each with negatives so the override cannot over-apply: -ts/ --tensor-split return nothing, an existing "layer" returns nothing, and an AST check pins the call site's guard to _paravirtual_cpu_forced so a real Mac keeps its row/none split. * Stop the httpx test stub from shadowing the real module in a combined run Cross-platform staging CI ran the four test files this PR touches in one pytest session and produced 18 failures on Linux, macOS and Windows alike, all one error: "module 'httpx' has no attribute 'Response'" out of routes/inference.py. sys.modules.setdefault("httpx", stub) only checks whether httpx has already been imported, not whether it is installed, so on a runner that pip-installs the real httpx the stub still won if this file imported first. Every file importing later then found a working httpx and kept it, and this stub is a strict subset of the real module, so anything reaching httpx.Response blew up. test_tp_vision_ regression.py and test_parallel_slots_per_load.py already guard against this by preferring the real module; this file now does the same, and its fallback stub carries Response so the deps-absent path is covered too. Pre-existing, not introduced here: the same 18 failures reproduce at the merge base from the two untouched files alone. It surfaced now because this PR is the first change to touch both halves of the pair, so CI selects them together. Combined run goes from 18 failed / 360 passed to 394 passed. Each file separately is unchanged: 275, 43, 42 and 34 passed. On an interpreter with no httpx at all the count is identical to the merge base, so the stub path did not regress. * Give the MTP fallback back the slots the clamp took The MTP backstop patches cmd's --parallel to 1 in place, and the --spec-default retry slices fallback_cmd out of that already-patched cmd. --parallel is emitted long before _spec_start, so it sits in the untouched prefix and the retry inherits the clamp. That retry is not MTP, so a load that asked for N slots, resolved to MTP, and then failed MTP startup came up on a working non-MTP server that serves one chat at a time. It was also permanent. _requested_n_parallel is set from the pre-clamp snapshot, so the dedupe compares the original ask against the incoming ask, they match, and an identical reload returns already_loaded instead of rebuilding the server. _requested_spec_mode is deliberately preserved across the fallback, so the spec-mode branch dedupes too. Reachable on the ordinary path: the earlier clamp fires only on an explicit user --spec-type in extras, and the route passes n_parallel through untouched (the training guard clamps only a local for VRAM sizing). The backstop exists exactly because Auto/MTP is not resolved until _build_speculative_flags runs. Restores the clamp-time count, not the caller's original ask, so the fit's own reduction is respected and the KV budget still covers it. Rebound at patch time because the mmproj text-only retry and the no---fit retry both derive from fallback_cmd. 6 tests, 4 of them negative: a single-slot load stays single, a user-owned --spec-type gains nothing, a non-MTP resolution is inert, and an 8-slot ask the fitter already cut to 4 restores 4 rather than 8. An AST check pins the restore to the one branch that actually drops MTP, so a successful MTP launch and the flash-attention retry both stay clamped. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the vision projector when it cannot be pinned off a virtualised Metal device The projector pin was probe-gated but the projector itself was not: with supports_no_mmproj_offload false, --mmproj still shipped and clip kept its default Metal backend, so the main model ran on CPU while every image was encoded from the corrupt device this fallback exists to avoid. clip has no other lever: tools/mtmd never reads n_gpu_layers, mmproj_use_gpu defaults true, and --device does not reach clip, so --no-mmproj-offload is the only way off. The reachable trigger is a failed or timed-out --help probe, which zeroes every capability, not an old llama-server. A build that lacks --no-mmproj-offload (llama.cpp 7c727fbe, 2025-04-24) cannot start at all here, since the base argv always passes --flash-attn on and that value form only arrives with e81b8e4b, 2025-08-30, four months later. Clears launch_mmproj_path rather than only effective_is_vision, because the same value feeds the mmproj VRAM budget and the audio-encoder probe; a half drop would leave the server advertising an encoder it never launched. Tradeoff: on a virtualised Mac whose probe fails, vision goes away for the session instead of returning corrupt embeddings. The warning names the cause and the fix, and the branch is confined to paravirtual hardware. 6 tests, 3 negative: a pinnable projector survives on the same hardware, a real Mac keeps vision on a build without the flag, and a text-only GGUF is unaffected. The three positives fail with the source change reverted. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop a separate drafter that cannot be pinned off a virtualised Metal device Same gap as the projector, one flag over. The pin was probe-gated but the drafter was not, so with no spec_draft_ngl_flag the CPU pin was skipped while --model-draft still shipped. A separate drafter carries its own placement: common_base_params_to_speculative overwrites n_gpu_layers with the draft default of -1, and any negative resolves to every layer, so --gpu-layers 0 does not reach it and the drafter ran on the corrupt device. Severity is throughput, not correctness. Draft tokens are only ever compared: common_sampler_sample_and_accept_n pushes the target model's own draw and breaks at the first mismatch, and the server rolls back the provisional tokens before streaming. A corrupt drafter therefore has every proposal rejected, costing a wasted forward pass per step, and cannot leak into the output. As with the projector, an old build is not the trigger. llama-server has advertised a draft-ngl flag since d9d54e49 (2024-11-25), and the base argv requires --flash-attn on from e81b8e4b (2025-08-30), so the only reachable paths are a failed or timed-out --help probe or a custom fork. Takes the spec group and the inherited LLAMA_ARG_SPEC_* with it, since a surviving --spec-type would reach a server with no drafter to serve it, and --spec-type appends rather than replaces. A drafter the user already pinned themselves is left alone: the probe only decides whether Unsloth can emit the flag, so their pin works regardless and dropping would cost them speed for nothing. 12 tests, 8 negative. Each gate clause is pinned by mutation: deleting any one of them fails exactly the negative that guards it. * Probe for httpx instead of importing it The Source lint import-hoist check blocks a newly added import that is never used, and the try/import guard was exactly that: the import existed only for its ImportError. find_spec answers the same question without importing, which also keeps installing the stub as this module's only side effect on sys.modules. Behaviour is unchanged: the real httpx still wins when installed, and the stub is still installed when it is not. * Keep a suppressed drafter and an inherited projector from defeating their guards Two follow-ons to the paravirtual guards, plus a landmine in my own httpx fix. The drafter drop cleared launch_mtp_draft_path, and that None was stored as the loaded drafter. The sibling is still on disk, so the detector kept handing it back and both duplicate-load checks compared it against None: the backend's _already_in_target_state and the route's detected-versus-stored probe, which fires first. Every repeat Apply therefore tore down a healthy drafter-free server and could hit the active-generation reload gate. The suppressed path is now recorded and accepted by both comparisons, and cleared on unload. Only an update lifts the suppression, and unload_model runs before the binary swap, so a newly-capable build can never be deduped away. The projector guard only ever cleared Unsloth's own resolved path, so an inherited LLAMA_ARG_MMPROJ loaded a projector the guard believed it had dropped, unpinned and independent of --gpu-layers 0. LLAMA_ARG_MMPROJ_URL is worse: its download overwrites mmproj.path, so it outranks even the --mmproj Unsloth emits with --no-mmproj-offload. Both are popped on this device, matching the LLAMA_ARG_SPEC_* scrub the drafter drop already does. Users cannot reach this through argv, since --mmproj and --mmproj-url are denylisted. The find_spec probe I added for the import-hoist lint could abort collection. find_spec raises ValueError on a module already in sys.modules without a spec, and 27 test files in this tree install a bare ModuleType httpx stub. On any interpreter without real httpx, one of those collecting first turned the whole run into a collection error rather than a failed test. CI never saw it because starlette imports real httpx first. sys.modules is now tested before find_spec, which is also the more direct question: if anything already provided httpx, leave it alone. 9 tests, 6 negative: a drafter that genuinely appeared still reloads, a different or deleted drafter still reloads, unload clears the suppression, a launched drafter records none, and a real Mac keeps its inherited projector. * Ask hw.model too: SPDisplaysDataType is empty on the headless Macs this catches Measured the detector on macos-14 and macos-15 runners, which are the exact hardware this guard exists for (hw.model VirtualMac2,1). MLX names the device "Apple Paravirtual device" in about 40 ms. system_profiler SPDisplaysDataType returns zero bytes on both, stdout and stderr alike, after about 300 ms. So the fallback did not work. Its comment says MLX is not on every Mac, but on a VM with no display, which is every cloud and CI Mac, the display probe has nothing to report, and the machine read as bare metal and kept the offload that corrupts its output. The probe only ever worked when MLX was already installed, which is the case that never needed it. hw.model answers headless: VirtualMac2,1 against Mac<n>,<n> on real hardware. It goes between the two, so the order is now cheapest-first and each probe covers what the next cannot: MLX names the device outright, hw.model catches headless VMs, and SPDisplaysDataType still catches desktop VMs whose model identifier looks physical. 4 tests, 3 negative: a physical Mac reporting Mac15,3 is not dragged down, a desktop VM is still caught through the display probe so the net did not narrow, and MLX answering spawns no subprocess at all. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Stop a failed capability probe from dropping vision and speculation Parallel review of the whole branch. Three fixes, all in the same direction: the guards were treating "the probe did not answer" as "the build cannot do this". Both companion flags predate the oldest build that can start here. --no-mmproj-offload is llama.cpp b5178 (2025-04-24) and --gpu-layers-draft is from 2023, while the base argv already requires --flash-attn on from b6325 (2025-08-30). So a false capability never means the flag is missing, it means the --help probe failed, and that is easy to trigger: llama.cpp applies every LLAMA_ARG_* before parsing -h, so one malformed inherited variable makes --help exit non-zero, and the all-false result is cached for the rest of the process. The drops then disabled image input and speculation and told the user to update a build that already supported both. The drops now require a conclusive probe, and the pins cover the unanswered case, which is the same fail-open shape the MTP probe already uses. The drafter env scrub only knew the post-b8955 variable names. Between the launchable floor and the rename on 2026-04-28 the child reads LLAMA_ARG_MODEL_DRAFT and LLAMA_ARG_HFD_REPO instead, so on any build in that window an inherited drafter survived the drop and llama.cpp put it straight back on the virtualised device. Both spellings are now read and scrubbed. --override-tensor was the one placement flag --gpu-layers 0 cannot answer. llama.cpp applies it while selecting each weight's buffer type, before any layer is assigned to a device, so -ot ".*=Metal" puts weights on the corrupt GPU whatever the layer count says, and -otd does the same to the drafter. It accumulates rather than replacing, so no appended flag can neutralise it. Only GPU-bound targets are dropped: -ot exps=CPU is the common spelling, it moves weights the same way this fallback does, and stripping it would slow the load it is meant to rescue. Same rule for the inherited LLAMA_ARG_OVERRIDE_TENSOR. The exec-based harnesses now seed their scope from the module instead of listing helpers by hand, so they run against the real functions and a new helper cannot silently fall out of scope. 17 new tests, 9 negative. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Normalize the virtualised-Metal CPU pin once, and compare against it On a paravirtual Mac load_model rewrites the requested placement to CPU (manual/0, no tensor split, no MoE offload, offload flags stripped from the extras) and records the rewrite. The rewrite lived inside load_model while both duplicate-load comparators judged the RAW incoming request, so a repeat identical Apply mismatched the backend's own state and tore down a healthy CPU server, 409-ing on an active generation or cancelling it. paravirtual_normalized_request is now the one definition of that rewrite, used by the launch and by both comparators (LlamaCppBackend._already_in_target_state and the route's _request_matches_loaded_settings). It is pure and idempotent, so load_model's own comparator call and a respawn replay are no-ops. It also runs unconditionally, not only when the request asks for offload. Manual plus 0 layers already places the main model on CPU, but the extras are the caller's: an -ot ".*=Metal" is applied while each weight's buffer type is chosen, before any layer is assigned, and the route's manual-mode strip only covers the --gpu-layers family. That request reached the child with the override intact and put the weights straight back on the corrupt device. The drafter drop rewrites the extras too (it strips the whole spec group). Rather than compare that rewrite against the list the caller keeps sending, record the requested extras beside the launched ones, the way _requested_n_ctx and _mtp_draft_suppressed_path already do, and restore the requested spec mode when the drop removed the user's own --spec-type. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gate the paravirtual drafter drop on a drafter the launch actually loads On a virtualised Metal Mac with no draft-layer flag to pin with, the drop fired on any resolved sibling mtp-*.gguf, even when the user's own --spec-type selects a drafter-free mode. _build_speculative_flags returns before it can emit --model-draft for that sibling, so nothing was going to be placed on the device, yet strip_spec removed the user's --spec-type and its ngram knobs. Key the sibling half on Unsloth still owning the spec block; an explicit --model-draft (or the inherited env) still drops, since llama.cpp loads a draft model whenever its path is set. * Pin the device outright, widen VM detection and add an escape hatch for PR #7717 --gpu-layers 0 does not by itself keep work off the device: with op_offload on, ggml_backend_sched still routes ops whose weights live in host buffers to a higher-priority backend and Metal takes them at batch >= 32, so the launch now also passes --device none. It is emitted after the user extras and --device assigns rather than appends, so the pin wins. hw.model reports VMM-x86_64 inside an Intel guest, which contains neither 'virtual' nor 'paravirtual', so that spelling is matched too. UNSLOTH_ALLOW_PARAVIRTUAL_METAL=1 keeps the GPU on a VM known to be good: the corruption does not reproduce on every build and quant, and gemma-3-270m Q4_K_M on b9000 and b10090 was byte identical at gpu_layers 0 and 99. The MTP slot clamp reads the environment again. llama.cpp appends spec types rather than replacing them, so an inherited LLAMA_ARG_SPEC_TYPE=draft-mtp really does launch MTP and no later flag clears it. --override_tensor now folds through _flag_name, matching llama.cpp's own underscore folding, so the underscore spelling cannot slip past the strip. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the suite host independent for PR #7717 The dedupe comparators consult real hardware, so every existing test of them became host dependent: on a Mac, and on GitHub's macos runners which are paravirtual, the incoming request normalizes to the CPU pin while fixture state that was never normalized does not. 27 tests failed on macos-14 and none on Linux. An autouse fixture pins the detector off, patching the route's binding too since its import sits in a module-level try and is a separate global. test_the_route_dedupe_reads_the_suppressed_drafter_too patched only the llama_cpp attribute while calling the route comparator, so it exercised the bare-metal path on Linux and the real one on a Mac. It now patches both and starts from the placement a paravirtual load leaves behind. The diffusion dedupe contract follows the rename to the normalized locals, which fall back to the request itself off a virtualised device. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Align the route slot clamp and stop scoring a CPU-pinned projector as GPU resident for PR #7717 The route sized VRAM with env = {} while the backend clamp reads the environment, so with an inherited LLAMA_ARG_SPEC_TYPE=draft-mtp it budgeted for the slots that were asked for against a server that launches one, and could 409 a load that fits. Both sides read the env now. --no-mmproj-offload clears mmproj_use_gpu and clip.cpp gates the whole GPU backend on it, so the projector holds no VRAM. _cmd_has_gpu_companion still answered True for the --mmproj token, which made a --device none vision server look GPU resident and let the training coordinator unload a healthy chat server. The last of the two offload flags wins, as llama.cpp assigns there rather than accumulating. The diffusion contract loads its own copy of the module, so neither conftest reaches it; it pins the detector off the same way. * Pin the drafter device, not just its layer count, for PR #7717 common_base_params_to_speculative replaces the draft context's device list with the draft one, so the main --device none never reaches a separate drafter, and an empty draft list means no device filter at all: every device stays visible to it. The layer count alone left the drafter free to run on the corrupt device for exactly the reason --gpu-layers 0 did, so the pin now adds --device-draft none. That spelling is accepted across the whole supported range, primary at b6325 and an alias at head, and the CPU-drafter accounting already reads none as CPU so the budget still does not charge it. * Detect MTP across every accumulated spec type, and drop drafter state on diffusion loads for PR #7717 _extra_args_requests_mtp read only the last --spec-type and ignored the env behind it, but llama.cpp applies the env first and inserts each --spec-type rather than replacing, so an inherited draft-mtp alongside an extras --spec-type ngram-mod still launches MTP. The clamp then left the slots alone and the backstop was skipped because the extras owned the spec type, which is exactly the unsupported multi-slot MTP path. It now counts a type from any source. _effective_spec_type keeps last-wins, since it answers the different question of which single label to display and compare, and its docstring no longer claims that matches llama.cpp. The diffusion branch returns before the assignment that records the drafter, and only unload clears it, so a drafter from the previous load stayed on the backend while the dedupe compared the pair. Both fields are cleared there now, beside the preserved-fallback flag that is reset for the same reason. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Accumulate speculative types in the separate-draft check too for PR #7717 The MTP fix left its sibling on last-wins, so an inherited draft-simple followed by --spec-default or another --spec-type read as no separate draft model and the budget under-reserved one that still loads. Both now share _accumulated_spec_types, which is also the single place that documents why reading the last value is wrong. The two budget tests asserted the old premise outright (a later CLI type overrides an earlier one, and --spec-default clears a stale env). They assert the accumulate semantics now, including --spec-type none, which returns NONE but is still appended and so cannot clear an earlier draft-mtp. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clear the inherited spec env when Unsloth owns the spec block for PR #7717 Nothing Unsloth emits can undo an inherited LLAMA_ARG_SPEC_TYPE, because llama.cpp applies the env first and appends rather than replaces. So a managed non-MTP launch still started MTP, the crash-recovery replay that sets the mode to off and appends --spec-default could not actually drop it, and the fit had not budgeted the separate drafter an inherited draft-simple plus LLAMA_ARG_SPEC_DRAFT_MODEL would add, which is a load that passes the training guard sized main-only and then evicts training. The launch now scrubs the spec env whenever the extras do not name a --spec-type, which is exactly when _build_speculative_flags emits the whole block. This is the same reconciliation the launch already does for LLAMA_ARG_SPLIT_MODE, LLAMA_ARG_TENSOR_SPLIT, LLAMA_ARG_DEVICE and LLAMA_ARG_OVERRIDE_TENSOR. Extras that own --spec-type keep theirs, since there the flags and the env genuinely accumulate, and that is the case the slot clamp reads. The fit is paired with it: it reserves against the env only in that same extras-own case, the one where the env still reaches the child. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Judge the spec env the child will actually get for PR #7717 Scrubbing the spec env for a managed block left every prediction of the launch reading os.environ, so they described a server that was not the one starting. The slot clamp cut a multi-slot load to one for an inherited draft-mtp the child never receives, and the count never came back because the dedupe records the original ask. The fit reserved weights and KV for an inherited drafter that never loads, shrinking context or rejecting a placement that fits. _child_spec_env is now the single answer to what survives to the child, and every site uses it: the early clamp, the resolved-flags backstop, the fit budget and its env drafter, the launch-time MTP decision and the route guard. Two more sites the same reasoning reaches. The launch treated the env as live whenever nothing emitted a spec flag, which is now exactly when it is scrubbed. And the MTP crash recovery neutralised a user --spec-type draft-mtp by appending --spec-default, which cannot work because llama.cpp appends types; the replay strips the spec flags instead, and then owns the block so the env goes too. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Stop the training guard shrinking itself for MTP in PR #7717 The MTP branch this PR added to the chat-load guard sized the request as one slot, matching what load_model launches. But _estimate_gguf_required_gb counts only the drafter file and the main KV: not the draft KV, the duplicated target context MLA keeps, or the draft compute reserve, all of which load_model does budget. Clamping there dropped the slot KV without adding those back, so a model whose one-slot KV fits beside training could be admitted and then evict it. main has no such clamp, so this was a guard my own change had weakened. Sizing the MTP overhead properly at the route would mean replicating the launch resolution the estimator needs (draft cache types, swa_full, kv_unified, ubatch, flash-attn), so the guard keeps the asked count instead and the spare slots stand in for what is not modelled. A false 409 is a retry; a guard that under-sizes evicts a running job. The diffusion and kv-unified clamps stay, since neither drops a term the estimate models. * Give back the slots the drafter drop frees, and guard the CPU pin for PR #7717 The extras-MTP clamp cuts a multi-slot request to one slot. On a virtualised Mac whose llama-server advertises no draft-layer flag, the drop then strips that same spec group, so the server launches without speculation and still serves one chat at a time. Nothing restored it either: the dedupe records the original ask, so every repeat Apply matched and kept the clamp. The slots are handed back at the drop, before the spec flags are rebuilt, so the existing backstop still re-clamps if Unsloth's own resolution turns out to be MTP. The training guard sized the request as it arrived, but load_model rewrites a GGUF placement to CPU on this hardware, so an Auto chat load could be refused for VRAM it never takes. It now normalizes through the same helper the launch and both duplicate-load comparators use; the rewrite lands on manual with zero layers, which the guard already treats as an explicit placement. * Refuse a diffusion load whose CPU pin cannot be applied for PR #7717 The paravirtual rewrite hands the diffusion runner manual with zero layers, but an older unsloth_zoo shim has no --ngl and the split is dropped rather than failing argparse. Nothing else keeps that runner off Metal: cpu_only comes from _effective_gpu_count, which is torch.cuda only and reads 0 on a Mac, and the empty --gpu token it produces still leaves Metal available. Only the zero-layer split keeps the weights off every backend, so dropping it put the load back on the path this guard exists to avoid, silently. It now refuses, naming both ways out: update for a shim with --ngl, or set UNSLOTH_ALLOW_PARAVIRTUAL_METAL=1 on a VM that has been verified. A non-zero manual split is untouched, since that is the user's own placement. _start_diffusion_server enforces it for every path; the local-file case is settled above the teardown so a refusal leaves the running server alone. * Settle the HF diffusion refusal early, and stop over-reading the env for PR #7717 Three follow-ons from the last two rounds. The diffusion refusal only covered a local file. An HF load has no gguf_path above the teardown, so it killed the healthy server and downloaded the model before raising. The shim probe is local and cheap, so it is taken once and now also gates the preflight, letting the HF case raise beside the Vulkan one. The unpinnable-drafter gate still read os.environ, so an inherited LLAMA_ARG_SPEC_DRAFT_MODEL dropped a drafter the launch already scrubs and took the caller's --spec-draft-n-max and ngram knobs with it. It reads the same _child_spec_env view as the launch and the budget now. The MTP crash replay launches a stripped list while the caller keeps sending the original, so both comparators missed and the next Apply restarted the configuration that had just crashed. The replay restores the caller's extras as the requested state, device-stripped the way the launch records them. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten the comments added by PR #7717 * Drop the now-unused MTP import from the route for PR #7717 The training guard stopped clamping for MTP, which left _extra_args_requests_mtp imported in both blocks and referenced nowhere. The import-hoist safety net flags that as a blocker. * Drop accumulated MTP sources before the startup retry for PR #7717 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hand back the extras-owned MTP slot clamp on the startup retry for PR #7717 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep an inherited extras list from reloading the server it rewrote for PR #7717 * Tighten the comments added by the main merge for PR #7717 * Record no effective GPU pin for a forced-CPU launch for PR #7717 * Clear the effective GPU pin at the final recorder too for PR #7717 * Budget the slots the MTP retry restores, and clear the diffusion CPU pin for PR #7717 * Restore the extras MTP slots only where no GPU had to admit them for PR #7717 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin env-only drafters and judge inherited extras by flag for PR #7717 * Skip GPU budgeting on the forced-CPU guard and persist the fallback extras for PR #7717 --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
404 lines
16 KiB
Python
404 lines
16 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
|
|
"""The diffusion runner must honour the GPU-layer split (#7574).
|
|
|
|
Studio used to drop a manual GPU-layers setting on the diffusion path and pin every layer
|
|
to GPU, so a GGUF larger than VRAM OOMed in cudaMalloc with no way out.
|
|
|
|
The pure helpers run directly; the wiring is checked at source level, since importing the
|
|
backend pulls in the whole studio stack.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
SOURCE_PATH = REPO_ROOT / "studio" / "backend" / "core" / "inference" / "llama_cpp.py"
|
|
ROUTE_PATH = REPO_ROOT / "studio" / "backend" / "routes" / "inference.py"
|
|
SRC = SOURCE_PATH.read_text(encoding = "utf-8")
|
|
TREE = ast.parse(SRC)
|
|
|
|
|
|
@pytest.fixture(scope = "module")
|
|
def llama_cpp():
|
|
"""Import the backend module directly; skip if the studio deps aren't installed."""
|
|
backend = str(REPO_ROOT / "studio" / "backend")
|
|
if backend not in sys.path:
|
|
sys.path.insert(0, backend)
|
|
spec = importlib.util.spec_from_file_location("_llama_cpp_under_test", SOURCE_PATH)
|
|
module = importlib.util.module_from_spec(spec)
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
except Exception as exc: # missing optional studio dep on a bare checkout
|
|
pytest.skip(f"llama_cpp not importable here: {exc}")
|
|
finally:
|
|
# Do not leave studio/backend on sys.path: it shadows generic top-level names
|
|
# (utils, state, models, hub, auth, storage) for every later test.
|
|
if sys.path and sys.path[0] == backend:
|
|
sys.path.pop(0)
|
|
# The dedupe comparators consult the Metal device, so on a Mac (and on the macos
|
|
# runners, which are paravirtual) they would normalize the request to the CPU pin and
|
|
# stop matching these fixtures. A private copy, so pinning cannot leak into the app.
|
|
module._metal_device_is_paravirtual = lambda: False
|
|
return module
|
|
|
|
|
|
def _function(name: str) -> ast.FunctionDef:
|
|
for node in ast.walk(TREE):
|
|
if isinstance(node, ast.FunctionDef) and node.name == name:
|
|
return node
|
|
raise AssertionError(f"{name} missing")
|
|
|
|
|
|
def _body(name: str) -> str:
|
|
return ast.get_source_segment(SRC, _function(name)) or ""
|
|
|
|
|
|
# ── the split the child actually launches with ──
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mode", "layers", "expected"),
|
|
[
|
|
("manual", 8, 8),
|
|
("manual", 0, 0), # CPU-only is a real request, not "unset"
|
|
("manual", -1, None), # Auto slider defers to the runner
|
|
("auto", 8, None), # Unsloth mode ignores a stale layer count
|
|
("auto", -1, None),
|
|
],
|
|
)
|
|
def test_effective_ngl(llama_cpp, mode, layers, expected):
|
|
assert llama_cpp._diffusion_manual_ngl(mode, layers) == expected
|
|
|
|
|
|
def test_zero_layers_is_not_swallowed_as_falsy(llama_cpp):
|
|
"""The exact case in the report: GPU layers set to 0 must reach the child."""
|
|
assert llama_cpp._diffusion_manual_ngl("manual", 0) == 0
|
|
|
|
|
|
# ── shim capability probe ──
|
|
|
|
|
|
def test_shim_without_ngl_is_detected(llama_cpp, tmp_path):
|
|
shim = tmp_path / "shim.py"
|
|
shim.write_text('ap.add_argument("--maxtok", type=int)\n', encoding = "utf-8")
|
|
assert llama_cpp._shim_supports_ngl(["python", str(shim)]) is False
|
|
|
|
|
|
def test_shim_with_ngl_is_detected(llama_cpp, tmp_path):
|
|
shim = tmp_path / "shim.py"
|
|
shim.write_text('ap.add_argument("--ngl", type=int)\n', encoding = "utf-8")
|
|
assert llama_cpp._shim_supports_ngl(["python", str(shim)]) is True
|
|
|
|
|
|
def test_missing_shim_file_does_not_raise(llama_cpp, tmp_path):
|
|
assert llama_cpp._shim_supports_ngl(["python", str(tmp_path / "gone.py")]) is False
|
|
|
|
|
|
# ── wiring ──
|
|
|
|
|
|
def test_diffusion_server_accepts_the_layer_split():
|
|
fn = _function("_start_diffusion_server")
|
|
names = {a.arg for a in fn.args.kwonlyargs} | {a.arg for a in fn.args.args}
|
|
assert {"gpu_memory_mode", "gpu_layers"} <= names
|
|
|
|
|
|
def test_diffusion_server_forwards_ngl_and_gates_it_on_shim_support():
|
|
body = _body("_start_diffusion_server")
|
|
assert '"--ngl"' in body
|
|
assert "_shim_supports_ngl" in body
|
|
|
|
|
|
def test_zero_layers_masks_the_child_devices(llama_cpp):
|
|
"""gpu_layers=0 must CUDA-mask the child, else _gpu_offload_active=False lies to the
|
|
training VRAM coordinator and a GPU-resident runner survives into a training run.
|
|
Behavioural, not a source-text match: what matters is the token the child gets."""
|
|
arg = llama_cpp.LlamaCppBackend._diffusion_gpu_arg
|
|
assert arg([3, 1], force_cpu = True) == ""
|
|
assert arg(None, force_cpu = True) == ""
|
|
|
|
|
|
def test_explicit_pick_still_wins_when_layers_are_not_zero(llama_cpp):
|
|
"""force_cpu is the only thing above the picker. A host whose GPU torch cannot see
|
|
(Metal, Vulkan, Windows-HIP, Intel XPU) still has to honour an explicit pick."""
|
|
arg = llama_cpp.LlamaCppBackend._diffusion_gpu_arg
|
|
assert arg([3, 1], cpu_only = True) == "1"
|
|
assert arg([3, 1]) == "1"
|
|
|
|
|
|
def test_no_gpu_and_no_pick_masks_the_child(llama_cpp):
|
|
assert llama_cpp.LlamaCppBackend._diffusion_gpu_arg(None, cpu_only = True) == ""
|
|
|
|
|
|
def test_diffusion_load_passes_the_users_split_through():
|
|
call = next(
|
|
node
|
|
for node in ast.walk(_function("load_model"))
|
|
if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "_start_diffusion_server"
|
|
)
|
|
keywords = {keyword.arg: keyword.value for keyword in call.keywords}
|
|
for name in ("gpu_memory_mode", "gpu_layers"):
|
|
assert isinstance(keywords.get(name), ast.Name)
|
|
assert keywords[name].id == name
|
|
|
|
|
|
def test_diffusion_no_longer_hardcodes_auto_over_the_users_choice():
|
|
body = _body("_start_diffusion_server")
|
|
assert 'self._gpu_memory_mode = "auto"' not in body
|
|
assert "self._gpu_layers = -1" not in body
|
|
|
|
|
|
# ── dedup guards must see a split change ──
|
|
|
|
|
|
def _loaded_diffusion(llama_cpp, *, recorded_layers, requested_ngl):
|
|
"""A backend that looks like a healthy diffusion runner, for the dedup guards."""
|
|
b = llama_cpp.LlamaCppBackend()
|
|
b._process, b._healthy, b._is_diffusion = object(), True, True
|
|
b._model_identifier = "unsloth/DiffusionGemma-GGUF"
|
|
b._hf_variant = b._gguf_path = b._cache_type_kv = None
|
|
b._requested_n_ctx = 4096
|
|
b._tensor_parallel = b._layer_preserves_tensor_intent = False
|
|
b._gpu_layers = recorded_layers
|
|
b._gpu_memory_mode = "auto" if recorded_layers < 0 else "manual"
|
|
b._diffusion_requested_ngl = requested_ngl
|
|
b._gpu_ids = b._requested_gpu_ids = [0]
|
|
b._requested_spec_mode = "auto"
|
|
b._spec_fallback_reason = b._speculative_type = b._spec_draft_n_max = None
|
|
b._chat_template_override = b._mtp_draft_path = b._extra_args = None
|
|
# Dropped-split rows model "the shim stayed old"; the upgrade flip is separate.
|
|
b.diffusion_split_supported = lambda: False
|
|
return b
|
|
|
|
|
|
def _in_target_state(llama_cpp, b, *, mode, layers):
|
|
return b.adopt_load_intent_if_matched(
|
|
llama_cpp.GgufLoadIntent(
|
|
model_identifier = "unsloth/DiffusionGemma-GGUF",
|
|
n_ctx = 4096,
|
|
gpu_memory_mode = mode,
|
|
gpu_layers = layers,
|
|
gpu_ids = [0],
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("recorded", "requested_ngl", "mode", "layers", "expected"),
|
|
[
|
|
(-1, None, "auto", -1, True), # auto -> auto
|
|
(-1, None, "manual", -1, True), # inert manual preference must not loop
|
|
(-1, None, "manual", 8, False), # a real split must reload
|
|
(8, 8, "manual", 8, True), # same split dedupes
|
|
(8, 8, "manual", 4, False), # a split change reloads
|
|
(8, 8, "auto", -1, False), # manual -> auto reloads
|
|
(0, 0, "manual", 0, True), # CPU-only split dedupes with itself
|
|
# No --ngl: -1 runs but 20 was the ask; comparing on the ask stops a reload loop.
|
|
(-1, 20, "manual", 20, True),
|
|
(-1, 20, "manual", 8, False),
|
|
],
|
|
)
|
|
def test_backend_dedup_compares_the_requested_split(
|
|
llama_cpp, recorded, requested_ngl, mode, layers, expected
|
|
):
|
|
b = _loaded_diffusion(llama_cpp, recorded_layers = recorded, requested_ngl = requested_ngl)
|
|
assert _in_target_state(llama_cpp, b, mode = mode, layers = layers) is expected
|
|
|
|
|
|
def test_the_dedupe_compares_the_requested_split_through_the_paravirtual_rewrite():
|
|
"""The single comparator now lives on the backend, so the diffusion split has to be
|
|
judged on the normalized intent: a virtualised Metal device launches the CPU-pinned
|
|
rewrite, and comparing the raw ask against it would reload a healthy server forever."""
|
|
bodies = {
|
|
node.name: (ast.get_source_segment(SRC, node) or "")
|
|
for node in ast.walk(TREE)
|
|
if isinstance(node, ast.FunctionDef)
|
|
and node.name in ("adopt_load_intent_if_matched", "_runtime_matches_intent")
|
|
}
|
|
adopt = bodies["adopt_load_intent_if_matched"]
|
|
assert "_metal_device_is_paravirtual()" in adopt
|
|
assert "paravirtual_normalized_request(" in adopt
|
|
# Normalized before the runtime comparison reads it, or the rewrite changes nothing.
|
|
assert adopt.index("paravirtual_normalized_request(") < adopt.index("_runtime_matches_intent(")
|
|
runtime = bodies["_runtime_matches_intent"]
|
|
assert "_diffusion_manual_ngl(intent.gpu_memory_mode, intent.gpu_layers)" in runtime
|
|
assert "self.diffusion_requested_ngl" in runtime
|
|
|
|
|
|
def test_requested_split_survives_a_shim_without_the_flag(llama_cpp):
|
|
"""gpu_layers reports what is running; diffusion_requested_ngl reports the ask."""
|
|
b = _loaded_diffusion(llama_cpp, recorded_layers = -1, requested_ngl = 20)
|
|
assert b.gpu_layers == -1
|
|
assert b.diffusion_requested_ngl == 20
|
|
|
|
|
|
# ── the capability probe must read code, not prose ──
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "expected"),
|
|
[
|
|
('ap.add_argument("--ngl", type=int)', True),
|
|
("ap.add_argument('--ngl', type=int)", True), # quoting must not matter
|
|
('# someday: support "--ngl"', False), # a comment is not support
|
|
('"""usage: --ngl N"""', False), # nor is a docstring
|
|
('ap.add_argument("--maxtok", type=int)', False),
|
|
],
|
|
)
|
|
def test_probe_reads_declarations_not_substrings(llama_cpp, tmp_path, source, expected):
|
|
shim = tmp_path / "shim.py"
|
|
shim.write_text(source + "\n", encoding = "utf-8")
|
|
assert llama_cpp._shim_supports_ngl(["python", str(shim)]) is expected
|
|
|
|
|
|
def test_probe_accepts_an_uppercase_extension(llama_cpp, tmp_path):
|
|
"""A Windows UNSLOTH_DG_SHIM override may be SHIM.PY; it must still be the file read."""
|
|
shim = tmp_path / "SHIM.PY"
|
|
shim.write_text('ap.add_argument("--ngl", type=int)\n', encoding = "utf-8")
|
|
assert llama_cpp._shim_supports_ngl(["python", str(shim)]) is True
|
|
|
|
|
|
def test_probe_falls_back_to_a_substring_scan_on_unparseable_source(llama_cpp, tmp_path):
|
|
shim = tmp_path / "shim.py"
|
|
shim.write_text('ap.add_argument("--ngl"\n', encoding = "utf-8") # syntax error
|
|
assert llama_cpp._shim_supports_ngl(["python", str(shim)]) is True
|
|
|
|
|
|
# ── the probe must inspect the file that will be spawned, whatever its name ──
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["shim", "shim.pyw", "SHIM.PY"])
|
|
def test_probe_keys_on_argv_shape_not_suffix(llama_cpp, tmp_path, name):
|
|
"""Any UNSLOTH_DG_SHIM file launches as-is, so the probe must answer for that exact
|
|
file; an extensionless or .pyw override used to fall through to the package."""
|
|
shim = tmp_path / name
|
|
shim.write_text('ap.add_argument("--ngl", type=int)\n', encoding = "utf-8")
|
|
assert llama_cpp._shim_supports_ngl(["python", str(shim)]) is True
|
|
|
|
|
|
def test_probe_does_not_mistake_the_module_form_for_a_file(llama_cpp, monkeypatch):
|
|
"""[python, -m, unsloth_zoo.diffusion_studio.shim] carries a module name, not a
|
|
path; the probe must resolve the installed package, not stat the module string."""
|
|
import importlib.util as ilu
|
|
|
|
monkeypatch.setattr(ilu, "find_spec", lambda name: None)
|
|
cmd = ["python", "-m", "unsloth_zoo.diffusion_studio.shim"]
|
|
assert llama_cpp._shim_supports_ngl(cmd) is False # unresolvable -> conservative
|
|
|
|
|
|
# ── the guard must mirror what the launcher will actually do ──
|
|
|
|
|
|
def test_split_supported_mirrors_the_launch_gate(llama_cpp, tmp_path, monkeypatch):
|
|
b = llama_cpp.LlamaCppBackend()
|
|
shim = tmp_path / "shim.py"
|
|
|
|
shim.write_text('ap.add_argument("--ngl", type=int)\n', encoding = "utf-8")
|
|
monkeypatch.setattr(
|
|
b, "_find_diffusion_assets", lambda: (["python", str(shim)], "/bin/dg", None)
|
|
)
|
|
assert b.diffusion_split_supported() is True
|
|
|
|
shim.write_text('ap.add_argument("--maxtok", type=int)\n', encoding = "utf-8")
|
|
assert b.diffusion_split_supported() is False
|
|
|
|
monkeypatch.setattr(b, "_find_diffusion_assets", lambda: None)
|
|
assert b.diffusion_split_supported() is False # no runner -> no split
|
|
|
|
|
|
def test_training_guard_mirrors_shim_support():
|
|
"""The zero-layer bypass and the split-scaled estimate are only valid when the
|
|
launcher will actually emit --ngl; a dropped split runs GPU-resident."""
|
|
route_src = ROUTE_PATH.read_text(encoding = "utf-8")
|
|
route_tree = ast.parse(route_src)
|
|
fn = next(
|
|
n
|
|
for n in ast.walk(route_tree)
|
|
if isinstance(n, ast.FunctionDef) and n.name == "_guard_chat_load_against_training"
|
|
)
|
|
body = ast.get_source_segment(route_src, fn) or ""
|
|
assert "diffusion_split_supported" in body
|
|
assert body.index("diffusion_split_supported") < body.index("diffusion_ngl == 0")
|
|
assert "_scale_diffusion_required_gb" in body
|
|
|
|
|
|
# ── a positive split competes with its GPU share, not the whole file ──
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("required", "ngl", "n_layers", "expected"),
|
|
[
|
|
(15.0, 10, 30, 5.0), # a third of the layers -> a third of the footprint
|
|
(15.0, 30, 30, 15.0), # all layers -> unchanged
|
|
(15.0, 99, 30, 15.0), # over-ask clamps to all layers
|
|
(15.0, 10, None, 15.0), # unknown layer count stays conservative
|
|
(15.0, 10, 0, 15.0), # degenerate header value stays conservative
|
|
],
|
|
)
|
|
def test_positive_split_scales_the_guard_estimate(llama_cpp, required, ngl, n_layers, expected):
|
|
assert llama_cpp._scale_diffusion_required_gb(required, ngl, n_layers) == pytest.approx(
|
|
expected
|
|
)
|
|
|
|
|
|
# ── a custom-named override answers for itself, not a sibling shim.py ──
|
|
|
|
|
|
def test_probe_ignores_a_sibling_shim_next_to_a_custom_override(llama_cpp, tmp_path):
|
|
"""An override runs as-is; a capable sibling shim.py must not vouch for it, or the
|
|
launch appends --ngl to a parser that exits on it."""
|
|
override = tmp_path / "my_shim"
|
|
override.write_text('ap.add_argument("--maxtok", type=int)\n', encoding = "utf-8")
|
|
sibling = tmp_path / "shim.py"
|
|
sibling.write_text('ap.add_argument("--ngl", type=int)\n', encoding = "utf-8")
|
|
assert llama_cpp._shim_supports_ngl(["python", str(override)]) is False
|
|
|
|
|
|
# ── a zoo upgrade mid-session must un-stick a dropped split ──
|
|
|
|
|
|
def test_zoo_upgrade_reloads_a_dropped_split(llama_cpp):
|
|
"""manual/20 against an old shim launched with the default and deduped on the
|
|
ask. Once the shim gains --ngl, the identical ask must reload to apply it."""
|
|
b = _loaded_diffusion(llama_cpp, recorded_layers = -1, requested_ngl = 20)
|
|
assert _in_target_state(llama_cpp, b, mode = "manual", layers = 20) is True # shim still old
|
|
b.diffusion_split_supported = lambda: True # zoo upgraded in this session
|
|
assert _in_target_state(llama_cpp, b, mode = "manual", layers = 20) is False # now applies
|
|
b2 = _loaded_diffusion(llama_cpp, recorded_layers = 20, requested_ngl = 20)
|
|
b2.diffusion_split_supported = lambda: True
|
|
assert _in_target_state(llama_cpp, b2, mode = "manual", layers = 20) is True # applied: rest
|
|
|
|
|
|
# ── the dropped split must reach the client ──
|
|
|
|
|
|
def test_response_models_expose_the_requested_split():
|
|
"""A refresh has no in-memory split left, so the wire has to carry the ask."""
|
|
models_src = (REPO_ROOT / "studio" / "backend" / "models" / "inference.py").read_text(
|
|
encoding = "utf-8"
|
|
)
|
|
tree = ast.parse(models_src)
|
|
runtime = next(
|
|
n
|
|
for n in ast.walk(tree)
|
|
if isinstance(n, ast.ClassDef) and n.name == "_InferenceRuntimeFields"
|
|
)
|
|
fields = {
|
|
node.target.id
|
|
for node in runtime.body
|
|
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name)
|
|
}
|
|
assert "diffusion_requested_ngl" in fields
|
|
for name in ("LoadResponse", "InferenceStatusResponse"):
|
|
cls = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef) and n.name == name)
|
|
assert any(isinstance(base, ast.Name) and base.id == runtime.name for base in cls.bases)
|