From 3b1c1417bec816bcbfe9a6219d0429f2bf942b3a Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 14:32:48 +0000 Subject: [PATCH 001/110] feat(studio): GGUF host-residency memory mode + Vulkan gpu_ids pinning Narrowed to complement #6414 (merged), which already provides the GGUF GPU device picker, gpu_memory_mode (auto/manual), gpu_layers, n_cpu_moe, and tensor_split. This adds only what #6414 lacks: 1. Host-residency `gguf_memory_mode` (auto/pinned/resident) mapping to llama.cpp --mlock / --no-mmap. Distinct from #6414's gpu_memory_mode (that controls VRAM/layer offload; this controls host-RAM residency). Default auto is a no-op: an omitted value launches identical argv/env to main. First-class field shadows and scrubs inherited --mmap/--no-mmap/--mlock and LLAMA_ARG_MLOCK/NO_MMAP/MMAP so a stale value can't leak. Backend/API-driven (mirrored from /status), no new UI control. 2. Vulkan-ordinal gpu_ids hardening. #6414 rejects gpu_ids on a Vulkan build with HTTP 400; this replaces that with real pinning: gpu_ids are treated as ggml Vulkan ordinals (matched against the probe directly, never remapped through CUDA_VISIBLE_DEVICES), pinned via --device Vulkan, with conflicting user --device / inherited LLAMA_ARG_DEVICE stripped under a pin, and the training coexistence guard budgeting conservatively against the least-free card (ordinals can't map to physical free-VRAM). Backend: gguf_memory_mode field + validator (LoadRequest / ValidateModelRequest, echoed on responses); _memory_mode_flags / _canonical_memory_mode; strip_memory_mode + strip_device in llama_server_args; is_vulkan_build / assert_requested_gpu_ids_resolvable; memory-mode reload-dedup in _already_in_target_state and the route matcher (device-stripped under a pin, mirroring the launch); gpu_ids_are_vulkan_ordinals budgeting in training_vram. Frontend: a read-only activeMemoryMode mirror wired into the existing reload flow (no dropdown). #6414's gpu_ids picker is untouched. --- studio/backend/core/inference/llama_cpp.py | 392 +++++- .../core/inference/llama_server_args.py | 29 +- studio/backend/models/inference.py | 45 + studio/backend/routes/inference.py | 240 +++- studio/backend/routes/training_vram.py | 24 +- .../tests/test_chat_load_during_training.py | 284 ++++- .../tests/test_inference_model_validation.py | 30 + .../tests/test_llama_cpp_memory_mode.py | 1127 +++++++++++++++++ .../backend/tests/test_llama_server_args.py | 88 ++ studio/backend/tests/test_mtp_vram_budget.py | 47 + .../tests/test_tp_vision_regression.py | 122 ++ .../src/features/chat/api/chat-api.ts | 3 + .../chat/hooks/use-chat-model-runtime.ts | 19 + .../lib/apply-inference-status-to-store.ts | 8 + .../chat/stores/chat-runtime-store.ts | 11 + .../frontend/src/features/chat/types/api.ts | 10 +- 16 files changed, 2355 insertions(+), 124 deletions(-) create mode 100644 studio/backend/tests/test_llama_cpp_memory_mode.py diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 147174451e..1cd5054aae 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1752,6 +1752,34 @@ def _extra_args_draft_offloaded_to_cpu( return False +def _extra_args_draft_device_pin(extra_args: Optional[Iterable[str]]) -> Optional[str]: + """Return the drafter's explicit device pin from user extras when it names a + real GPU device (not cpu/none), else None. Parses the same draft-device flags + as _extra_args_draft_offloaded_to_cpu (--spec-draft-device / -devd / + --device-draft), last-wins, inline (=) or next-token, comma-separated. + + A separate MTP drafter normally follows the main -ngl / device selection, so + under an explicit gpu_ids pin it lands on the pinned cards the training guard + budgeted. A draft-device naming a different GPU escapes that pin and can place + the drafter on a card the guard never reserved (#7188). cpu/none is a + supported offload (keeps the drafter off the GPU entirely), so it does not + conflict and returns None.""" + dev_flags = {"--spec-draft-device", "-devd", "--device-draft"} + args = [str(a) for a in extra_args] if extra_args else [] + last_dev: Optional[str] = None + for i, raw in enumerate(args): + flag, eq, inline = raw.partition("=") + value = inline if eq else (args[i + 1] if i + 1 < len(args) else "") + if flag in dev_flags: + last_dev = value + if last_dev is None: + return None + devs = [d.strip() for d in last_dev.split(",") if d.strip()] + if not devs or all(d.lower() in ("cpu", "none") for d in devs): + return None + return last_dev + + def _extra_args_n_ubatch( extra_args: Optional[Iterable[str]], env: Optional[Mapping[str, str]] = None ) -> Optional[int]: @@ -2043,10 +2071,16 @@ class LlamaCppBackend: self._tensor_split: Optional[List[float]] = None # User-picked physical GPU indices (None = automatic selection). self._gpu_ids: Optional[List[int]] = None - # RAW requested GPU pin, before the fit narrowed it. self._gpu_ids records the - # EFFECTIVE (fit-narrowed) pin for /status; dedupe compares this raw value so a - # [0, 1] narrowed to [0] and re-sent as [0, 1] still matches (#7239). - self._requested_gpu_ids: Optional[List[int]] = None + # GGUF memory placement mode (None = auto; pinned/resident map to --mlock/--no-mmap). + self._memory_mode: Optional[str] = None + # Raw requested mode for the response echo. _memory_mode canonicalizes + # "auto" -> None, but the echo must keep an explicit "auto" so it round-trips + # instead of collapsing to null and letting LLAMA_ARG_MLOCK creep back (#7188). + self._requested_memory_mode: Optional[str] = None + # True when the child launched with no explicit memory_mode but inherited an + # LLAMA_ARG_MLOCK/NO_MMAP/MMAP env; lets an explicit 'auto' reload force the + # scrub the dedup would otherwise skip. + self._launched_with_inherited_mem_env: bool = False # Layer load kept multi-GPU only to honor a downgraded tensor request, so a # later explicit tensor-off reloads instead of deduping to it (#6659). self._layer_preserves_tensor_intent: bool = False @@ -2519,44 +2553,23 @@ class LlamaCppBackend: return self._gpu_ids @property - def requested_gpu_ids(self) -> Optional[List[int]]: - """RAW requested GPU pin (before the fit narrowed it), or None for auto. - gpu_ids echoes the EFFECTIVE pin for /status.""" - return self._requested_gpu_ids + def memory_mode(self) -> Optional[str]: + """GGUF memory placement mode of the active load (auto/pinned/resident). + Auto is canonicalised to None for dedup; use requested_memory_mode for the + original user-requested value.""" + return self._memory_mode - def matches_gpu_ids(self, gpu_ids: Optional[List[int]]) -> bool: - """Whether a requested pin is already satisfied by the active runner. + @property + def requested_memory_mode(self) -> Optional[str]: + """User's raw requested memory mode for the response echo. Distinguishes an + explicit "auto" (which _memory_mode canonicalizes to None) from omitted.""" + return self._requested_memory_mode - A regular GGUF load may narrow the requested placement pool to the - smallest fitting subset. Accept both the original request and the - effective status-echoed subset so either can round-trip without a - needless reload. Diffusion drives one device and keeps its existing - lowest-device normalization. - """ - if self._is_diffusion: - requested = [sorted(int(x) for x in gpu_ids)[0]] if gpu_ids else None - return requested == (self._gpu_ids or None) - - requested = sorted(int(x) for x in gpu_ids) if gpu_ids else None - raw = self._requested_gpu_ids or None - effective = self._gpu_ids or None - return requested == raw or requested == effective - - def _record_matching_gpu_request(self, gpu_ids: Optional[List[int]]) -> None: - """Adopt the caller's explicit pool after a full already-loaded match. - - Matching an effective subset avoids a reload, but the incoming request - is still the user's latest placement intent. Record it so status and a - later reload do not restore GPUs the user just removed. - """ - if self._is_diffusion: - self._requested_gpu_ids = [sorted(int(x) for x in gpu_ids)[0]] if gpu_ids else None - else: - self._requested_gpu_ids = sorted(int(x) for x in gpu_ids) if gpu_ids else None - if self._last_load_kwargs is not None: - self._last_load_kwargs["gpu_ids"] = ( - list(self._requested_gpu_ids) if self._requested_gpu_ids else None - ) + @property + def launched_with_inherited_mem_env(self) -> bool: + """True when the live child inherited operator LLAMA_ARG_MLOCK/NO_MMAP/MMAP env + because no memory_mode was applied; an explicit mode reload clears it.""" + return self._launched_with_inherited_mem_env @property def n_layers(self) -> Optional[int]: @@ -3342,6 +3355,169 @@ class LlamaCppBackend: return [] return ["--device", ",".join(f"Vulkan{i}" for i in gpu_indices)] + @staticmethod + def _backend_lacks_gpu_lib(binary: Optional[str] = None) -> bool: + """True only when the llama.cpp build clearly ships CPU-only ggml libs (a + ggml-cpu/base lib present but no cuda/hip/vulkan sibling), so an explicit gpu_ids + pin can't be honored: the child is steered only by CUDA_VISIBLE_DEVICES and a + CPU-only llama-server would ignore it and run on CPU while /load reports a + GPU-pinned success. Conservative -- an unrecognized layout (no lib dir, or no + ggml libs at all, e.g. a statically linked build) returns False so a valid custom + GPU build is never falsely rejected (#7188).""" + binary = binary or LlamaCppBackend._find_llama_server_binary() + if not binary: + return False + lib_dir = _llama_lib_dir(binary) + if not lib_dir or not lib_dir.is_dir(): + return False + + def _lib(name): + return f"ggml-{name}.dll" if sys.platform == "win32" else f"libggml-{name}.so" + + def _has_lib(name): + # Match the exact soname and versioned variants (e.g. libggml-cuda.so.0), + # as shipped by distro-packaged / split-lib llama.cpp builds (#7188). + stem = _lib(name) + try: + return any( + f.name == stem or f.name.startswith(stem + ".") + for f in lib_dir.iterdir() + if f.is_file() + ) + except OSError: + return False + + if any(_has_lib(b) for b in ("vulkan", "cuda", "hip")): + return False # a GPU ggml backend is present -> the pin can be honored + # No GPU lib: CPU-only only if a CPU/base ggml lib proves the split-lib layout (not a static build). + return any(_has_lib(b) for b in ("cpu", "base")) + + def has_gpu_backend(self) -> bool: + """True if a GPU probe finds any device: nvidia-smi/amd-smi (CUDA/ROCm) or a + Vulkan build's ggml enumeration. Lets the route reject gpu_ids on a GPU-less + host before teardown without falsely rejecting a torch-less Vulkan host (which + get_device() reports as CPU, the AMD/Vulkan case #7164 targets). On a probe + failure return True so the caller defers to the load path (#7188).""" + try: + if self._get_gpu_memory(self._find_llama_server_binary()): + return True + # A telemetry-down GPU still exposes a parent-visible mask, which the resolver + # validates against; treating an empty probe as "no backend" here would 400 a + # selection the resolver would have accepted (#7188). + from utils.hardware import get_parent_visible_gpu_ids + return bool(get_parent_visible_gpu_ids()) + except Exception: + return True + + def is_vulkan_build(self) -> bool: + """True if the resolved llama-server binary is a Vulkan build. The route uses this + to defer gpu_ids to the backend (which treats them as Vulkan ordinals) instead of + the CUDA physical-ID resolver even on a CUDA-visible host (#7188).""" + try: + return self._is_vulkan_backend(self._find_llama_server_binary()) + except Exception: + return False + + def assert_requested_gpu_ids_resolvable(self, gpu_ids: Optional[list[int]]) -> None: + """Raise ValueError if the requested gpu_ids can't be honored by the llama.cpp + backend. Self-contained (finds the binary + probes) so the route can reject a + bad selection BEFORE it unloads the active model; otherwise on non-CUDA hosts a + typo like gpu_ids=[99] tore the model down and 400'd only in load_model (#7188).""" + if not gpu_ids: + return + binary = self._find_llama_server_binary() + self._assert_gpu_ids_resolvable( + gpu_ids, + self._get_gpu_memory(binary), + self._is_vulkan_backend(binary), + self._backend_lacks_gpu_lib(binary), + ) + + @staticmethod + def _assert_gpu_ids_resolvable( + gpu_ids: Optional[list[int]], + gpu_mem: list[tuple[int, int, int]], + is_vulkan_backend: bool, + backend_lacks_gpu_lib: bool = False, + ) -> None: + """Raise ValueError if the requested gpu_ids cannot be honored on this host. + + Side-effect-free mirror of load_model's flag-builder checks, so Phase 0 can + reject a bad selection (out-of-range/duplicate id, or a GPU-less host) BEFORE + Phase 1 kills the running server (#7188). Returns without raising for the valid + 'real GPU, telemetry down' fall-through. The flag builder stays the backstop.""" + if not gpu_ids: + return + # A CPU-only build (no cuda/hip/vulkan ggml lib) ignores CUDA_VISIBLE_DEVICES, so + # it can't honor a pin: reject before teardown rather than run silently on CPU (#7188). + if backend_lacks_gpu_lib: + raise ValueError( + f"Requested gpu_ids {list(gpu_ids)} but the llama.cpp build has no GPU " + "backend (CPU-only build); it would ignore the pin and run on CPU. Omit " + "gpu_ids to run on CPU." + ) + # Duplicate/negative ids are invalid on every backend; enforce it here for the + # deferred GGUF path too, else a non-Vulkan probe collapses [0, 0] into {0} and + # pins one GPU while recording the duplicate (#7188). + _requested = list(gpu_ids) + if len(set(_requested)) != len(_requested) or any(g < 0 for g in _requested): + raise ValueError(f"Invalid gpu_ids {_requested}: IDs must be unique and non-negative.") + allowed = set(gpu_ids) + # Vulkan ordinals match the probe directly; never remap through the CUDA/HIP mask + # (Vulkan enumerates independently of CUDA_VISIBLE_DEVICES) (#7188). + matched = [g for g in gpu_mem if g[0] in allowed] + if gpu_mem: + # Every requested id must match: a partial hit like [0, 99] against [0] must be + # rejected, not narrowed to [0] (which places on fewer GPUs than asked) (#7188). + if len(matched) != len(allowed): + raise ValueError(f"Requested gpu_ids {list(gpu_ids)} do not match any visible GPUs") + elif is_vulkan_backend: + # Vulkan build with an empty probe: the ordinals can't be resolved. + raise ValueError(f"Requested gpu_ids {list(gpu_ids)} do not match any visible GPUs") + else: + from utils.hardware import DeviceType, get_device, get_parent_visible_gpu_ids + + # The CUDA/HIP mask only governs placement on a CUDA/ROCm build. A Metal/SYCL/CPU + # backend ignores CUDA_VISIBLE_DEVICES, so an empty probe on a non-CUDA host means + # the pin can't be honored: reject rather than drop onto the default device + # (#7188). ROCm reports CUDA here, so HIP hosts keep the mask path. + if get_device() != DeviceType.CUDA: + raise ValueError( + f"Requested gpu_ids {list(gpu_ids)} but this backend does not support " + "explicit GPU selection (no GPU probe on a non-CUDA device); omit " + "gpu_ids to run on the default device." + ) + parent_visible = get_parent_visible_gpu_ids() + if not parent_visible: + raise ValueError( + f"Requested gpu_ids {list(gpu_ids)} but no GPU backend is available " + "(no GPU telemetry and no visible GPU mask); omit gpu_ids to run on " + "CPU." + ) + _outside_mask = [g for g in gpu_ids if g not in parent_visible] + if _outside_mask: + raise ValueError( + f"Requested gpu_ids {list(gpu_ids)} do not match any visible GPUs " + f"{parent_visible}" + ) + # Valid (incl. the real-GPU / telemetry-down fall-through). + + @staticmethod + def _strip_device_extra_args(extra_args): + """Drop a user --device/-dev from extra_args so an explicit gpu_ids pin owns + placement. Used for the launched command AND the persisted extras, so a dropped + --device can't be resurrected on a later inheriting reload (#7188).""" + return strip_shadowing_flags( + extra_args, + strip_context = False, + strip_cache = False, + strip_spec = False, + strip_template = False, + strip_split_mode = False, + strip_memory_mode = False, + strip_device = True, + ) + @staticmethod def _get_gpu_free_memory(binary: Optional[str] = None) -> list[tuple[int, int]]: """Query free memory per GPU. Returns ``(gpu_index, free_mib)`` sorted by @@ -6267,6 +6443,33 @@ class LlamaCppBackend: ) self._stdout_thread.start() + @staticmethod + def _canonical_memory_mode(memory_mode: Optional[str]) -> Optional[str]: + """Normalize a memory_mode value so 'auto'/blank/None are equivalent. + + Returns None for the default (memory-mapped) placement, or the lowercase + canonical name 'pinned' / 'resident' for explicit modes. + """ + mode = (memory_mode or "").strip().lower() + if mode in ("", "auto"): + return None + return mode + + @staticmethod + def _memory_mode_flags(memory_mode: Optional[str]) -> List[str]: + """Return the llama-server flags for a GGUF memory placement mode. + + - "pinned" -> --mlock (lock mmap'd pages in RAM). + - "resident" -> --no-mmap --mlock (load fully into RAM and lock). + - otherwise -> [] (llama.cpp default, memory-mapped file). + """ + mode = LlamaCppBackend._canonical_memory_mode(memory_mode) + if mode == "pinned": + return ["--mlock"] + if mode == "resident": + return ["--no-mmap", "--mlock"] + return [] + @_with_gguf_load_marker def load_model( self, @@ -6297,6 +6500,7 @@ class LlamaCppBackend: # Explicit GPU placement pool (issue #7164). None/[] = auto-select; # the fitter may pin the smallest subset of this pool that fits. gpu_ids: Optional[List[int]] = None, + memory_mode: Optional[str] = None, n_threads: Optional[int] = None, n_gpu_layers: Optional[int] = None, # caller compat, unused n_parallel: int = 1, @@ -6312,6 +6516,21 @@ class LlamaCppBackend: Returns True if the server started and the health check passed. """ + # When a mode is set, Unsloth's --mlock/--no-mmap (or their absence for auto) wins, + # so strip conflicting --mmap/--no-mmap/--mlock from extra_args; else last-wins + # parsing could memory-map the child while Unsloth stored 'resident'. The route + # strips this too; repeating it here covers direct load_model calls (idempotent). + # No mode = no opinion, so user flags are left untouched. + if memory_mode is not None and extra_args: + extra_args = strip_shadowing_flags( + extra_args, + strip_context = False, + strip_cache = False, + strip_spec = False, + strip_template = False, + strip_split_mode = False, + strip_memory_mode = True, + ) # Raw load inputs so the runtime MTP-crash reload can replay this model # without MTP. Committed to _last_load_kwargs only on a healthy load. _pending_load_kwargs = { @@ -6337,6 +6556,7 @@ class LlamaCppBackend: "n_cpu_moe": n_cpu_moe, "tensor_split": list(tensor_split) if tensor_split is not None else None, "gpu_ids": list(gpu_ids) if gpu_ids is not None else None, + "memory_mode": memory_mode, "n_threads": n_threads, "n_gpu_layers": n_gpu_layers, "n_parallel": n_parallel, @@ -6368,6 +6588,7 @@ class LlamaCppBackend: n_cpu_moe = n_cpu_moe, tensor_split = tensor_split, gpu_ids = gpu_ids, + memory_mode = memory_mode, chat_template_override = chat_template_override, extra_args = extra_args, is_vision = is_vision, @@ -6536,6 +6757,12 @@ class LlamaCppBackend: # Not a tensor/layer GGUF: clear any preserved-fallback flag from a # prior load (this path skips the command builder that clears it). self._layer_preserves_tensor_intent = False + # This path skips the command builder that records host-memory placement, + # so clear any mode carried from a prior non-diffusion load (the diffusion + # runner has no --mlock/--no-mmap plumbing) (#7188). + self._memory_mode = None + self._requested_memory_mode = None + self._launched_with_inherited_mem_env = False with self._lock: if self._cancel_event.is_set(): logger.info("Load cancelled before diffusion server start") @@ -7691,6 +7918,11 @@ class LlamaCppBackend: elif not auto_fit: cmd.extend(["-c", "0"]) + # Memory placement: keep weights resident so idle weights aren't paged + # out and re-faulted from disk (#7164). Emitted as a CMD flag (not env) + # so it side-steps _clear_manual_placement_env in manual mode. + cmd.extend(self._memory_mode_flags(memory_mode)) + # Report a clean public model id (matching GET /v1/models) rather # than the raw -m path in llama-server's own /v1/models and the # "model" field of its chat/completions responses. @@ -8037,8 +8269,22 @@ class LlamaCppBackend: # lets the user override Unsloth's auto-set flags. Already # validated by the route via validate_extra_args(). if extra_args: - cmd.extend(str(a) for a in extra_args) - logger.info(f"Appending user extra args to llama-server: {list(extra_args)}") + _emit_extra_args = list(extra_args) + if gpu_ids is not None: + # gpu_ids is authoritative: drop a user --device/-dev so it can't + # override the pin and offload to a guard-unaccounted GPU (#7188). + # On Vulkan --device is the only pin; on CUDA/ROCm it keeps + # backend.gpu_ids honest. Other extras pass through. + _emit_extra_args = self._strip_device_extra_args(extra_args) + if _emit_extra_args != list(extra_args): + logger.info( + "Dropped a user --device/-dev from extra args: explicit " + "gpu_ids owns device placement." + ) + cmd.extend(str(a) for a in _emit_extra_args) + logger.info( + f"Appending user extra args to llama-server: {list(_emit_extra_args)}" + ) logger.info(f"Starting llama-server: {' '.join(self._redacted_cmd_for_log(cmd))}") @@ -8052,6 +8298,22 @@ class LlamaCppBackend: if "--threads" not in cmd: env.pop("LLAMA_ARG_THREADS", None) + # Unsloth's --mlock/--no-mmap (or their absence for auto) must win. llama-server + # honors LLAMA_ARG_MLOCK/NO_MMAP/MMAP only where Unsloth emits no flag, so an + # inherited env could mlock an explicit 'auto' or turn 'pinned' into 'resident'. + # Scrub only when a mode is set, so operator env vars keep the pre-PR + # inheritance otherwise (backwards compatible). Record whether the child + # launched WITH inherited placement flags so a later explicit 'auto' reloads + # to clear them (the dedup treats 'auto' and None alike and would otherwise + # leave it mlocked). Mirrors the threads / split / cache scrubs. + _mm_vars = ("LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_MMAP") + if memory_mode is not None: + for _mm_var in _mm_vars: + env.pop(_mm_var, None) + _child_inherited_mem_env = False + else: + _child_inherited_mem_env = any(_v in env for _v in _mm_vars) + # Reconcile the inherited LLAMA_ARG_* env with Unsloth's final # decision: stripping CLI extras on a tensor->layer downgrade # can't remove env vars, so the child could run a mode/KV Unsloth @@ -8077,6 +8339,13 @@ class LlamaCppBackend: if _ct_raw and _ct_raw not in self._TENSOR_PARALLEL_KV_TYPES: env.pop(_ct_var, None) + # Under an explicit gpu_ids pin, scrub inherited LLAMA_ARG_DEVICE (env form + # of --device): on the CUDA/ROCm path Unsloth emits no --device flag, so it + # would otherwise override the pin and steer offload off the pinned cards + # (or to 'none' -> CPU). Without a pin, inheritance is left intact. + if gpu_ids is not None: + env.pop("LLAMA_ARG_DEVICE", None) + # Windows + full offload: PASSIVE OMP + 2 threads stop # spin-wait burning CPU. CPU/partial offload keeps default # OMP parallelism. #5692. @@ -8553,9 +8822,21 @@ class LlamaCppBackend: # clears, list sets. Source records hf_variant for the route's # same_source check. if extra_args is not None: - self._extra_args = list(extra_args) + # Persist the same device-stripped extras the command used when gpu_ids + # owns placement, so a later inheriting reload (after gpu_ids clears) + # can't resurrect the dropped --device (#7188). + self._extra_args = ( + self._strip_device_extra_args(extra_args) + if gpu_ids is not None + else list(extra_args) + ) self._extra_args_source = (model_identifier, hf_variant) self._requested_n_ctx = int(n_ctx) + self._memory_mode = LlamaCppBackend._canonical_memory_mode(memory_mode) + # Raw requested mode for the response echo, so an explicit "auto" + # round-trips instead of collapsing to null (#7188). + self._requested_memory_mode = (memory_mode or "").strip().lower() or None + self._launched_with_inherited_mem_env = _child_inherited_mem_env # Commit the known-good snapshot + whether MTP+tensor is live, then # watch this load for a mid-generation crash. self._last_load_kwargs = _pending_load_kwargs @@ -8967,6 +9248,7 @@ class LlamaCppBackend: n_cpu_moe: int = 0, tensor_split: Optional[List[float]] = None, gpu_ids: Optional[List[int]] = None, + memory_mode: Optional[str] = None, mtp_draft_path: Optional[str] = None, preserve_multi_gpu_on_layer: bool = False, ) -> bool: @@ -9049,6 +9331,17 @@ class LlamaCppBackend: if not self.matches_gpu_ids(gpu_ids): return False + # GGUF host-memory placement mode is first-class; a change must reload (#7164). + if LlamaCppBackend._canonical_memory_mode( + self._memory_mode + ) != LlamaCppBackend._canonical_memory_mode(memory_mode): + return False + # An explicit memory_mode (incl. 'auto') over a child carrying inherited LLAMA_ARG_* + # flags must reload so the scrub runs; the canonical check above treats 'auto' as + # omitted and would otherwise leave the child mlocked/no-mmap (#7164). + if memory_mode is not None and self._launched_with_inherited_mem_env: + return False + # Compare on the canonical requested mode. With --spec-type in # extra_args the backend stores None; mirror that here. if _extra_args_set_spec_type(extra_args): @@ -9102,7 +9395,14 @@ class LlamaCppBackend: # layer); only an explicit list forces equality. if extra_args is not None: current = list(self._extra_args) if self._extra_args is not None else [] - if list(extra_args) != current: + candidate = list(extra_args) + # Under a gpu_ids pin, load_model stored the device-stripped extras + # (the pin wins over a user --device), so strip the request the same + # way before comparing -- else a raced duplicate /load carrying + # --device needlessly reloads (mirrors the route matcher). + if gpu_ids is not None: + candidate = self._strip_device_extra_args(candidate) + if candidate != current: return False self._record_matching_gpu_request(gpu_ids) return True @@ -9245,6 +9545,10 @@ class LlamaCppBackend: self._gpu_layers = -1 self._n_cpu_moe = 0 self._tensor_split = None + self._gpu_ids = None + self._memory_mode = None + self._requested_memory_mode = None + self._launched_with_inherited_mem_env = False self._layer_preserves_tensor_intent = False self._speculative_type = None self._requested_spec_mode = None diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index 7b42d2f40d..ace910df4e 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -177,6 +177,9 @@ _TEMPLATE_FLAGS: frozenset[str] = frozenset( "--no-jinja", } ) +# Shadow the GGUF memory_mode field: pass-through in explicit extras, stripped on +# inherit when the mode changes. --mmap is the inverse of --no-mmap. +_MEMORY_MODE_FLAGS: frozenset[str] = frozenset({"--mlock", "--no-mmap", "--mmap"}) # Multi-GPU split mode shadows the Tensor Parallelism toggle # (--split-mode tensor). Pass-through stays allowed so users keep the # row/none/layer modes the toggle doesn't expose, but it's stripped on @@ -187,6 +190,10 @@ _TEMPLATE_FLAGS: frozenset[str] = frozenset( _SPLIT_MODE_FLAGS: frozenset[str] = frozenset({"-sm", "--split-mode"}) _TENSOR_SPLIT_FLAGS: frozenset[str] = frozenset({"-ts", "--tensor-split"}) _SPLIT_SHADOWING_FLAGS: frozenset[str] = _SPLIT_MODE_FLAGS | _TENSOR_SPLIT_FLAGS +# llama.cpp offload device list (--device / -dev). Opt-in (users may pass it under +# auto-select): stripped only when gpu_ids is set, so it can't override the pin and +# offload to a GPU the training guard never budgeted (#7188). +_DEVICE_FLAGS: frozenset[str] = frozenset({"--device", "-dev"}) # GPU-offload flags. Stripped only when the GPU Memory mode owns offload # (manual emits --fit / --gpu-layers / --n-cpu-moe); in auto, a user's @@ -200,12 +207,17 @@ _MOE_OFFLOAD_FLAGS: frozenset[str] = frozenset({"-ncmoe", "--n-cpu-moe", "-cmoe" _OFFLOAD_SHADOWING_FLAGS: frozenset[str] = _LAYER_OFFLOAD_FLAGS | _MOE_OFFLOAD_FLAGS _SHADOWING_FLAGS: frozenset[str] = ( - _CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS | _SPLIT_SHADOWING_FLAGS + _CONTEXT_FLAGS + | _CACHE_FLAGS + | _SPEC_FLAGS + | _TEMPLATE_FLAGS + | _SPLIT_SHADOWING_FLAGS + | _MEMORY_MODE_FLAGS ) # Shadowing flags that take no value -- strip the flag only, not the next token. _BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset( - {"--spec-default", "--jinja", "--no-jinja", "-cmoe", "--cpu-moe"} + {"--spec-default", "--jinja", "--no-jinja", "-cmoe", "--cpu-moe", "--mlock", "--no-mmap", "--mmap"} ) @@ -441,12 +453,14 @@ def strip_shadowing_flags( strip_split_mode: bool = True, strip_tensor_split: bool = False, strip_offload: bool = False, + strip_memory_mode: bool = True, + strip_device: bool = False, ) -> list[str]: """Strip flags that shadow first-class Unsloth settings. Used when inheriting a previous load's ``llama_extra_args`` so an inherited `-c 4096` can't override the current `max_seq_length` - (same for cache / spec / template / split-mode). Each ``strip_*`` + (same for cache / spec / template / split-mode / memory_mode). Each ``strip_*`` toggle controls one group; the route only strips groups whose first-class field the caller actually supplied. @@ -454,7 +468,9 @@ def strip_shadowing_flags( ``--tensor-split`` (the Tensor Parallelism toggle owns the whole split). ``strip_tensor_split`` removes ``--tensor-split`` *alone*, so manual mode can replace an inherited per-GPU ratio while leaving the user's ``--split-mode`` - row/none/layer choice intact. + row/none/layer choice intact. ``strip_device`` is off by default (users may + pass ``--device`` when Unsloth auto-selects) and enabled only for explicit + gpu_ids. """ shadowing: set[str] = set() if strip_context: @@ -471,6 +487,10 @@ def strip_shadowing_flags( shadowing |= _TENSOR_SPLIT_FLAGS if strip_offload: shadowing |= _OFFLOAD_SHADOWING_FLAGS + if strip_memory_mode: + shadowing |= _MEMORY_MODE_FLAGS + if strip_device: + shadowing |= _DEVICE_FLAGS tokens = [str(a) for a in (args or [])] out: list[str] = [] @@ -507,4 +527,5 @@ def strip_split_mode_only(args: Optional[Iterable[str]]) -> Optional[list[str]]: strip_spec = False, strip_template = False, strip_split_mode = True, + strip_memory_mode = False, ) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 1758efe515..c800b7f8c0 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -182,6 +182,29 @@ class LoadRequest(BaseModel): raise ValueError("tensor_split must have a positive total") return value + gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + None, + description = ( + "GGUF host-memory placement mode (llama.cpp --mlock/--no-mmap). These " + "control system RAM residency and file mapping on the host, NOT GPU VRAM " + "placement, so they do not by themselves keep offloaded weights pinned in " + "VRAM. 'auto' (default) uses llama.cpp's normal memory-mapped loading. " + "'pinned' adds --mlock so the OS cannot page the model's host pages out. " + "'resident' loads the full model into RAM with --no-mmap and locks it with " + "--mlock, avoiding any file-backed mapping. Ignored for non-GGUF models." + ), + ) + + @field_validator("gguf_memory_mode", mode = "before") + @classmethod + def normalize_blank_gguf_memory_mode(cls, value: Any) -> Any: + # Map a form's blank default to explicit "auto" (not None) so it counts as a + # choice: the scrub of inherited LLAMA_ARG_MLOCK/NO_MMAP/MMAP only runs when the + # value is not None, so blank -> None would let those env vars survive (#7164). + if isinstance(value, str) and value.strip() == "": + return "auto" + return value + llama_extra_args: Optional[List[str]] = Field( None, description = ( @@ -249,6 +272,20 @@ class ValidateModelRequest(BaseModel): "delegate fitting to llama.cpp, while explicit layers are user-owned." ), ) + gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + None, + description = "Intended GGUF memory placement mode; mirrors /load so validate's sizing agrees with the follow-up load.", + ) + + @field_validator("gguf_memory_mode", mode = "before") + @classmethod + def normalize_blank_gguf_memory_mode(cls, value: Any) -> Any: + # Mirror LoadRequest: blank maps to explicit "auto" so validate and load agree + # and the inherited-env scrub isn't skipped (and it avoids a 422) (#7164). + if isinstance(value, str) and value.strip() == "": + return "auto" + return value + include_context_length: bool = Field( False, description = "Also read the native context length from the local GGUF header. " @@ -509,6 +546,10 @@ class LoadResponse(BaseModel): "or None for automatic selection." ), ) + gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + None, + description = "Active GGUF memory placement mode. Only meaningful when is_gguf is True.", + ) class UnloadResponse(BaseModel): @@ -684,6 +725,10 @@ class InferenceStatusResponse(BaseModel): "or None for automatic selection." ), ) + gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + None, + description = "Active GGUF memory placement mode. Only meaningful when is_gguf is True.", + ) llama_cpp_supports_mtp: bool = Field( True, description = ( diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9d40650a56..26c25fe39f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3216,6 +3216,10 @@ def _request_matches_loaded_settings( # stored extras stripped the way the reload strips them, so an extras-driven # tensor load isn't seen as a mismatch that needlessly reloads the server. backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else [] + # Strip inherited --mlock/--no-mmap/--mmap only for a real memory mode. Pydantic marks + # the field "set" even for an explicit null, but null == "no opinion" (placement left + # alone), so stripping then would needlessly reload. Gate on the value (#7188). + _strip_mem = request.gguf_memory_mode is not None effective_extra = ( request.llama_extra_args if request.llama_extra_args is not None @@ -3224,6 +3228,7 @@ def _request_matches_loaded_settings( strip_split_mode = _should_strip_split_mode(request, backend_extra), strip_tensor_split = _should_strip_tensor_split(request), strip_offload = request.gpu_memory_mode == "manual", + strip_memory_mode = _strip_mem, ) ) if not _tensor_parallel_matches_loaded( @@ -3255,6 +3260,16 @@ def _request_matches_loaded_settings( # its single-device normalization. if not llama_backend.matches_gpu_ids(request.gpu_ids): return False + # GGUF host-memory placement mode is first-class; any change must reload (#7164). + if LlamaCppBackend._canonical_memory_mode( + request.gguf_memory_mode + ) != LlamaCppBackend._canonical_memory_mode(llama_backend.memory_mode): + return False + # An explicit memory_mode (incl. 'auto') over a child with inherited LLAMA_ARG_* flags + # must reload so the scrub runs; the canonical check above treats 'auto' as omitted and + # would leave the child mlocked/no-mmap (#7164). + if request.gguf_memory_mode is not None and llama_backend.launched_with_inherited_mem_env: + return False # Preserved tensor->layer fallback (both report tensor=off, so the check above # matches): if the user now explicitly drops tensor intent, reload so placement # re-selects instead of keeping the all-GPU mask (#6659). The effective check @@ -3308,12 +3323,38 @@ def _request_matches_loaded_settings( strip_split_mode = _should_strip_split_mode(request, backend_extra), strip_tensor_split = _should_strip_tensor_split(request), strip_offload = request.gpu_memory_mode == "manual", + strip_memory_mode = _strip_mem, ) != backend_extra ): return False else: - if list(request.llama_extra_args) != backend_extra: + # Strip memory-placement flags request-side only for a real mode (mirrors the + # reload's strip of explicit extras), so a request repeating a --mlock/--mmap/--no-mmap + # the loaded server already dropped still hits this fast path instead of the guard. + # The backend side is NOT stripped: a server loaded with no memory_mode keeps a + # pass-through --mlock/--no-mmap and is still mlocked; keeping it forces a reload so + # the auto scrub runs (#7164). Explicit gpu_ids drop a user --device from the launched + # and stored extras, so strip it request-side too or an identical reload would miss + # this fast path and force a needless reload / training 409 (#7188). Gate on an + # EFFECTIVE pin: the load path normalizes gpu_ids=[] to None and keeps its --device, + # so an empty list must NOT strip here or the two sides diverge. + _strip_dev = bool(request.gpu_ids) + _request_extra = ( + strip_shadowing_flags( + request.llama_extra_args, + strip_context = False, + strip_cache = False, + strip_spec = False, + strip_template = False, + strip_split_mode = False, + strip_memory_mode = _strip_mem, + strip_device = _strip_dev, + ) + if (_strip_mem or _strip_dev) + else list(request.llama_extra_args) + ) + if _request_extra != backend_extra: return False # A separate drafter (Gemma's root mtp-*.gguf) appearing or disappearing # next to the loaded weights changes the launch command (--model-draft), @@ -4004,6 +4045,26 @@ async def _resolve_gguf_gpu_ids_for_request( return resolved +def _reject_diffusion_memory_mode(config: ModelConfig, memory_mode: Optional[str]) -> None: + """Raise 400 if a DiffusionGemma GGUF was given an explicit gguf_memory_mode. + + The diffusion runner is single-GPU (DG_GPU) with no --mlock/--no-mmap plumbing. + Called by BOTH /validate and /load before either tears down the active model, so + they agree and a placement /load will reject isn't validated post-unload (#7188). + gpu_ids for diffusion is handled by the runner (collapsed to a single device), so + only the host-memory mode is rejected here.""" + if LlamaCppBackend._canonical_memory_mode(memory_mode) is None: + return + if _classify_diffusion_gguf(config) is True: + raise HTTPException( + status_code = 400, + detail = ( + "Explicit gguf_memory_mode is not supported for diffusion " + "(DiffusionGemma) GGUF models." + ), + ) + + def _guard_chat_load_against_training( config: ModelConfig, *, @@ -4073,6 +4134,16 @@ def _guard_chat_load_against_training( else None ) + # Vulkan ordinals enumerate independently of the CUDA index space the guard budgets in; + # resolving them as CUDA physical IDs would size free VRAM on the wrong card. Flag it so + # the guard budgets conservatively, matching /load's deferral to the probe (#7188). + gpu_ids_are_vulkan_ordinals = False + if is_gguf and requested_gpu_ids and diffusion_gpu is None: + try: + gpu_ids_are_vulkan_ordinals = get_llama_cpp_backend().is_vulkan_build() + except Exception: + gpu_ids_are_vulkan_ordinals = False + ok, info = can_load_chat_during_training( model_name = model_identifier, hf_token = hf_token, @@ -4080,7 +4151,7 @@ def _guard_chat_load_against_training( max_seq_length = max_seq_length, requested_gpu_ids = requested_gpu_ids, is_gguf = is_gguf, - is_vulkan = is_vulkan, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, required_override_gb = required_override_gb, single_device_gpu = diffusion_gpu, ) @@ -4434,7 +4505,7 @@ async def _load_model_impl( n_layers = llama_backend.n_layers, n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, - requested_gpu_ids = llama_backend.requested_gpu_ids, + gguf_memory_mode = llama_backend.requested_memory_mode, ) else: if ( @@ -4498,15 +4569,90 @@ async def _load_model_impl( detail = f"Invalid model identifier: {model_log_label}", ) + # DiffusionGemma rejects an explicit host-memory mode before any teardown + # (shared with /validate); its runner has no --mlock/--no-mmap plumbing. + _reject_diffusion_memory_mode(config, request.gguf_memory_mode) + # Normalize gpu_ids: empty list means auto-selection, same as None effective_gpu_ids = request.gpu_ids if request.gpu_ids else None - # Validate the full GGUF placement pool before the training guard so an - # invalid physical ID or Vulkan ordinal is a clean 400, not a masked VRAM - # 409. The same helper is used by /validate. - gguf_gpu_ids: Optional[List[int]] = None - if config.is_gguf: - gguf_gpu_ids = await _resolve_gguf_gpu_ids_for_request(config, effective_gpu_ids) + # GGUF supports gpu_ids: validate the pick up front (before the training + # guard) so a bad pick is a clean 400, not masked by a VRAM 409. Rejects + # negative / out-of-range / duplicate ids and UUID/MIG parents. XPU hosts + # are rejected outright: the picker's indices are torch-xpu ordinals neither + # applicator speaks (CUDA/HIP masks don't apply, the Vulkan --device pin + # uses ggml's own Vulkan ordinals), so a pick could land on the wrong device. + if config.is_gguf and effective_gpu_ids is not None: + from utils.hardware import DeviceType, get_device + from utils.hardware.hardware import resolve_requested_gpu_ids + from core.inference.llama_cpp import _extra_args_draft_device_pin + + if get_device() == DeviceType.XPU: + raise HTTPException( + status_code = 400, + detail = ( + "GPU selection (gpu_ids) is not supported on Intel XPU. " + "Omit gpu_ids to use all devices." + ), + ) + # A draft-device override in extras (--spec-draft-device / -devd / --device-draft) + # naming a real GPU would place the MTP drafter outside the budgeted gpu_ids and + # OOM training on an unreserved card. Reject before teardown; drop the flag to + # follow the pin, or set it to cpu to offload (cpu/none allowed). llama_extra_args + # None means "inherit the running server's extras" on a same-model reload, and + # draft-device flags survive that inherit, so check the effective extras: the + # request's when provided, else the loaded same-model backend's (#7188). + _draft_extra = request.llama_extra_args + if _draft_extra is None: + _loaded_llama = get_llama_cpp_backend() + if ( + _loaded_llama.is_loaded + and _loaded_llama.model_identifier + and _loaded_llama.model_identifier.lower() == model_identifier.lower() + ): + _draft_extra = _loaded_llama.extra_args + _draft_dev_pin = _extra_args_draft_device_pin(_draft_extra) + if _draft_dev_pin is not None: + raise HTTPException( + status_code = 400, + detail = ( + f"A draft-model device override ('{_draft_dev_pin}') cannot be " + "combined with explicit gpu_ids: it would place the speculative " + "drafter outside the pinned GPUs the training guard budgeted. " + "Remove the draft-device flag to follow gpu_ids, or set it to cpu." + ), + ) + # A Vulkan build treats gpu_ids as Vulkan ordinals (never remapped through + # CUDA_VISIBLE_DEVICES), so defer to the backend probe even on a CUDA-visible + # host, else the CUDA resolver would 400 a valid Vulkan id under a CUDA/HIP + # mask (#7188). Otherwise keep the CUDA physical-ID resolver (#6414). + _gguf_vulkan_build = await asyncio.to_thread(get_llama_cpp_backend().is_vulkan_build) + if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: + try: + resolve_requested_gpu_ids(effective_gpu_ids) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc + else: + # Non-CUDA GGUF host or a Vulkan build on any host: the CUDA resolver can't + # enumerate llama.cpp's devices, so gate on the backend probe and reject + # before teardown (#7188). A torch-less Vulkan host reports CPU but its + # probe is non-empty, so it falls through. + _llama_backend = get_llama_cpp_backend() + if not await asyncio.to_thread(_llama_backend.has_gpu_backend): + raise HTTPException( + status_code = 400, + detail = ( + "gpu_ids selection is not supported: no GPU backend " + "detected on this host." + ), + ) + try: + await asyncio.to_thread( + _llama_backend.assert_requested_gpu_ids_resolvable, + effective_gpu_ids, + ) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc if not config.is_gguf and _mlx_distributed_launch_detected(): raise HTTPException( status_code = 400, @@ -4630,6 +4776,8 @@ async def _load_model_impl( gpu_layers = request.gpu_layers, n_cpu_moe = request.n_cpu_moe, tensor_split = request.tensor_split, + gpu_ids = effective_gpu_ids, + memory_mode = request.gguf_memory_mode, n_parallel = _n_parallel, # Issue #7164: explicit GPU pin resolved to physical ids above. gpu_ids = gguf_gpu_ids, @@ -4806,7 +4954,7 @@ async def _load_model_impl( n_layers = llama_backend.n_layers, n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, - requested_gpu_ids = llama_backend.requested_gpu_ids, + gguf_memory_mode = llama_backend.requested_memory_mode, ) # ── Standard path: load via Unsloth/transformers ────────── @@ -5097,11 +5245,77 @@ async def validate_model( detail = f"Invalid model identifier: {model_log_label}", ) + # Reject DiffusionGemma host-memory placement here too, matching /load (#7188). + _reject_diffusion_memory_mode(config, request.gguf_memory_mode) + # Apply the same training coexistence policy as /load before the frontend # unloads the current model. effective_gpu_ids = request.gpu_ids if request.gpu_ids else None - if config.is_gguf: - await _resolve_gguf_gpu_ids_for_request(config, effective_gpu_ids) + # Mirror /load: GGUF supports gpu_ids, so validate the pick (a bad one is + # a clean 400) before the guard sizes the model against training VRAM. + # XPU-host picks are rejected like /load (no defined mapping from the + # picker's torch-xpu ordinals to the launcher's device spaces). + if config.is_gguf and effective_gpu_ids is not None: + from utils.hardware import DeviceType, get_device + from utils.hardware.hardware import resolve_requested_gpu_ids + from core.inference.llama_cpp import _extra_args_draft_device_pin + + if get_device() == DeviceType.XPU: + raise HTTPException( + status_code = 400, + detail = ( + "GPU selection (gpu_ids) is not supported on Intel XPU. " + "Omit gpu_ids to use all devices." + ), + ) + # Mirror /load's draft-device reject for the validate-then-unload flow: a + # same-model reload inherits the running server's stored extras, and a + # stored --spec-draft-device naming a real GPU would escape the new gpu_ids + # pin. ValidateModelRequest carries no llama_extra_args, so only the + # inherited backend extras are checkable here (#7188). + _loaded_llama = get_llama_cpp_backend() + if ( + _loaded_llama.is_loaded + and _loaded_llama.model_identifier + and _loaded_llama.model_identifier.lower() == model_identifier.lower() + ): + _draft_dev_pin = _extra_args_draft_device_pin(_loaded_llama.extra_args) + if _draft_dev_pin is not None: + raise HTTPException( + status_code = 400, + detail = ( + f"A draft-model device override ('{_draft_dev_pin}') cannot be " + "combined with explicit gpu_ids: it would place the speculative " + "drafter outside the pinned GPUs the training guard budgeted. " + "Remove the draft-device flag to follow gpu_ids, or set it to cpu." + ), + ) + # A Vulkan build treats gpu_ids as Vulkan ordinals, so defer to the backend + # probe even on a CUDA-visible host; otherwise keep the CUDA resolver (#6414/#7188). + _gguf_vulkan_build = await asyncio.to_thread(get_llama_cpp_backend().is_vulkan_build) + if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: + try: + resolve_requested_gpu_ids(effective_gpu_ids) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc + else: + # Non-CUDA GGUF host or a Vulkan build: gate on the llama.cpp probe so an + # unsupported selection is rejected before the unload (#7188). + if not await asyncio.to_thread(_loaded_llama.has_gpu_backend): + raise HTTPException( + status_code = 400, + detail = ( + "gpu_ids selection is not supported: no GPU backend " + "detected on this host." + ), + ) + try: + await asyncio.to_thread( + _loaded_llama.assert_requested_gpu_ids_resolvable, + effective_gpu_ids, + ) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc effective_load_in_4bit = _effective_load_in_4bit(config, request.load_in_4bit) # Both checks cover the [adapter, base] set (matching the scan route and workers): @@ -5931,7 +6145,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)): n_layers = llama_backend.n_layers, n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, - requested_gpu_ids = llama_backend.requested_gpu_ids, + gguf_memory_mode = llama_backend.requested_memory_mode, llama_cpp_supports_mtp = _supports_mtp, spec_fallback_reason = llama_backend.spec_fallback_reason, llama_cpp_prebuilt_stale = _stale, diff --git a/studio/backend/routes/training_vram.py b/studio/backend/routes/training_vram.py index 8ddda11b1e..89856fd0f2 100644 --- a/studio/backend/routes/training_vram.py +++ b/studio/backend/routes/training_vram.py @@ -225,7 +225,7 @@ def can_load_chat_during_training( max_seq_length: int, requested_gpu_ids: Optional[List[int]], is_gguf: bool = False, - is_vulkan: bool = False, + gpu_ids_are_vulkan_ordinals: bool = False, required_override_gb: Optional[float] = None, single_device_gpu: Optional[str] = None, ) -> Tuple[bool, Dict[str, Any]]: @@ -338,6 +338,28 @@ def can_load_chat_during_training( free_vals = [min(free_by_index.values())] if free_by_index else [] else: free_vals = [free_by_index.get(selected_gpu, 0.0)] + elif requested_gpu_ids and gpu_ids_are_vulkan_ordinals: + # Vulkan ordinals enumerate independently of the CUDA index space free_by_index + # uses (the loader skips resolve_requested_gpu_ids for them), so the target + # physical card is unknown. Require a fit on the least-free visible GPU so no + # ordinal->physical mapping can approve a load that then OOMs training (#7188). + free_vals = list(free_by_index.values()) + if not free_vals: + return False, {"mode": "gguf_vulkan", "reason": "no_visible_gpus"} + needed_gb = required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB + # A multi-GPU pin shards the model (~needed/N per device), so require each + # visible GPU to hold one shard, not the whole model. With the mapping unknown, + # min_free is the safe bound: if the least-free card holds a shard, any mapping + # does (#7188). Returns early, so it never reaches the GGUF per-GPU-floor skip below. + per_gpu_needed_gb = needed_gb / len(requested_gpu_ids) + min_free_gb = min(free_vals) + return min_free_gb >= per_gpu_needed_gb, { + "mode": "gguf_vulkan", + "required_gb": round(required_gb, 3), + "needed_gb": round(needed_gb, 3), + "per_gpu_needed_gb": round(per_gpu_needed_gb, 3), + "min_free_gb": round(min_free_gb, 3), + } elif requested_gpu_ids: # Invalid ids -> load_model 400s first, so don't block; missing id = 0. try: diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index f1d973f004..936ee8e014 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -170,7 +170,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): estimate = None, single_device_gpu = None, gpu_ids = None, - is_vulkan = False, + gpu_ids_are_vulkan_ordinals = False, ): with ( patch("utils.hardware.get_device", return_value = DeviceType.CUDA), @@ -186,7 +186,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): max_seq_length = 0, requested_gpu_ids = gpu_ids, is_gguf = True, - is_vulkan = is_vulkan, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, required_override_gb = required_override, single_device_gpu = single_device_gpu, ) @@ -321,6 +321,79 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertFalse(ok) self.assertEqual(info["reason"], "estimate_unavailable") + # ── Vulkan-ordinal least-free / per-shard budgeting (#7188) ────────────── + # gpu_ids on a Vulkan build are ordinals that don't map to the CUDA index + # space free VRAM is reported in, so the guard budgets the least-free visible + # GPU per shard instead of resolving ids to physical cards. + + def test_vulkan_build_budgets_least_free_gpu(self): + # Vulkan ordinals can't map to the CUDA index space free VRAM is reported in. + # free [70, 60], needed 9.75: fits even the least-free card, so allow. + ok, info, _ = self._run( + devices = _devices((0, 80, 10), (1, 80, 20)), + required_override = 5.0, + gpu_ids = [0], + gpu_ids_are_vulkan_ordinals = True, + ) + self.assertTrue(ok) + self.assertEqual(info["mode"], "gguf_vulkan") + + def test_vulkan_build_rejected_when_worst_card_too_full(self): + # free [70, 3], needed 15.5. A Vulkan pin could land on the near-full card and + # OOM training, so the least-free check rejects even though the aggregate fits. + ok, info, _ = self._run( + devices = _devices((0, 80, 10), (1, 80, 77)), + required_override = 10.0, + gpu_ids = [0], + gpu_ids_are_vulkan_ordinals = True, + ) + self.assertFalse(ok) + self.assertEqual(info["mode"], "gguf_vulkan") + + def test_cuda_indexed_build_allows_same_layout(self): + # Same VRAM layout, CUDA-indexed build: resolves ids and budgets the aggregate + # (usable 72.55 >= 15.5) -> allow. Confirms the Vulkan branch tightens the case. + ok, info, _ = self._run( + devices = _devices((0, 80, 10), (1, 80, 77)), + required_override = 10.0, + gpu_ids = [0, 1], + gpu_ids_are_vulkan_ordinals = False, + ) + self.assertTrue(ok) + self.assertEqual(info["mode"], "gguf") + + def test_vulkan_build_no_visible_gpus_refuses(self): + # Vulkan build with no visible GPUs -> nothing to budget -> default-deny. + ok, info, _ = self._run( + devices = [], + required_override = 5.0, + gpu_ids = [0], + gpu_ids_are_vulkan_ordinals = True, + ) + self.assertFalse(ok) + self.assertEqual(info["reason"], "no_visible_gpus") + + def test_vulkan_multi_gpu_shards_budget_per_device(self): + # free [70, 4], needed 6.3. A single-GPU Vulkan pin needs the whole 6.3 on the + # least-free card -> reject; a 2-GPU pin shards (~3.15/card) and fits the least- + # free card -> allow. Confirms the guard budgets per-shard, not whole-model (#7188). + ok_single, info_single, _ = self._run( + devices = _devices((0, 80, 10), (1, 80, 76)), + required_override = 2.0, + gpu_ids = [0], + gpu_ids_are_vulkan_ordinals = True, + ) + self.assertFalse(ok_single) + ok_multi, info_multi, _ = self._run( + devices = _devices((0, 80, 10), (1, 80, 76)), + required_override = 2.0, + gpu_ids = [0, 1], + gpu_ids_are_vulkan_ordinals = True, + ) + self.assertTrue(ok_multi) + self.assertEqual(info_multi["mode"], "gguf_vulkan") + self.assertEqual(info_multi["per_gpu_needed_gb"], round(6.3 / 2, 3)) + # ── can_load_chat_during_training: device-independent paths ────────────────── @@ -813,79 +886,47 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): asyncio.run(self.route.validate_model(request, current_subject = "u")) self.assertEqual(guard_called, []) - def _validate_gguf_template( - self, - *, - template, - canonical_path = "/picked/model.gguf", - ): - # Drive validate_model for a native lease-backed GGUF template probe and - # capture what the embedded-template reader was called with. + def test_gguf_validate_rejects_inherited_draft_device_before_unload(self): + # Validate-then-unload flow: a same-model reload inherits the running + # server's stored --spec-draft-device, which would escape a new gpu_ids + # pin. /validate must 400 BEFORE the client unloads, mirroring /load. from models.inference import ValidateModelRequest - request = ValidateModelRequest( - model_path = "model.gguf", - gguf_variant = "Q4_K_M", - native_path_lease = "signed-lease", - include_chat_template = True, - ) + request = ValidateModelRequest(model_path = "x.gguf", gpu_ids = [0]) cfg = SimpleNamespace( - identifier = canonical_path, - display_name = "model.gguf", + identifier = "x.gguf", + display_name = "x", is_gguf = True, is_lora = False, is_vision = False, - gguf_file = canonical_path, path = None, base_model = None, ) - import utils.models.gguf_metadata as gguf_meta - - seen = {} - - def _fake_read(path): - seen["path"] = path - return template - - guard_called = [] + # Same model already loaded, carrying a draft-device pin in its extras. + loaded = SimpleNamespace( + is_loaded = True, + model_identifier = "x.gguf", + extra_args = ["--spec-draft-device", "CUDA1"], + ) + captured = [] with ( patch.object( self.route, "_resolve_model_identifier_for_request", - return_value = (canonical_path, "model.gguf", True), + return_value = ("x.gguf", "x.gguf", False), ), patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), patch.object(self.route, "load_inference_config", return_value = {}), - patch.object(gguf_meta, "read_gguf_chat_template", _fake_read), - patch.object( - self.route, - "_guard_chat_load_against_training", - lambda *a, **kw: guard_called.append(True), - ), + patch.object(self.route, "_requires_trust_remote_code_for_model", return_value = False), + patch.object(self.route, "get_llama_cpp_backend", return_value = loaded), + _stub_guard_deps(training_active = True, decision = (True, {}), captured = captured), ): - resp = asyncio.run(self.route.validate_model(request, current_subject = "u")) - return resp, seen, guard_called - - def test_include_chat_template_reads_leased_gguf_embedded_template(self): - # The picker chat-template GET has no lease plumbing, so a native picked - # GGUF surfaces its default template through this lease-aware probe: the - # embedded template is read from the granted canonical path and returned. - resp, seen, _ = self._validate_gguf_template(template = "{{ messages }}") - self.assertEqual(resp.chat_template, "{{ messages }}") - # Read strictly the leased file's own embedded template, never a sibling - # sidecar: the grant authorizes just this one path. - self.assertEqual(seen["path"], "/picked/model.gguf") - - def test_include_chat_template_skips_training_guard(self): - # A template-only probe allocates no VRAM, so like include_context_length - # it must not be refused by the training guard. - _, _, guard_called = self._validate_gguf_template(template = "{{ messages }}") - self.assertEqual(guard_called, []) - - def test_include_chat_template_over_cap_is_dropped(self): - from picker.schemas import MAX_CHAT_TEMPLATE_BYTES - resp, _, _ = self._validate_gguf_template(template = "a" * (MAX_CHAT_TEMPLATE_BYTES + 1)) - self.assertIsNone(resp.chat_template) + with self.assertRaises(HTTPException) as ctx: + asyncio.run(self.route.validate_model(request, current_subject = "u")) + self.assertEqual(ctx.exception.status_code, 400) + self.assertIn("draft-model device", ctx.exception.detail) + # Rejected before the coexistence guard (and thus before any unload). + self.assertEqual(captured, []) # ── _estimate_gguf_required_gb (sizes the same weights the loader loads) ────── @@ -1077,6 +1118,133 @@ class TestLoadModelGuardIntegration(unittest.TestCase): inf._shutdown_subprocess.assert_not_called() llama.unload_model.assert_not_called() + def test_gguf_draft_device_gpu_rejected_under_gpu_ids_before_unload(self): + # A draft-device override naming a real GPU (--spec-draft-device CUDA1) would place + # the drafter outside the gpu_ids pin, so /load must 400 before the unload frees the + # model, not after teardown (#7188). cpu/none offload is covered by the + # _extra_args_draft_device_pin unit test. + import contextlib + from unittest.mock import MagicMock + from models.inference import LoadRequest + + inf = SimpleNamespace(active_model_name = None) + inf.unload_model = MagicMock() + inf._shutdown_subprocess = MagicMock() + llama = SimpleNamespace(is_loaded = False, model_identifier = None, hf_variant = None) + llama.unload_model = MagicMock() + cfg = SimpleNamespace( + is_gguf = True, + is_lora = False, + is_vision = False, + path = None, + base_model = None, + identifier = "x.gguf", + display_name = "x", + ) + request = LoadRequest( + model_path = "x.gguf", + gpu_ids = [0], + llama_extra_args = ["--spec-draft-device", "CUDA1"], + max_seq_length = 4096, + ) + captured = [] + with ( + patch("utils.transformers_version.latest_tier_active_for", return_value = False), + patch.object( + self.route, + "validate_extra_args", + return_value = ["--spec-draft-device", "CUDA1"], + ), + patch.object( + self.route, + "_resolve_model_identifier_for_request", + return_value = ("x.gguf", "x.gguf", False), + ), + patch.object(self.route, "resolve_effective_chat_template_override", return_value = None), + # The merged impl renamed the diffusion guard to _reject_diffusion_memory_mode. + patch.object(self.route, "_reject_diffusion_memory_mode", return_value = None), + patch.object(self.route, "get_inference_backend", return_value = inf), + patch.object(self.route, "get_llama_cpp_backend", return_value = llama), + patch.object(self.route, "_hf_offline_if_dns_dead", lambda: contextlib.nullcontext()), + patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), + # Guard would PASS, so a 400 is attributable to the draft-device check. + _stub_guard_deps(training_active = True, decision = (True, {}), captured = captured), + ): + with self.assertRaises(HTTPException) as exc: + asyncio.run( + self.route.load_model(request, fastapi_request = MagicMock(), current_subject = "u") + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("draft-model device", exc.exception.detail) + # Rejected before the guard and the unload step, so nothing is torn down. + self.assertEqual(captured, []) + inf.unload_model.assert_not_called() + inf._shutdown_subprocess.assert_not_called() + llama.unload_model.assert_not_called() + + def test_gguf_inherited_draft_device_rejected_under_gpu_ids(self): + # A same-model reload omitting llama_extra_args inherits the stored extras, and a + # stored --spec-draft-device survives. Adding gpu_ids must still 400: the check runs + # against the effective (inherited) extras, not just the raw request (#7188). + import contextlib + from unittest.mock import MagicMock + from models.inference import LoadRequest + + inf = SimpleNamespace(active_model_name = None) + inf.unload_model = MagicMock() + inf._shutdown_subprocess = MagicMock() + # Same model already loaded, with a draft-device pin in its stored extras. + llama = SimpleNamespace( + is_loaded = True, + model_identifier = "x.gguf", + hf_variant = None, + extra_args = ["--spec-draft-device", "CUDA1"], + ) + llama.unload_model = MagicMock() + cfg = SimpleNamespace( + is_gguf = True, + is_lora = False, + is_vision = False, + path = None, + base_model = None, + identifier = "x.gguf", + display_name = "x", + ) + # Reload the same checkpoint, omitting extras (inherit) but adding a pin. + request = LoadRequest(model_path = "x.gguf", gpu_ids = [0], max_seq_length = 4096) + captured = [] + with ( + patch("utils.transformers_version.latest_tier_active_for", return_value = False), + patch.object(self.route, "validate_extra_args", return_value = None), + patch.object( + self.route, + "_resolve_model_identifier_for_request", + return_value = ("x.gguf", "x.gguf", False), + ), + patch.object(self.route, "resolve_effective_chat_template_override", return_value = None), + patch.object(self.route, "_reject_diffusion_memory_mode", return_value = None), + # Different gpu_ids -> not a settings match, so the already_loaded early + # return is skipped and the reload path (with my check) runs. + patch.object(self.route, "_request_matches_loaded_settings", return_value = False), + patch.object(self.route, "get_inference_backend", return_value = inf), + patch.object(self.route, "get_llama_cpp_backend", return_value = llama), + patch.object(self.route, "_hf_offline_if_dns_dead", lambda: contextlib.nullcontext()), + patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), + _stub_guard_deps(training_active = True, decision = (True, {}), captured = captured), + ): + with self.assertRaises(HTTPException) as exc: + asyncio.run( + self.route.load_model(request, fastapi_request = MagicMock(), current_subject = "u") + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("draft-model device", exc.exception.detail) + # Rejected before the guard and the unload step. + self.assertEqual(captured, []) + inf.unload_model.assert_not_called() + llama.unload_model.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/studio/backend/tests/test_inference_model_validation.py b/studio/backend/tests/test_inference_model_validation.py index 2427ad35fa..3717693eef 100644 --- a/studio/backend/tests/test_inference_model_validation.py +++ b/studio/backend/tests/test_inference_model_validation.py @@ -36,6 +36,36 @@ def test_nonblank_chat_template_override_is_preserved_verbatim(): assert req.chat_template_override == template +import pytest # noqa: E402 +from pydantic import ValidationError # noqa: E402 + +from models.inference import ValidateModelRequest # noqa: E402 + + +@pytest.mark.parametrize("blank", ["", " ", "\n\t"]) +def test_blank_gguf_memory_mode_normalizes_to_auto(blank): + # A form may serialize the default placement as ""; map it to explicit "auto" so it + # counts as a choice and the scrub runs, not 422 or inherit LLAMA_ARG_MLOCK (#7164). + assert _base_load_request(gguf_memory_mode = blank).gguf_memory_mode == "auto" + assert ( + ValidateModelRequest.model_validate( + {"model_path": "x", "gguf_memory_mode": blank} + ).gguf_memory_mode + == "auto" + ) + + +@pytest.mark.parametrize("mode", ["auto", "pinned", "resident"]) +def test_valid_gguf_memory_mode_preserved(mode): + assert _base_load_request(gguf_memory_mode = mode).gguf_memory_mode == mode + + +def test_invalid_gguf_memory_mode_still_rejected(): + # Non-blank typos must still fail Literal validation (only blanks are rescued). + with pytest.raises(ValidationError): + _base_load_request(gguf_memory_mode = "resdent") + + # ---------- ChatCompletionRequest tool_call_id walkback ---------- from models.inference import ChatCompletionRequest diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py new file mode 100644 index 0000000000..1dae15a06c --- /dev/null +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -0,0 +1,1127 @@ +# 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 GGUF memory placement mode and explicit GPU selection (#7164).""" + +from __future__ import annotations + +import os +import struct +import subprocess +import sys +import types as _types +from pathlib import Path +from unittest.mock import patch + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + + +def _install_stub_if_absent(name: str, build): + """Install a stub for ``name`` only when the real module isn't importable, so a + stub never shadows a real module and breaks unrelated tests in a combined pytest + run. The stub stays a pure fallback for minimal environments.""" + if name in sys.modules: + return + try: + __import__(name) + return + except Exception: + sys.modules[name] = build() + + +def _build_loggers_stub(): + mod = _types.ModuleType("loggers") + mod.get_logger = lambda name: __import__("logging").getLogger(name) + return mod + + +def _build_structlog_stub(): + mod = _types.ModuleType("structlog") + mod.get_logger = lambda *a, **k: __import__("logging").getLogger("stub") + return mod + + +def _build_httpx_stub(): + mod = _types.ModuleType("httpx") + for _exc_name in ( + "ConnectError", + "TimeoutException", + "ReadTimeout", + "ReadError", + "RemoteProtocolError", + "CloseError", + ): + setattr(mod, _exc_name, type(_exc_name, (Exception,), {})) + mod.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None}) + mod.Client = type( + "Client", + (), + { + "__init__": lambda self, **kw: None, + "__enter__": lambda self: self, + "__exit__": lambda self, *a: None, + }, + ) + return mod + + +def _build_jwt_stub(): + mod = _types.ModuleType("jwt") + mod.decode = lambda *a, **k: {} + mod.ExpiredSignatureError = type("ExpiredSignatureError", (Exception,), {}) + mod.InvalidTokenError = type("InvalidTokenError", (Exception,), {}) + return mod + + +_install_stub_if_absent("loggers", _build_loggers_stub) +_install_stub_if_absent("structlog", _build_structlog_stub) +_install_stub_if_absent("httpx", _build_httpx_stub) +_install_stub_if_absent("jwt", _build_jwt_stub) + +import pytest + +from core.inference.llama_cpp import LlamaCppBackend + +_GGUF_MAGIC = 0x46554747 +_VTYPE_STRING = 8 + + +def _enc_string(s: str) -> bytes: + b = s.encode("utf-8") + return struct.pack(" bytes: + return _enc_string(key) + struct.pack(" Path: + body = _enc_kv_string("general.architecture", arch) + header = struct.pack(") instead of raising (#7188).""" + backend, gguf = _fit_fallback_backend( + tmp_path, gpu_memory = [(0, 10000, 16000), (1, 8000, 16000)], vulkan = True + ) + backend._select_gpus = lambda *a, **k: ([1], False) + + captured = {} + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 321 + + def __init__(self, cmd, **kwargs): + captured["cmd"] = list(cmd) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with ( + patch.object(subprocess, "Popen", side_effect = _make_fake_popen), + patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []), + ): + assert backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + gpu_ids = [1], + ) + cmd = captured["cmd"] + assert "--device" in cmd and cmd[cmd.index("--device") + 1] == "Vulkan1" + + +def test_vulkan_rejects_duplicate_gpu_ids(tmp_path): + """The CUDA resolver's duplicate check is skipped for the Vulkan path, so the branch + must reject duplicates itself rather than let set() silently collapse them (#7188).""" + backend, _gguf = _fit_fallback_backend( + tmp_path, gpu_memory = [(0, 10000, 16000), (1, 8000, 16000)], vulkan = True + ) + with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): + with pytest.raises(ValueError, match = "unique and non-negative"): + backend.assert_requested_gpu_ids_resolvable([0, 0]) + + +def test_empty_probe_rejects_gpu_ids_without_gpu_backend(tmp_path): + """A non-Vulkan build with an empty probe AND empty parent-visible mask has no GPU + backend, so the backend must reject gpu_ids rather than pin a non-existent device + and silently run on CPU (#7188).""" + from utils.hardware import DeviceType + + backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = []) # empty probe + + with ( + # CUDA host (the mask governs placement) but no mask -> genuinely GPU-less. + patch("utils.hardware.get_device", return_value = DeviceType.CUDA), + patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []), + ): + with pytest.raises(ValueError, match = "no GPU backend"): + backend.assert_requested_gpu_ids_resolvable([0]) + + +def test_empty_probe_rejects_gpu_ids_outside_parent_mask(tmp_path): + """Empty non-Vulkan probe but a set parent-visible mask (real GPU, no telemetry): + a request outside that mask is a genuine 'no such GPU', so it must still raise + rather than pin an id the host can't offer (#7188).""" + from utils.hardware import DeviceType + + backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = []) # empty probe + + with ( + # CUDA host with telemetry down: the mask [0, 1] is real, but 9 is outside it. + patch("utils.hardware.get_device", return_value = DeviceType.CUDA), + patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0, 1]), + ): + with pytest.raises(ValueError, match = "do not match any visible GPUs"): + backend.assert_requested_gpu_ids_resolvable([9]) + + +def test_vulkan_gpu_ids_strips_conflicting_user_device(tmp_path): + """On Vulkan --device is the only pin. With explicit gpu_ids, a user --device in + extras must be stripped so it can't override Unsloth's pin (#7188). Unsloth's --device + survives; unrelated extras pass through.""" + backend, gguf = _fit_fallback_backend( + tmp_path, gpu_memory = [(0, 10000, 16000), (1, 8000, 16000)], vulkan = True + ) + backend._select_gpus = lambda *a, **k: ([0], False) + + captured = {} + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 999 + + def __init__(self, cmd, **kwargs): + captured["cmd"] = list(cmd) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with ( + patch.object(subprocess, "Popen", side_effect = _make_fake_popen), + patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0, 1]), + ): + assert backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + gpu_ids = [0], + extra_args = ["--device", "Vulkan1", "--top-k", "5"], + ) + + cmd = captured["cmd"] + # Unsloth pinned Vulkan0 (gpu_indices=[0]); the user's Vulkan1 override is gone. + assert "Vulkan1" not in cmd + device_idxs = [i for i, tok in enumerate(cmd) if tok == "--device"] + assert len(device_idxs) == 1 + assert cmd[device_idxs[0] + 1] == "Vulkan0" + # Unrelated user extras still pass through. + assert "--top-k" in cmd and cmd[cmd.index("--top-k") + 1] == "5" + + +def test_populated_probe_nonmatching_gpu_ids_still_raises(tmp_path): + """When the probe DID enumerate GPUs but none match the request, the id is genuinely + absent and the load must still raise (the empty-probe relaxation must not swallow + a real 'no such GPU' error).""" + backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)]) + + with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0]): + with pytest.raises(ValueError, match = "do not match any visible GPUs"): + backend.assert_requested_gpu_ids_resolvable([9]) + + +@pytest.mark.parametrize( + "bad_ids, match", + [ + ([99], "do not match any visible GPUs"), # out-of-range on a Vulkan host + ([0, 0], "unique and non-negative"), # duplicate ids + ], +) +def test_invalid_gpu_ids_rejected_before_teardown(tmp_path, bad_ids, match): + """A bad gpu_ids (a typo like [99], or a duplicate) must be rejected by the route's + resolvability preflight BEFORE the active model is torn down. The merged impl does + the check in the route (assert_requested_gpu_ids_resolvable) rather than a Phase-0 + block inside load_model, so exercise that helper directly; a running server is never + reached, hence never killed (#7188).""" + backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)], vulkan = True) + killed = [] + backend._kill_process = lambda: killed.append(True) + + with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): + with pytest.raises(ValueError, match = match): + backend.assert_requested_gpu_ids_resolvable(bad_ids) + + assert killed == [], f"active model was torn down before rejecting gpu_ids={bad_ids}" + + +def test_valid_gpu_ids_pass_resolvable_preflight(tmp_path): + """The resolvability preflight must NOT over-reject a VALID selection: a good gpu_ids + passes without raising so the route proceeds to load.""" + backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)], vulkan = True) + + with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): + # Does not raise. + backend.assert_requested_gpu_ids_resolvable([0]) + + +def test_gpu_ids_preserved_on_fit_fallback(tmp_path): + """When _select_gpus falls back to --fit on, still pin CUDA_VISIBLE_DEVICES.""" + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + + backend = LlamaCppBackend() + backend._get_gpu_memory = lambda _binary = None: [ + (0, 10000, 16000), + (1, 8000, 16000), + (2, 6000, 16000), + ] + backend._read_gguf_metadata = lambda _p: None + backend._can_estimate_kv = lambda: False + backend._get_gguf_size_bytes = lambda _p: 1024 + backend._mmproj_vram_bytes = lambda _p: 0 + backend._resolve_launch_mmproj_path = lambda **k: None + backend._apu_ram_shortfall_message = lambda *a, **k: None + backend._amd_apu_wants_unified_memory = lambda *a, **k: False + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + # Force a --fit on fallback. + backend._select_gpus = lambda *a, **k: (None, True) + backend._wait_for_health = lambda timeout: True + backend._detect_audio_type_strict = lambda: None + backend._apply_detected_audio = lambda _d: True + + captured_envs = [] + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + captured_envs.append(kwargs.get("env") or dict(os.environ)) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with patch.object(subprocess, "Popen", side_effect = _make_fake_popen): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + gpu_ids = [1, 2], + ) + + assert captured_envs, "llama-server was not spawned" + assert captured_envs[-1]["CUDA_VISIBLE_DEVICES"] == "1,2" + + +@pytest.mark.parametrize("gpu_ids,scrubbed", [([1, 2], True), (None, False)]) +def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_ids, scrubbed): + """An explicit gpu_ids pin owns device placement, so an inherited LLAMA_ARG_DEVICE (the + env form of llama.cpp --device) is scrubbed from the child env -- otherwise it would + steer offload off the pinned cards (or to 'none' -> CPU) while /load reports a GPU-pinned + success. Without gpu_ids the operator's LLAMA_ARG_DEVICE inheritance is left intact, + mirroring the memory/split/tensor env scrubs (backwards compatible) (#7188).""" + monkeypatch.setenv("LLAMA_ARG_DEVICE", "CUDA3") + + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + + backend = LlamaCppBackend() + backend._get_gpu_memory = lambda _binary = None: [ + (0, 10000, 16000), + (1, 8000, 16000), + (2, 6000, 16000), + ] + backend._read_gguf_metadata = lambda _p: None + backend._can_estimate_kv = lambda: False + backend._get_gguf_size_bytes = lambda _p: 1024 + backend._mmproj_vram_bytes = lambda _p: 0 + backend._resolve_launch_mmproj_path = lambda **k: None + backend._apu_ram_shortfall_message = lambda *a, **k: None + backend._amd_apu_wants_unified_memory = lambda *a, **k: False + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._select_gpus = lambda *a, **k: ((list(gpu_ids) if gpu_ids else [0]), False) + backend._wait_for_health = lambda timeout: True + backend._detect_audio_type_strict = lambda: None + backend._apply_detected_audio = lambda _d: True + + captured_envs = [] + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + captured_envs.append(kwargs.get("env") or dict(os.environ)) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with patch.object(subprocess, "Popen", side_effect = _make_fake_popen): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + gpu_ids = gpu_ids, + ) + + assert captured_envs, "llama-server was not spawned" + assert ("LLAMA_ARG_DEVICE" not in captured_envs[-1]) == scrubbed + + +def test_memory_mode_clears_inherited_mmap_env_vars(tmp_path): + """An explicit memory_mode must clear stale LLAMA_ARG_* env vars.""" + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + + backend = LlamaCppBackend() + backend._get_gpu_memory = lambda _binary = None: [(0, 10000, 16000)] + backend._read_gguf_metadata = lambda _p: None + backend._can_estimate_kv = lambda: False + backend._get_gguf_size_bytes = lambda _p: 1024 + backend._mmproj_vram_bytes = lambda _p: 0 + backend._resolve_launch_mmproj_path = lambda **k: None + backend._apu_ram_shortfall_message = lambda *a, **k: None + backend._amd_apu_wants_unified_memory = lambda *a, **k: False + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._select_gpus = lambda *a, **k: ([0], False) + backend._wait_for_health = lambda timeout: True + backend._detect_audio_type_strict = lambda: None + backend._apply_detected_audio = lambda _d: True + + base_env = dict(os.environ) + base_env.update( + { + "LLAMA_ARG_MLOCK": "1", + "LLAMA_ARG_MMAP": "1", + "LLAMA_ARG_NO_MMAP": "1", + } + ) + backend._llama_server_env_for_binary = lambda _binary: dict(base_env) + + captured_envs = [] + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + captured_envs.append(kwargs.get("env") or dict(os.environ)) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with patch.object(subprocess, "Popen", side_effect = _make_fake_popen): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + memory_mode = "auto", + ) + + assert captured_envs, "llama-server was not spawned" + env = captured_envs[-1] + assert "LLAMA_ARG_MLOCK" not in env + assert "LLAMA_ARG_MMAP" not in env + assert "LLAMA_ARG_NO_MMAP" not in env + + +@pytest.mark.parametrize( + "mode,user_flag,winning", + [ + ("resident", "--mmap", "--no-mmap"), + ("pinned", "--no-mmap", None), + ("auto", "--mlock", None), + ], +) +def test_memory_mode_strips_conflicting_extra_args(tmp_path, mode, user_flag, winning): + """When a placement mode is applied, a conflicting --mmap/--no-mmap/--mlock left + in extra_args must be stripped so llama.cpp's last-wins parsing can't run a + placement that disagrees with the stored memory_mode (#7164). auto emits no + memory flag, so the user's --mlock is dropped rather than pinning the child.""" + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + backend = _mem_env_backend(gguf) + + captured_cmds = [] + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + captured_cmds.append(list(cmd)) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with patch.object(subprocess, "Popen", side_effect = _make_fake_popen): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + memory_mode = mode, + extra_args = [user_flag], + ) + + assert captured_cmds, "llama-server was not spawned" + cmd = captured_cmds[-1] + # The caller's conflicting flag is gone. + assert user_flag not in cmd + # Only Unsloth's own memory flags (if any) remain, and the last mmap/no-mmap + # flag reflects Unsloth's mode, not the stripped user flag. + mmap_flags = [a for a in cmd if a in ("--mmap", "--no-mmap")] + if winning is None: + assert "--mmap" not in cmd # user --mmap/--no-mmap fully stripped + else: + assert mmap_flags[-1] == winning + + +def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): + """gpu_ids are Vulkan device ordinals matched directly against the Vulkan probe, + NEVER remapped through the CUDA/HIP parent mask. Vulkan enumerates independently of + CUDA_VISIBLE_DEVICES (its order can even reverse the CUDA order), so a remap would + pin the wrong device. A CUDA mask of [2, 3] must not shift the requested ordinal + (#7188).""" + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + + backend = LlamaCppBackend() + backend._is_vulkan_backend = lambda _binary = None: True + # Vulkan reports two devices at ordinals 0 and 1. + backend._get_gpu_memory = lambda _binary = None: [(0, 10000, 16000), (1, 9000, 16000)] + backend._get_gpu_free_memory = lambda _binary = None: [(0, 10000), (1, 9000)] + backend._read_gguf_metadata = lambda _p: None + backend._can_estimate_kv = lambda: False + backend._get_gguf_size_bytes = lambda _p: 1024 + backend._mmproj_vram_bytes = lambda _p: 0 + backend._resolve_launch_mmproj_path = lambda **k: None + backend._apu_ram_shortfall_message = lambda *a, **k: None + backend._amd_apu_wants_unified_memory = lambda *a, **k: False + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + # Select the single candidate that survives the gpu_ids filter (its Vulkan ordinal). + backend._select_gpus = lambda requested_total, gpus, **k: ([gpus[0][0]], False) + backend._wait_for_health = lambda timeout: True + backend._detect_audio_type_strict = lambda: None + backend._apply_detected_audio = lambda _d: True + + captured_cmds = [] + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + captured_cmds.append(list(cmd)) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with ( + patch.object( + subprocess, + "Popen", + side_effect = _make_fake_popen, + ), + # A CUDA/HIP mask of [2, 3] must NOT remap the requested Vulkan ordinal. + patch( + "utils.hardware.get_parent_visible_gpu_ids", + return_value = [2, 3], + ), + ): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + gpu_ids = [1], + ) + + assert captured_cmds, "llama-server was not spawned" + cmd = captured_cmds[-1] + assert "--device" in cmd + # gpu_ids=[1] pins Vulkan ordinal 1 directly, not remapped to Vulkan0 via the mask. + assert cmd[cmd.index("--device") + 1] == "Vulkan1" + + +def test_has_gpu_backend_accepts_parent_mask_when_probe_empty(): + """A real GPU with telemetry down (empty probe) but a numeric parent-visible mask + must count as a backend, so /load does not 400 a selection assert_requested_gpu_ids_ + resolvable would accept via the mask fallback. No probe AND no mask is GPU-less (#7188).""" + backend = LlamaCppBackend() + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._get_gpu_memory = lambda _binary = None: [] # telemetry unavailable + with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0, 1]): + assert backend.has_gpu_backend() is True + with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): + assert backend.has_gpu_backend() is False + + +def test_partial_gpu_ids_match_is_rejected(): + """A partial match such as [0, 99] against a probe of [0] must be rejected, not + silently narrowed to [0] (which would place the model on fewer GPUs than the caller + asked for). Every requested id must be present, not just one (#7188).""" + probe = [(0, 10000, 16000)] + for is_vulkan in (True, False): + with pytest.raises(ValueError, match = "do not match any visible GPUs"): + LlamaCppBackend._assert_gpu_ids_resolvable([0, 99], probe, is_vulkan) + # A full match still passes. + LlamaCppBackend._assert_gpu_ids_resolvable([0], probe, is_vulkan) + + +def test_duplicate_or_negative_gpu_ids_rejected_on_all_backends(): + """Duplicate/negative ids are invalid on every backend, not just Vulkan: a non-Vulkan + populated probe must not collapse [0, 0] into {0} and pin one GPU while recording the + duplicate (a torch-less CUDA host defers here, skipping the CUDA resolver) (#7188).""" + probe = [(0, 10000, 16000), (1, 8000, 16000)] + for is_vulkan in (True, False): + for bad in ([0, 0], [-1], [0, -1]): + with pytest.raises(ValueError, match = "unique and non-negative"): + LlamaCppBackend._assert_gpu_ids_resolvable(bad, probe, is_vulkan) + # A valid unique selection still passes. + LlamaCppBackend._assert_gpu_ids_resolvable([0, 1], probe, is_vulkan) + + +def test_cpu_only_build_rejects_gpu_ids(): + """A CPU-only llama.cpp build ignores CUDA_VISIBLE_DEVICES, so an explicit pin can't be + honored: reject it before teardown even with a populated nvidia-smi probe on a CUDA host, + instead of reporting a GPU-pinned load that silently runs on CPU (#7188).""" + probe = [(0, 10000, 16000)] + with pytest.raises(ValueError, match = "CPU-only build"): + LlamaCppBackend._assert_gpu_ids_resolvable([0], probe, False, backend_lacks_gpu_lib = True) + # With a GPU backend lib present the same pin is accepted. + LlamaCppBackend._assert_gpu_ids_resolvable([0], probe, False, backend_lacks_gpu_lib = False) + + +def test_backend_lacks_gpu_lib_detection(tmp_path): + """_backend_lacks_gpu_lib is True ONLY for a clear CPU-only split-lib layout (a + ggml-cpu/base lib with no gpu sibling); a gpu lib, or an unrecognized/static layout + with no ggml libs, returns False so a valid custom GPU build is never falsely rejected + (#7188).""" + ext = "dll" if sys.platform == "win32" else "so" + pre = "" if sys.platform == "win32" else "lib" + + def _lib_dir_with(*names): + d = tmp_path / ("libs_" + ("_".join(names) or "empty")) + d.mkdir() + for nm in names: + (d / f"{pre}ggml-{nm}.{ext}").write_bytes(b"x") + return d + + binary = str(tmp_path / "llama-server") + (tmp_path / "llama-server").write_bytes(b"x") + + # CPU-only split layout -> True. + with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _lib_dir_with("cpu")): + assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is True + # Any GPU ggml lib present -> False (pin can be honored). + for gpu in ("cuda", "hip", "vulkan"): + with patch( + "core.inference.llama_cpp._llama_lib_dir", return_value = _lib_dir_with("cpu", gpu) + ): + assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is False + # No ggml libs at all (static / unrecognized) -> False (never falsely reject). + with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _lib_dir_with()): + assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is False + + # Versioned sonames (e.g. libggml-cuda.so.0) are matched too: a versioned GPU lib + # next to an unversioned CPU lib is a real GPU build -> False (#7188). + for gpu in ("cuda", "hip", "vulkan"): + d = tmp_path / f"libsv_cpu_{gpu}" + d.mkdir() + (d / f"{pre}ggml-cpu.{ext}").write_bytes(b"x") + (d / f"{pre}ggml-{gpu}.{ext}.0").write_bytes(b"x") + with patch("core.inference.llama_cpp._llama_lib_dir", return_value = d): + assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is False + # A versioned CPU-only lib is still recognized as CPU-only -> True. + d = tmp_path / "libsv_cpu_only" + d.mkdir() + (d / f"{pre}ggml-cpu.{ext}.0").write_bytes(b"x") + with patch("core.inference.llama_cpp._llama_lib_dir", return_value = d): + assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is True + + +def test_empty_non_cuda_probe_rejects_gpu_ids_even_with_stray_mask(): + """On a Metal/SYCL/CPU (non-CUDA) backend the launcher's CUDA_VISIBLE_DEVICES is + ignored (SYCL keys off ONEAPI_DEVICE_SELECTOR, Metal off none), so an empty probe + with a stray parent-visible mask must NOT accept gpu_ids -- the pin would be silently + dropped onto the default device. A CUDA host with the same empty probe + mask is the + real telemetry-down case and still falls through (#7188).""" + from utils.hardware import DeviceType + with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0]): + # A stray CUDA mask on a non-CUDA host must not be trusted to honor the pin. + for dev in (DeviceType.MLX, DeviceType.XPU, DeviceType.CPU): + with patch("utils.hardware.get_device", return_value = dev): + with pytest.raises(ValueError, match = "does not support explicit GPU selection"): + LlamaCppBackend._assert_gpu_ids_resolvable([0], [], False) + # CUDA host (ROCm reports CUDA too): empty probe + valid mask is telemetry-down, + # so the selection is accepted for the loader to pin via CUDA_VISIBLE_DEVICES. + with patch("utils.hardware.get_device", return_value = DeviceType.CUDA): + LlamaCppBackend._assert_gpu_ids_resolvable([0], [], False) + + +def test_explicit_gpu_ids_strips_stored_device_extra_args(tmp_path): + """With explicit gpu_ids a user --device is dropped from BOTH the command and the + PERSISTED extras, so a later same-model reload that inherits these (after gpu_ids is + cleared) can't resurrect the dropped --device and override auto placement (#7188).""" + backend, gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)], vulkan = True) + backend._select_gpus = lambda requested_total, gpus, **k: ([gpus[0][0]], False) + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + pass + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with ( + patch.object(subprocess, "Popen", side_effect = _make_fake_popen), + patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []), + ): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + gpu_ids = [0], + extra_args = ["--device", "Vulkan3", "--top-k", "5"], + ) + + stored = backend._extra_args or [] + assert "--device" not in stored and "Vulkan3" not in stored + assert "--top-k" in stored # unrelated extras are preserved + + +def test_memory_mode_auto_matches_none_in_target_state(): + """An explicit 'auto' request should not reload a load that omitted the field.""" + backend = _loaded_backend(_memory_mode = None) + kwargs = _base_target_state_kwargs(backend) + kwargs["memory_mode"] = "auto" + assert backend._already_in_target_state(**kwargs) is True + + +def test_explicit_auto_reloads_when_child_inherited_mem_env(): + """When the live child was launched with no mode but inherited operator + LLAMA_ARG_* placement flags, an explicit 'auto' request must reload so the + scrub runs -- otherwise it dedups to already-loaded and stays mlocked (#7164).""" + backend = _loaded_backend(_memory_mode = None, _launched_with_inherited_mem_env = True) + kwargs = _base_target_state_kwargs(backend) + kwargs["memory_mode"] = "auto" + assert backend._already_in_target_state(**kwargs) is False + + +def test_omitted_mode_does_not_reload_child_with_inherited_mem_env(): + """A request that also omits the mode (memory_mode=None) does NOT reload the + inherited-env child: omitting keeps the operator env, so there's nothing to + scrub and no spurious reload (which would be rejected during training).""" + backend = _loaded_backend(_memory_mode = None, _launched_with_inherited_mem_env = True) + kwargs = _base_target_state_kwargs(backend) + kwargs["memory_mode"] = None + assert backend._already_in_target_state(**kwargs) is True + + +def test_explicit_auto_matches_scrubbed_child(): + """Once the child was launched clean (no inherited env), an explicit 'auto' + re-Apply dedups to already-loaded -- no needless reload.""" + backend = _loaded_backend(_memory_mode = None, _launched_with_inherited_mem_env = False) + kwargs = _base_target_state_kwargs(backend) + kwargs["memory_mode"] = "auto" + assert backend._already_in_target_state(**kwargs) is True + + +def test_memory_mode_pinned_does_not_match_none(): + backend = _loaded_backend(_memory_mode = None) + kwargs = _base_target_state_kwargs(backend) + kwargs["memory_mode"] = "pinned" + assert backend._already_in_target_state(**kwargs) is False + + +def test_load_response_and_status_round_trip_placement_fields(): + """gpu_ids and gguf_memory_mode are accepted by the response schemas so + status-hydrated requests can preserve explicit placement settings.""" + from models.inference import InferenceStatusResponse, LoadResponse + + load_resp = LoadResponse( + status = "loaded", + model = "m", + display_name = "m", + is_gguf = True, + inference = {}, + gpu_ids = [0, 1], + gguf_memory_mode = "resident", + ) + assert load_resp.gpu_ids == [0, 1] + assert load_resp.gguf_memory_mode == "resident" + + status_resp = InferenceStatusResponse( + is_gguf = True, + gpu_ids = [0, 1], + gguf_memory_mode = "pinned", + ) + assert status_resp.gpu_ids == [0, 1] + assert status_resp.gguf_memory_mode == "pinned" + + +@pytest.mark.parametrize( + "mode,expected_requested,expected_canonical", + [ + ("auto", "auto", None), + ("AUTO", "auto", None), + ("pinned", "pinned", "pinned"), + (None, None, None), + ], +) +def test_requested_memory_mode_preserves_explicit_auto( + tmp_path, mode, expected_requested, expected_canonical +): + """The response echoes requested_memory_mode, not the canonical placement. An explicit + "auto" must survive as "auto" (not collapse to null) so the UI can restore it and a + later reload re-runs the inherited-env scrub instead of letting LLAMA_ARG_MLOCK creep + back; canonical _memory_mode still maps "auto" -> None for placement (#7188).""" + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + backend = _mem_env_backend(gguf) + + with patch.object(subprocess, "Popen"): + assert backend.load_model(gguf_path = str(gguf), model_identifier = "t", memory_mode = mode) + assert backend.requested_memory_mode == expected_requested + assert backend.memory_mode == expected_canonical + + +# ── inherited LLAMA_ARG_* mmap/mlock env is scrubbed when a mode is set ─────── + + +def _mem_env_backend(gguf): + backend = LlamaCppBackend() + backend._get_gpu_memory = lambda _binary = None: [(0, 10000, 16000)] + backend._read_gguf_metadata = lambda _p: None + backend._can_estimate_kv = lambda: False + backend._get_gguf_size_bytes = lambda _p: 1024 + backend._mmproj_vram_bytes = lambda _p: 0 + backend._resolve_launch_mmproj_path = lambda **k: None + backend._apu_ram_shortfall_message = lambda *a, **k: None + backend._amd_apu_wants_unified_memory = lambda *a, **k: False + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._select_gpus = lambda *a, **k: ([0], False) + backend._wait_for_health = lambda timeout: True + backend._detect_audio_type_strict = lambda: None + backend._apply_detected_audio = lambda _d: True + return backend + + +@pytest.mark.parametrize( + "mode,scrubbed", + [("auto", True), ("pinned", True), ("resident", True), (None, False)], +) +def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scrubbed): + """An explicit memory_mode strips inherited LLAMA_ARG_MLOCK/NO_MMAP/MMAP so + llama-server can't run a placement Unsloth didn't select (#7164). memory_mode=None + leaves operator env untouched (backwards compatible); the reload-dedup handles a + later explicit 'auto' (see the target-state tests below).""" + monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") + monkeypatch.setenv("LLAMA_ARG_NO_MMAP", "1") + monkeypatch.setenv("LLAMA_ARG_MMAP", "true") + + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + backend = _mem_env_backend(gguf) + + captured_envs = [] + + def _make_fake_popen(cmd, **kwargs): + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + captured_envs.append(kwargs.get("env") or {}) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with patch.object(subprocess, "Popen", side_effect = _make_fake_popen): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + memory_mode = mode, + ) + + assert captured_envs, "llama-server was not spawned" + env = captured_envs[-1] + for var in ("LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_MMAP"): + assert (var not in env) == scrubbed + + +# ── diffusion GGUFs clear host-residency memory-mode state ─────────────────── +# The merged impl keeps #6414's single-device gpu_ids collapse for diffusion (it does +# NOT reject gpu_ids in load_model) and moved the explicit pinned/resident memory_mode +# rejection into the route (_reject_diffusion_memory_mode). #7188's load_model-level +# rejection tests for both are therefore dropped; what remains here is the kept +# load_model behaviour: a diffusion load clears any host-residency memory_mode carried +# from a prior llama-server load (the diffusion runner has no --mlock/--no-mmap). + + +@pytest.mark.parametrize("mode", [None, "auto", "AUTO", ""]) +def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): + """auto/blank/None is the allowed no-op default for diffusion. A successful load + must clear any _memory_mode left by a prior llama-server load so reload-dedup + doesn't force a needless kill+restart of the diffusion server. (The single-device + gpu_ids collapse is recorded inside _start_diffusion_server, exercised by #6414's + picker tests; this stub replaces the runner, so only the memory-mode clear, which + load_model performs itself, is asserted here.)""" + gguf = tmp_path / "diffusion.gguf" + _write_minimal_gguf(gguf, arch = "diffusion-gemma") + + backend = LlamaCppBackend() + backend._read_gguf_metadata = lambda _p: setattr(backend, "_is_diffusion", True) + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._is_vulkan_backend = lambda _binary = None: False + backend._start_diffusion_server = lambda **kw: True + + # Simulate leftover placement state from a previous llama-server GGUF load. + backend._memory_mode = "resident" + backend._requested_memory_mode = "resident" + backend._launched_with_inherited_mem_env = True + + assert ( + backend.load_model( + gguf_path = str(gguf), + model_identifier = "d", + memory_mode = mode, + ) + is True + ) + assert backend._memory_mode is None + assert backend._requested_memory_mode is None + assert backend._launched_with_inherited_mem_env is False + + +def test_local_chat_gguf_in_diffusion_path_not_prekilled(tmp_path): + """A local chat GGUF whose path contains "diffusion" is NOT a diffusion model: the + header (read via _classify_diffusion_gguf) decides, not the path string, so explicit + gpu_ids on such a local GGUF must load normally, not be rejected on the path (#7188).""" + backend, gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)]) + backend._select_gpus = lambda *a, **k: ([0], False) + + with patch.object(subprocess, "Popen"): + assert ( + backend.load_model( + gguf_path = str(gguf), + model_identifier = "/models/diffusion/chat.gguf", + gpu_ids = [0], + ) + is True + ) diff --git a/studio/backend/tests/test_llama_server_args.py b/studio/backend/tests/test_llama_server_args.py index fa4ba71791..f6aa183639 100644 --- a/studio/backend/tests/test_llama_server_args.py +++ b/studio/backend/tests/test_llama_server_args.py @@ -93,6 +93,9 @@ validate_extra_args = _lsa.validate_extra_args ["-fit", "off"], ["--fit", "on"], ["--fit-ctx", "8192"], + # Memory placement flags (soft-managed; shadowed on inherit) + ["--mlock"], + ["--no-mmap", "--mlock"], ], ) def test_pass_through_allowed(args): @@ -315,6 +318,9 @@ def test_is_managed_flag_false_for_pass_through(): assert is_managed_flag("--flash-attn") is False assert is_managed_flag("-ngl") is False assert is_managed_flag("--threads") is False + # Memory placement flags are pass-through (shadowed on inherit only). + assert is_managed_flag("--mlock") is False + assert is_managed_flag("--no-mmap") is False # ── strip_shadowing_flags ───────────────────────────────────────────── @@ -379,6 +385,36 @@ def test_strip_shadowing_flags_keeps_spec_when_spec_disabled(): assert out == ["--spec-type", "ngram-mod", "--draft-min", "48", "--top-k", "20"] +def test_strip_shadowing_flags_keeps_device_by_default(): + # --device is pass-through by default (users may pin when Unsloth auto-selects). + out = strip_shadowing_flags( + ["--device", "Vulkan1", "--top-k", "20"], + strip_context = False, + strip_cache = False, + strip_spec = False, + strip_template = False, + strip_split_mode = False, + strip_memory_mode = False, + ) + assert out == ["--device", "Vulkan1", "--top-k", "20"] + + +def test_strip_shadowing_flags_drops_device_when_requested(): + # strip_device drops --device/-dev + value when explicit gpu_ids owns placement. + for flag in ("--device", "-dev"): + out = strip_shadowing_flags( + [flag, "Vulkan1", "--top-k", "20"], + strip_context = False, + strip_cache = False, + strip_spec = False, + strip_template = False, + strip_split_mode = False, + strip_memory_mode = False, + strip_device = True, + ) + assert out == ["--top-k", "20"], flag + + def test_strip_shadowing_flags_drops_mtp_flags_when_requested(): # MTP / draft-mtp flags must drop when speculative_type re-applies. out = strip_shadowing_flags( @@ -862,3 +898,55 @@ def test_strip_shadowing_flags_keeps_model_draft_without_spec(): strip_template = False, ) assert out == ["--model-draft", "/custom/mtp.gguf"] + + +# ── Memory mode shadowing (#7164) ─────────────────────────────────────────── + + +def test_strip_shadowing_flags_drops_memory_mode_when_requested(): + out = strip_shadowing_flags( + ["--mlock", "--no-mmap", "--mmap", "--top-k", "20"], + strip_memory_mode = True, + ) + assert out == ["--top-k", "20"] + + +def test_strip_shadowing_flags_keeps_memory_mode_when_not_requested(): + out = strip_shadowing_flags( + ["--mlock", "--no-mmap", "--mmap", "--top-k", "20"], + strip_memory_mode = False, + ) + assert out == ["--mlock", "--no-mmap", "--mmap", "--top-k", "20"] + + +def test_strip_shadowing_flags_defaults_strip_memory_mode(): + # Default kwargs strip everything, including memory placement flags. + assert strip_shadowing_flags(["--mlock", "--no-mmap", "--mmap"]) == [] + + +def test_strip_split_mode_only_keeps_memory_mode(): + # Tensor->layer downgrade must not strip the user's memory mode choice. + assert strip_split_mode_only(["--mlock", "--no-mmap", "--mmap", "-sm", "tensor"]) == [ + "--mlock", + "--no-mmap", + "--mmap", + ] + + +def test_strip_shadowing_flags_drops_inverse_mmap_flag(): + # An inherited --mmap must not override Unsloth's resident-mode --no-mmap (#7164). + out = strip_shadowing_flags(["--mmap"], strip_memory_mode = True) + assert out == [] + + +@pytest.mark.parametrize("flag", ["--mlock", "--no-mmap", "--mmap"]) +def test_strip_memory_mode_valueless_preserves_next_token(flag): + # The memory-mode flags take no value; stripping must not consume the next token. + out = strip_shadowing_flags([flag, "--top-k", "40"], strip_memory_mode = True) + assert out == ["--top-k", "40"] + + +def test_strip_memory_mode_kept_when_field_not_supplied(): + # Pass-through preserved when the user didn't change gguf_memory_mode. + out = strip_shadowing_flags(["--mmap"], strip_memory_mode = False) + assert out == ["--mmap"] diff --git a/studio/backend/tests/test_mtp_vram_budget.py b/studio/backend/tests/test_mtp_vram_budget.py index 694d60cfc6..bdfc69e611 100644 --- a/studio/backend/tests/test_mtp_vram_budget.py +++ b/studio/backend/tests/test_mtp_vram_budget.py @@ -68,6 +68,7 @@ from core.inference.llama_cpp import ( # noqa: E402 _CTX_FIT_VRAM_FRACTION, LlamaCppBackend, _extra_args_draft_cache_types, + _extra_args_draft_device_pin, _extra_args_draft_offloaded_to_cpu, _extra_args_mtp_draft_path, _extra_args_n_ubatch, @@ -614,6 +615,31 @@ class TestExtraArgsMtpDetection: ) assert _extra_args_draft_offloaded_to_cpu([], env = {}) is False + @pytest.mark.parametrize( + "args,expected", + [ + # No draft-device flag -> no pin to reject. + (None, None), + ([], None), + (["-c", "4096"], None), + # cpu / none offload is a supported placement, not a GPU escape. + (["--spec-draft-device", "cpu"], None), + (["--spec-draft-device", "CPU,none"], None), + (["-devd", "none"], None), + # A real GPU device escapes the gpu_ids pin -> return the offending value. + (["--spec-draft-device", "CUDA1"], "CUDA1"), + (["--device-draft", "Vulkan2"], "Vulkan2"), + (["-devd", "CUDA0,CPU"], "CUDA0,CPU"), # any GPU in the list conflicts + # inline flag=value form. + (["--spec-draft-device=Vulkan3"], "Vulkan3"), + # last-wins: only the final draft-device value counts. + (["--spec-draft-device", "CUDA1", "--spec-draft-device", "cpu"], None), + (["--spec-draft-device", "cpu", "--spec-draft-device", "CUDA1"], "CUDA1"), + ], + ) + def test_draft_device_pin(self, args, expected): + assert _extra_args_draft_device_pin(args) == expected + @pytest.mark.parametrize( "args,expected", [ @@ -843,6 +869,27 @@ class TestExtraArgsMtpDetection: # HF-only (hf_repo): local/native loads have no download to retry. assert "llama_backend.hf_repo" in body + def test_route_matcher_strips_memory_flags_from_explicit_extras(self): + # A request repeating a --mlock/--mmap/--no-mmap the loaded server already stripped + # must still hit the fast path, so a no-op re-Apply during training isn't reloaded + # (#7164). Only the request side is stripped: the backend keeps its flags so an + # explicit auto over a server that inherited --mlock still reloads to clear it. + routes_src = ( + Path(__file__).resolve().parent.parent / "routes" / "inference.py" + ).read_text() + start = routes_src.index("def _request_matches_loaded_settings") + end = routes_src.index("\ndef ", start + 1) + body = "".join(routes_src[start:end].split()) + # Gated on the VALUE, not model_fields_set: an explicit null must not strip, + # so it dedupes as "no opinion" (#7188). + assert "_strip_mem=request.gguf_memory_modeisnotNone" in body + assert '"gguf_memory_mode"infields_set' not in body + # The request side is stripped and compared against the UNstripped backend. + assert "_request_extra" in body + assert "if_request_extra!=backend_extra:" in body + # Backend side is not stripped a second time (no _backend_extra_cmp helper). + assert "_backend_extra_cmp" not in body + def test_extra_args_main_cache_type_heavier_axis(self): # Asymmetric --cache-type-k/-v must budget the heavier axis (extras win # per axis at launch), not the last-wins single type that under-reserves. diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py index d1372ca415..4de980d1a0 100644 --- a/studio/backend/tests/test_tp_vision_regression.py +++ b/studio/backend/tests/test_tp_vision_regression.py @@ -814,3 +814,125 @@ def test_already_in_target_state_reloads_on_tensor_off_after_fallback(): ) # A genuine layer load (no preserved intent) -> dedupe, no churn. assert _backend(False)._already_in_target_state(**kwargs) is True + + +# ── route dedup: host-residency memory mode + gpu_ids device strip (#7164/#7188) ─ + + +def _mem_loaded_backend( + *, + memory_mode, + extra_args, + launched_with_inherited_mem_env = False, +): + """A loaded GGUF backend in a given memory-placement state, for the route dedup.""" + b = LlamaCppBackend() + b._model_identifier = "owner/repo" + b._requested_n_ctx = 0 + b._cache_type_kv = None + b._tensor_parallel = False + b._layer_preserves_tensor_intent = False + b._extra_args = list(extra_args) if extra_args else None + b._requested_spec_mode = "auto" + b._chat_template_override = None + b._gguf_path = None + b._gpu_ids = None + b._memory_mode = memory_mode + b._launched_with_inherited_mem_env = launched_with_inherited_mem_env + return b + + +def test_explicit_auto_reloads_over_passthrough_mlock_in_extras(): + """A server loaded with no memory_mode keeps a pass-through --mlock and is still + mlocked. An explicit "auto" repeating --mlock must NOT dedupe: stripping only the + request side keeps the backend's --mlock visible, so the matcher reloads and the + scrub runs (Codex #7164).""" + from models.inference import LoadRequest + + inference_routes = _load_inference_routes_module() + + req = LoadRequest( + model_path = "owner/repo", + gguf_memory_mode = "auto", + llama_extra_args = ["--mlock"], + ) + assert "gguf_memory_mode" in req.model_fields_set + backend = _mem_loaded_backend(memory_mode = None, extra_args = ["--mlock"]) + assert inference_routes._request_matches_loaded_settings(req, backend) is False + + +def test_explicit_null_memory_mode_dedupes_over_passthrough_mlock(): + """An explicit gguf_memory_mode=null (a client echoing the status response) is "no + opinion", not a mode change. Pydantic marks it set, but the dedup gates the strip on + the VALUE: null must NOT strip the request's --mlock, so a status-hydrated Apply + dedupes to the running server (#7188).""" + from models.inference import LoadRequest + + inference_routes = _load_inference_routes_module() + + req = LoadRequest( + model_path = "owner/repo", + gguf_memory_mode = None, + llama_extra_args = ["--mlock"], + ) + # Pydantic marks an explicit null as set -- why gating on model_fields_set was wrong. + assert "gguf_memory_mode" in req.model_fields_set + backend = _mem_loaded_backend(memory_mode = None, extra_args = ["--mlock"]) + assert inference_routes._request_matches_loaded_settings(req, backend) is True + + +def test_explicit_pinned_dedupes_when_flags_already_applied(): + """A server loaded WITH memory_mode="pinned" already had --mlock stripped from its + extras. A repeat pinned Apply re-sending --mlock must still dedupe: the request-side + strip makes it compare equal to the empty backend extras (#7164).""" + from models.inference import LoadRequest + + inference_routes = _load_inference_routes_module() + + req = LoadRequest( + model_path = "owner/repo", + gguf_memory_mode = "pinned", + llama_extra_args = ["--mlock"], + ) + backend = _mem_loaded_backend(memory_mode = "pinned", extra_args = None) + assert inference_routes._request_matches_loaded_settings(req, backend) is True + + +def test_explicit_gpu_ids_dedupes_when_device_already_stripped(): + """A GGUF loaded with explicit gpu_ids had a user --device stripped from its stored + extras. A repeat identical request re-sending --device must still dedupe: the request- + side strip (gated on gpu_ids) compares equal to the stripped backend extras, so the + load hits the fast path instead of a needless reload / training 409 (#7188).""" + from models.inference import LoadRequest + + inference_routes = _load_inference_routes_module() + + req = LoadRequest( + model_path = "owner/repo", + gpu_ids = [0], + llama_extra_args = ["--device", "Vulkan3", "--top-k", "5"], + ) + backend = _mem_loaded_backend(memory_mode = None, extra_args = ["--top-k", "5"]) + backend._gpu_ids = [0] + assert inference_routes._request_matches_loaded_settings(req, backend) is True + + +def test_empty_gpu_ids_dedupes_without_stripping_device(): + """gpu_ids=[] is documented as auto (same as omitting): the load path normalizes it to + None and keeps its --device, so the request-side strip must be gated on an EFFECTIVE pin. + An empty list re-sending --device must still dedupe against a server loaded with no pin + that kept its --device, not force a needless reload / training 409 (#7188).""" + from models.inference import LoadRequest + + inference_routes = _load_inference_routes_module() + + req = LoadRequest( + model_path = "owner/repo", + gpu_ids = [], + llama_extra_args = ["--device", "Vulkan3", "--top-k", "5"], + ) + backend = _mem_loaded_backend( + memory_mode = None, extra_args = ["--device", "Vulkan3", "--top-k", "5"] + ) + backend._gpu_ids = None + assert inference_routes._request_matches_loaded_settings(req, backend) is True diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 4d123e98ab..e666ed08fa 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -169,6 +169,9 @@ export async function validateModel( // --fit, while a pinned layer count is owned by the user. Tell validate // so it applies the same training-guard policy as /load. gpu_memory_mode: payload.gpu_memory_mode, + // GGUF host-memory residency (--mlock/--no-mmap): backend-owned, no UI + // editor. Sent so validate's preflight matches the follow-up /load. + gguf_memory_mode: payload.gguf_memory_mode ?? null, }), }); return parseJsonOrThrow(response); diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index bb19223a6a..af4b6bcee7 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -692,6 +692,15 @@ export function useChatModelRuntime() { const resetsPerModelSettings = Boolean( currentCheckpoint && switchingModelOrVariant && !keepSpeculative, ); + // GGUF host-memory residency is per-model and backend-owned (no UI + // editor): activeMemoryMode only mirrors the loaded model's /status. + // On a model/variant switch, reusing it would pin the new model to + // the prior model's residency, so drop to auto (null); a same-model + // Apply/reload keeps it so an API-set value survives the reload. + const loadMemoryMode = + currentCheckpoint && switchingModelOrVariant + ? null + : stateBeforeUnload.activeMemoryMode; const validateCustomContextLength = resetsPerModelSettings ? null : loadCustomContextLength; @@ -729,6 +738,7 @@ export function useChatModelRuntime() { gguf_variant: ggufVariant ?? null, gpu_ids: validateGpuIds ?? undefined, ...(isGguf ? { gpu_memory_mode: loadGpuMemoryMode } : {}), + gguf_memory_mode: loadMemoryMode ?? null, }); // Upgrade consent runs before the security dialogs; Accept installs and the load continues. if (validation.requires_transformers_upgrade) { @@ -879,6 +889,7 @@ export function useChatModelRuntime() { n_cpu_moe: loadNCpuMoe, tensor_split: loadSplitRatio ?? undefined, gpu_ids: loadSelectedGpuIds ?? undefined, + gguf_memory_mode: loadMemoryMode ?? null, }); // If cancelled while loading, don't update UI to show @@ -1004,6 +1015,12 @@ export function useChatModelRuntime() { loadedChatTemplateOverride: effectiveChatTemplateOverride, loadedIsMultimodal: isMultimodalResponse(loadResponse), loadedIsDiffusion: loadResponse.is_diffusion ?? false, + // Commit the residency the backend applied so the store stops + // reflecting the previous model's mode. Otherwise, after a switch + // (snapshot dropped to null) the store keeps the prior value until + // the next status hydration, so an immediate same-model Apply could + // resend a stale mode. Non-GGUF and auto loads report null here. + activeMemoryMode: loadResponse.gguf_memory_mode ?? null, activeNativePathToken: nativePathToken ?? null, activeNativePathExpiresAtMs: nativePathToken ? nativePathExpiresAtMs @@ -1108,6 +1125,8 @@ export function useChatModelRuntime() { n_cpu_moe: stateBeforeUnload.loadedNCpuMoe ?? 0, tensor_split: stateBeforeUnload.loadedSplitRatio ?? undefined, gpu_ids: stateBeforeUnload.loadedGpuIds ?? undefined, + // Restore the previous model's host-memory residency too. + gguf_memory_mode: stateBeforeUnload.activeMemoryMode ?? null, }); const rollbackSpeculativeType = normalizeSpeculativeType( rollbackResponse.speculative_type, diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index f85ff3246b..75133cbc06 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -330,6 +330,14 @@ export function applyActiveModelStatusToStore( hydratingExistingModel || gpuStatusChanged) && gpuStatusFields), + // GGUF host-memory residency is backend-owned (no UI editor), so mirror + // /status's gguf_memory_mode whenever load params are seeded, not only on + // first hydration or a model change. Otherwise an external reload of the + // same checkpoint with a different residency leaves a stale value for the + // next same-model Apply to resend. Non-GGUF status carries null. + ...(seedLoadParams && { + activeMemoryMode: status.gguf_memory_mode ?? null, + }), ...(status.chat_template_override !== undefined && prevState.loadedChatTemplateOverride === null && prevState.chatTemplateOverride === null && { diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 42359b8f7f..00826201f0 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -901,6 +901,14 @@ type ChatRuntimeStore = { /** Picked physical GPU indices (null = use all / automatic). */ selectedGpuIds: number[] | null; loadedGpuIds: number[] | null; + /** Backend-reported GGUF host-memory residency mode (--mlock/--no-mmap), + * mirrored read-only from /status. Set only via the API/extras; re-sent on a + * same-model reload so an API-set value is preserved. null = unset/auto. */ + activeMemoryMode: "auto" | "pinned" | "resident" | null; + /** Persisted: when false, picking a local model stages it as + * `pendingSelection` (and opens settings) instead of loading immediately, + * so load settings can be set before the single load. */ + loadOnSelection: boolean; /** Persisted: expand every On Device GGUF repo's quantizations by default * instead of waiting for a click. */ expandQuantizations: boolean; @@ -1354,6 +1362,8 @@ export const useChatRuntimeStore = create((set, get) => ({ moeLayerCount: null, selectedGpuIds: null, loadedGpuIds: null, + activeMemoryMode: null, + loadOnSelection: loadBool(CHAT_LOAD_ON_SELECTION_KEY, true), expandQuantizations: loadBool(CHAT_EXPAND_QUANTIZATIONS_KEY, false), showAllQuantizations: loadBool(CHAT_SHOW_ALL_QUANTIZATIONS_KEY, true), fitOnDeviceOnly: loadBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, false), @@ -1608,6 +1618,7 @@ export const useChatRuntimeStore = create((set, get) => ({ moeLayerCount: null, selectedGpuIds: null, loadedGpuIds: null, + activeMemoryMode: null, loadedIsMultimodal: false, loadedIsDiffusion: false, customContextLength: null, diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 6c3e919efe..f0e8a94ad2 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -77,6 +77,8 @@ export interface LoadModelRequest { tensor_split?: number[] | null; /** Picked physical GPU indices (omit/empty = automatic). */ gpu_ids?: number[]; + /** GGUF host-memory placement: auto (default), pinned (--mlock), or resident (--no-mmap --mlock). */ + gguf_memory_mode?: "auto" | "pinned" | "resident" | null; } export interface ValidateModelResponse { @@ -190,8 +192,8 @@ export interface LoadModelResponse { n_moe_layers?: number; /** Effective GPU placement after fit-time narrowing. */ gpu_ids?: number[] | null; - /** User-requested GPU placement pool before fit-time narrowing. */ - requested_gpu_ids?: number[] | null; + /** GGUF host-memory placement mode the load was invoked with. */ + gguf_memory_mode?: "auto" | "pinned" | "resident" | null; } export interface UnloadModelRequest { @@ -245,8 +247,8 @@ export interface InferenceStatusResponse { requested_context_length?: number | null; /** Effective GPU placement after fit-time narrowing. */ gpu_ids?: number[] | null; - /** User-requested GPU placement pool before fit-time narrowing. */ - requested_gpu_ids?: number[] | null; + /** Active GGUF host-memory placement mode (from /status). */ + gguf_memory_mode?: "auto" | "pinned" | "resident" | null; n_layers?: number | null; /** Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not MoE. */ n_moe_layers?: number; From b59cc71472bbd96eef047cbdbb5c982f84c2e6a6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:34:08 +0000 Subject: [PATCH 002/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_server_args.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index ace910df4e..536376ee04 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -217,7 +217,16 @@ _SHADOWING_FLAGS: frozenset[str] = ( # Shadowing flags that take no value -- strip the flag only, not the next token. _BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset( - {"--spec-default", "--jinja", "--no-jinja", "-cmoe", "--cpu-moe", "--mlock", "--no-mmap", "--mmap"} + { + "--spec-default", + "--jinja", + "--no-jinja", + "-cmoe", + "--cpu-moe", + "--mlock", + "--no-mmap", + "--mmap", + } ) From 182abb02750977e076328db2aecc54d0e235a6ba Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 15:08:57 +0000 Subject: [PATCH 003/110] Preserve inherited memory flags and reject GPU pins on CPU-only llama.cpp builds - strip_shadowing_flags: default strip_memory_mode to False (opt-in, matching strip_offload / strip_tensor_split / strip_device). The inherit resolver now strips an inherited --mlock/--mmap/--no-mmap only when the request supplies gguf_memory_mode, so a same-model Apply that omits the field keeps a user's pass-through memory flag instead of silently dropping it. - /load and /validate: on the CUDA path, reject explicit gpu_ids when the llama.cpp build ships no cuda/hip/vulkan ggml lib (CPU-only). Such a build ignores CUDA_VISIBLE_DEVICES, so the pin would run on CPU while the API reports it active. Mirrors the non-CUDA resolvable check. - Document that gpu_ids are ggml Vulkan ordinals on a Vulkan build (enumerated independently of CUDA_VISIBLE_DEVICES), not CUDA physical indices. - Add regression tests: CPU-only route reject, inherit preserve/strip of memory flags, and the flipped strip_memory_mode default. --- .../core/inference/llama_server_args.py | 11 ++- studio/backend/models/inference.py | 11 +-- studio/backend/routes/inference.py | 35 +++++++- studio/backend/tests/test_gpu_selection.py | 88 +++++++++++++++++++ .../backend/tests/test_llama_server_args.py | 12 ++- 5 files changed, 137 insertions(+), 20 deletions(-) diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index 536376ee04..41ed081313 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -462,7 +462,7 @@ def strip_shadowing_flags( strip_split_mode: bool = True, strip_tensor_split: bool = False, strip_offload: bool = False, - strip_memory_mode: bool = True, + strip_memory_mode: bool = False, strip_device: bool = False, ) -> list[str]: """Strip flags that shadow first-class Unsloth settings. @@ -477,9 +477,12 @@ def strip_shadowing_flags( ``--tensor-split`` (the Tensor Parallelism toggle owns the whole split). ``strip_tensor_split`` removes ``--tensor-split`` *alone*, so manual mode can replace an inherited per-GPU ratio while leaving the user's ``--split-mode`` - row/none/layer choice intact. ``strip_device`` is off by default (users may - pass ``--device`` when Unsloth auto-selects) and enabled only for explicit - gpu_ids. + row/none/layer choice intact. ``strip_device`` and ``strip_memory_mode`` are + off by default (opt-in, like ``strip_offload`` / ``strip_tensor_split``): a user + may pass ``--device`` / ``--mlock`` / ``--no-mmap`` when Unsloth has no opinion, + so they're stripped only when the caller sets gpu_ids / gguf_memory_mode. Enabling + them by default would silently drop those inherited pass-through flags on an Apply + that omits the field. """ shadowing: set[str] = set() if strip_context: diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index c800b7f8c0..bb2fdf633c 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -77,16 +77,7 @@ class LoadRequest(BaseModel): ) gpu_ids: Optional[List[int]] = Field( None, - description = ( - "GPU placement pool, for example [0, 1]. Omit or pass [] to use " - "automatic selection. CUDA/ROCm and Intel XPU values are physical " - "GPU indices; Vulkan values are ggml device ordinals. Explicit " - "physical IDs are unsupported when the parent visibility mask uses " - "non-numeric or subdevice entries, including CUDA_VISIBLE_DEVICES " - "with UUID/MIG entries and ZE_AFFINITY_MASK with subdevice tokens " - "(for example '0.0,0.1') or FLAT-hierarchy tile handles. For GGUF " - "models the fitter may pin the smallest subset of this pool that fits." - ), + description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries. For GGUF models the picked devices are pinned via CUDA/HIP_VISIBLE_DEVICES. On a Vulkan llama.cpp build the indices are ggml's compact Vulkan ordinals (as enumerated by the Vulkan probe and pinned via --device Vulkan), not CUDA physical indices; Vulkan enumerates independently of CUDA_VISIBLE_DEVICES.", ) speculative_type: Optional[str] = Field( None, diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 26c25fe39f..2a4eea6fd4 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4244,6 +4244,10 @@ def _resolve_inherited_extra_args( or effective_chat_template_override is not None ), strip_split_mode = _should_strip_split_mode(request, llama_backend.extra_args), + # A real gguf_memory_mode owns --mlock/--mmap/--no-mmap, so strip an + # inherited one; a request that omits the field keeps the pass-through flag + # (opt-in, mirrors the fast-path _strip_mem). null == "no opinion" (#7188). + strip_memory_mode = getattr(request, "gguf_memory_mode", None) is not None, # manual + per-GPU ratio emits its own --tensor-split; drop # an inherited one (appended last would override it) while # keeping the user's --split-mode row/none/layer choice. @@ -4626,8 +4630,22 @@ async def _load_model_impl( # CUDA_VISIBLE_DEVICES), so defer to the backend probe even on a CUDA-visible # host, else the CUDA resolver would 400 a valid Vulkan id under a CUDA/HIP # mask (#7188). Otherwise keep the CUDA physical-ID resolver (#6414). - _gguf_vulkan_build = await asyncio.to_thread(get_llama_cpp_backend().is_vulkan_build) + _llama_backend = get_llama_cpp_backend() + _gguf_vulkan_build = await asyncio.to_thread(_llama_backend.is_vulkan_build) if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: + # The CUDA resolver only validates the physical-ID mask; it can't tell the + # llama.cpp build is CPU-only. A CPU-only build ignores CUDA_VISIBLE_DEVICES, + # so the pin would silently run on CPU while /load reports gpu_ids active -- + # reject before teardown here too (mirrors the non-CUDA resolvable check) (#7188). + if await asyncio.to_thread(_llama_backend._backend_lacks_gpu_lib): + raise HTTPException( + status_code = 400, + detail = ( + f"Requested gpu_ids {list(effective_gpu_ids)} but the llama.cpp " + "build has no GPU backend (CPU-only build); it would ignore the " + "pin and run on CPU. Omit gpu_ids to run on CPU." + ), + ) try: resolve_requested_gpu_ids(effective_gpu_ids) except ValueError as exc: @@ -4637,7 +4655,6 @@ async def _load_model_impl( # enumerate llama.cpp's devices, so gate on the backend probe and reject # before teardown (#7188). A torch-less Vulkan host reports CPU but its # probe is non-empty, so it falls through. - _llama_backend = get_llama_cpp_backend() if not await asyncio.to_thread(_llama_backend.has_gpu_backend): raise HTTPException( status_code = 400, @@ -5292,8 +5309,20 @@ async def validate_model( ) # A Vulkan build treats gpu_ids as Vulkan ordinals, so defer to the backend # probe even on a CUDA-visible host; otherwise keep the CUDA resolver (#6414/#7188). - _gguf_vulkan_build = await asyncio.to_thread(get_llama_cpp_backend().is_vulkan_build) + _gguf_vulkan_build = await asyncio.to_thread(_loaded_llama.is_vulkan_build) if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: + # A CPU-only llama.cpp build ignores CUDA_VISIBLE_DEVICES; the CUDA resolver + # can't see that, so reject a pin it would silently run on CPU (mirrors /load + # and the non-CUDA resolvable check) (#7188). + if await asyncio.to_thread(_loaded_llama._backend_lacks_gpu_lib): + raise HTTPException( + status_code = 400, + detail = ( + f"Requested gpu_ids {list(effective_gpu_ids)} but the llama.cpp " + "build has no GPU backend (CPU-only build); it would ignore the " + "pin and run on CPU. Omit gpu_ids to run on CPU." + ), + ) try: resolve_requested_gpu_ids(effective_gpu_ids) except ValueError as exc: diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 3dab7ef368..e78e377b6d 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1110,6 +1110,94 @@ class TestRouteErrors(unittest.TestCase): self.assertIn("gpu_ids", exc_info.exception.detail.lower()) self.assertNotIn("not supported", exc_info.exception.detail.lower()) + def test_inference_route_rejects_gpu_ids_on_cpu_only_llama_build(self): + # A CUDA-visible host with a CPU-only llama.cpp build: the CUDA resolver validates + # the mask but can't see that the build ignores CUDA_VISIBLE_DEVICES, so the pin + # would silently run on CPU while /load reports it active. The route must reject + # before teardown (mirrors the non-CUDA resolvable check) (#7188). + import utils.hardware as hardware_pkg + + inference_route = _load_route_module( + "inference_route_module_for_cpu_only_gpu_ids_test", + "routes/inference.py", + ) + request = LoadRequest(model_path = "unsloth/test.gguf", gpu_ids = [0, 1]) + model_config = SimpleNamespace( + is_gguf = True, is_lora = False, gguf_hf_repo = None, + gguf_file = "/tmp/test.gguf", gguf_mmproj_file = None, gguf_variant = None, + identifier = "unsloth/test.gguf", display_name = "unsloth/test.gguf", + is_vision = False, is_audio = False, audio_type = None, has_audio_input = False, + ) + fake_backend = SimpleNamespace( + is_loaded = False, + model_identifier = None, + is_vulkan_build = lambda: False, + _backend_lacks_gpu_lib = lambda *a, **k: True, + ) + with ( + patch.object( + inference_route, "ModelConfig", + SimpleNamespace(from_identifier = lambda **_kwargs: model_config), + ), + patch.object(inference_route, "_guard_chat_load_against_training", return_value = None), + patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), + patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext), + patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend), + patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CUDA), + ): + with self.assertRaises(HTTPException) as exc_info: + asyncio.run( + inference_route._load_model_impl( + request, + SimpleNamespace( + app = SimpleNamespace(state = SimpleNamespace(llama_parallel_slots = 1)), + ), + current_subject = "test-user", + ) + ) + self.assertEqual(exc_info.exception.status_code, 400) + self.assertIn("cpu-only build", exc_info.exception.detail.lower()) + + def test_inherited_extras_preserve_memory_flags_when_mode_omitted(self): + # A same-model Apply that omits gguf_memory_mode must NOT drop an inherited + # --mlock/--no-mmap pass-through (opt-in memory stripping), while a first-class + # field the caller set (max_seq_length) still strips its shadow flag (#7188). + inference_route = _load_route_module( + "inference_route_module_for_inherit_memflags_keep_test", + "routes/inference.py", + ) + model_id = "unsloth/test.gguf" + fake_backend = SimpleNamespace( + extra_args = ["--mlock", "--no-mmap", "-c", "4096"], + extra_args_source = (model_id, None), + ) + config = SimpleNamespace(is_gguf = True, gguf_variant = None) + request = LoadRequest(model_path = model_id, max_seq_length = 8192) + with patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend): + out = inference_route._resolve_inherited_extra_args(request, config, model_id, None) + self.assertIn("--mlock", out) + self.assertIn("--no-mmap", out) + self.assertNotIn("-c", out) # first-class max_seq_length still strips the inherited -c + + def test_inherited_extras_strip_memory_flags_when_mode_requested(self): + # A real gguf_memory_mode owns the memory flags, so an inherited one is stripped (#7188). + inference_route = _load_route_module( + "inference_route_module_for_inherit_memflags_strip_test", + "routes/inference.py", + ) + model_id = "unsloth/test.gguf" + fake_backend = SimpleNamespace( + extra_args = ["--mlock", "--no-mmap", "--top-k", "20"], + extra_args_source = (model_id, None), + ) + config = SimpleNamespace(is_gguf = True, gguf_variant = None) + request = LoadRequest(model_path = model_id, gguf_memory_mode = "resident") + with patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend): + out = inference_route._resolve_inherited_extra_args(request, config, model_id, None) + self.assertNotIn("--mlock", out) + self.assertNotIn("--no-mmap", out) + self.assertIn("--top-k", out) + def test_training_route_returns_400_for_invalid_gpu_ids(self): training_route = _load_route_module( "training_route_module_for_test", diff --git a/studio/backend/tests/test_llama_server_args.py b/studio/backend/tests/test_llama_server_args.py index f6aa183639..b604df1539 100644 --- a/studio/backend/tests/test_llama_server_args.py +++ b/studio/backend/tests/test_llama_server_args.py @@ -919,9 +919,15 @@ def test_strip_shadowing_flags_keeps_memory_mode_when_not_requested(): assert out == ["--mlock", "--no-mmap", "--mmap", "--top-k", "20"] -def test_strip_shadowing_flags_defaults_strip_memory_mode(): - # Default kwargs strip everything, including memory placement flags. - assert strip_shadowing_flags(["--mlock", "--no-mmap", "--mmap"]) == [] +def test_strip_shadowing_flags_default_keeps_memory_mode(): + # Memory-mode stripping is opt-in (like offload/tensor_split/device): the default + # must PRESERVE inherited --mlock/--mmap/--no-mmap so an Apply that omits + # gguf_memory_mode doesn't silently drop a user's pass-through memory flag (#7188). + assert strip_shadowing_flags(["--mlock", "--no-mmap", "--mmap"]) == [ + "--mlock", + "--no-mmap", + "--mmap", + ] def test_strip_split_mode_only_keeps_memory_mode(): From a50598f7fc1140662fd905876176a488c5b9c48c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:10:43 +0000 Subject: [PATCH 004/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_gpu_selection.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index e78e377b6d..584df0f24a 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1123,10 +1123,18 @@ class TestRouteErrors(unittest.TestCase): ) request = LoadRequest(model_path = "unsloth/test.gguf", gpu_ids = [0, 1]) model_config = SimpleNamespace( - is_gguf = True, is_lora = False, gguf_hf_repo = None, - gguf_file = "/tmp/test.gguf", gguf_mmproj_file = None, gguf_variant = None, - identifier = "unsloth/test.gguf", display_name = "unsloth/test.gguf", - is_vision = False, is_audio = False, audio_type = None, has_audio_input = False, + is_gguf = True, + is_lora = False, + gguf_hf_repo = None, + gguf_file = "/tmp/test.gguf", + gguf_mmproj_file = None, + gguf_variant = None, + identifier = "unsloth/test.gguf", + display_name = "unsloth/test.gguf", + is_vision = False, + is_audio = False, + audio_type = None, + has_audio_input = False, ) fake_backend = SimpleNamespace( is_loaded = False, @@ -1136,7 +1144,8 @@ class TestRouteErrors(unittest.TestCase): ) with ( patch.object( - inference_route, "ModelConfig", + inference_route, + "ModelConfig", SimpleNamespace(from_identifier = lambda **_kwargs: model_config), ), patch.object(inference_route, "_guard_chat_load_against_training", return_value = None), From 5dd9f590bf76f1805ca3b72d08c3e352546ece11 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Sun, 19 Jul 2026 15:42:06 +0000 Subject: [PATCH 005/110] Detect versioned Vulkan libs and sync memory mode across all load paths - _is_vulkan_backend now matches versioned Vulkan sonames (libggml-vulkan.so.0) via a shared _lib_dir_has_ggml_backend matcher, unified with _backend_lacks_gpu_lib so Vulkan detection and the CPU-only-build check agree. A distro/split-lib Vulkan install without the dev-only unversioned symlink is now detected as Vulkan and gets the --device Vulkan pin instead of being misread as CUDA. This also keeps the Chat Settings picker reliably disabled on Vulkan builds (main.py gates gguf_gpu_ids_supported on this detection), so the UI never emits physical indices into the Vulkan-ordinal route. - Frontend: fold activeMemoryMode into loadedGpuMemoryFields so every successful load path (primary, rollback, compare-load, GGUF/non-GGUF/MTP auto-load) resets it from the LoadResponse, not just the primary path. Prevents a stale pinned mode from an earlier model being re-sent by an immediate same-model Apply. - Add test_is_vulkan_backend_matches_versioned_soname. --- studio/backend/core/inference/llama_cpp.py | 48 +++++------ .../tests/test_llama_cpp_memory_mode.py | 37 +++++++++ .../chat/hooks/use-chat-model-runtime.ts | 8 +- .../chat/stores/chat-runtime-store.ts | 81 ++++++++++++++++++- 4 files changed, 142 insertions(+), 32 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1cd5054aae..9fdbc3e71e 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1966,6 +1966,23 @@ def _llama_lib_dir(binary: str) -> Path: return resolved.parent +def _lib_dir_has_ggml_backend(lib_dir: Path, backend: str) -> bool: + """True if ``lib_dir`` holds a ggml backend lib for ``backend`` (vulkan/cuda/hip/ + cpu/base), matching the exact soname AND versioned variants (e.g. + ``libggml-vulkan.so.0``) shipped by distro/split-lib llama.cpp builds. Vulkan + detection and the CPU-only-build check share this so they agree on what counts as + a present backend (#7188).""" + stem = f"ggml-{backend}.dll" if sys.platform == "win32" else f"libggml-{backend}.so" + try: + return any( + f.name == stem or f.name.startswith(stem + ".") + for f in lib_dir.iterdir() + if f.is_file() + ) + except OSError: + return False + + def _is_external_link(path: Path) -> bool: """True when ``path`` is a --with-llama-cpp-dir local link: a POSIX symlink or a Windows directory junction / reparse point. Such a link resolves into @@ -3043,13 +3060,13 @@ class LlamaCppBackend: if not binary: return False lib_dir = _llama_lib_dir(binary) - if not (lib_dir / _vulkan_lib_filename()).is_file(): + # Match versioned sonames too (libggml-vulkan.so.0), as distro/split-lib installs + # ship the runtime lib without the dev-only unversioned symlink; else a real Vulkan + # build is misread as CUDA and the --device Vulkan pin is never emitted (#7188). + if not _lib_dir_has_ggml_backend(lib_dir, "vulkan"): return False for _backend in ("cuda", "hip"): - sibling = ( - f"ggml-{_backend}.dll" if sys.platform == "win32" else f"libggml-{_backend}.so" - ) - if (lib_dir / sibling).is_file(): + if _lib_dir_has_ggml_backend(lib_dir, _backend): return False return True @@ -3370,27 +3387,10 @@ class LlamaCppBackend: lib_dir = _llama_lib_dir(binary) if not lib_dir or not lib_dir.is_dir(): return False - - def _lib(name): - return f"ggml-{name}.dll" if sys.platform == "win32" else f"libggml-{name}.so" - - def _has_lib(name): - # Match the exact soname and versioned variants (e.g. libggml-cuda.so.0), - # as shipped by distro-packaged / split-lib llama.cpp builds (#7188). - stem = _lib(name) - try: - return any( - f.name == stem or f.name.startswith(stem + ".") - for f in lib_dir.iterdir() - if f.is_file() - ) - except OSError: - return False - - if any(_has_lib(b) for b in ("vulkan", "cuda", "hip")): + if any(_lib_dir_has_ggml_backend(lib_dir, b) for b in ("vulkan", "cuda", "hip")): return False # a GPU ggml backend is present -> the pin can be honored # No GPU lib: CPU-only only if a CPU/base ggml lib proves the split-lib layout (not a static build). - return any(_has_lib(b) for b in ("cpu", "base")) + return any(_lib_dir_has_ggml_backend(lib_dir, b) for b in ("cpu", "base")) def has_gpu_backend(self) -> bool: """True if a GPU probe finds any device: nvidia-smi/amd-smi (CUDA/ROCm) or a diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 1dae15a06c..e3975297e0 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -850,6 +850,43 @@ def test_backend_lacks_gpu_lib_detection(tmp_path): assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is True +def test_is_vulkan_backend_matches_versioned_soname(tmp_path): + """_is_vulkan_backend matches versioned Vulkan sonames (libggml-vulkan.so.0) too, so a + distro/split-lib Vulkan install without the dev-only unversioned symlink is still detected + as Vulkan; otherwise the route treats Vulkan ordinals as CUDA ids and never emits the + --device Vulkan pin (#7188). Shares the matcher with _backend_lacks_gpu_lib.""" + ext = "dll" if sys.platform == "win32" else "so" + pre = "" if sys.platform == "win32" else "lib" + binary = str(tmp_path / "llama-server") + (tmp_path / "llama-server").write_bytes(b"x") + counter = {"n": 0} + + def _dir(*files): + counter["n"] += 1 + d = tmp_path / f"vkdir_{counter['n']}" + d.mkdir() + for f in files: + (d / f).write_bytes(b"x") + return d + + # Versioned-only Vulkan lib (no unversioned symlink) -> detected as Vulkan. + with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}.0")): + assert LlamaCppBackend._is_vulkan_backend(binary) is True + # Unversioned Vulkan lib -> still detected (regression). + with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}")): + assert LlamaCppBackend._is_vulkan_backend(binary) is True + # A CUDA/HIP sibling (versioned or not) means a multi-backend build -> defer to that backend. + for sib in (f"{pre}ggml-cuda.{ext}", f"{pre}ggml-hip.{ext}.0"): + with patch( + "core.inference.llama_cpp._llama_lib_dir", + return_value = _dir(f"{pre}ggml-vulkan.{ext}.0", sib), + ): + assert LlamaCppBackend._is_vulkan_backend(binary) is False + # No Vulkan lib at all -> not a Vulkan build. + with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-cpu.{ext}")): + assert LlamaCppBackend._is_vulkan_backend(binary) is False + + def test_empty_non_cuda_probe_rejects_gpu_ids_even_with_stray_mask(): """On a Metal/SYCL/CPU (non-CUDA) backend the launcher's CUDA_VISIBLE_DEVICES is ignored (SYCL keys off ONEAPI_DEVICE_SELECTOR, Metal off none), so an empty probe diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index af4b6bcee7..880708ce19 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -1015,12 +1015,8 @@ export function useChatModelRuntime() { loadedChatTemplateOverride: effectiveChatTemplateOverride, loadedIsMultimodal: isMultimodalResponse(loadResponse), loadedIsDiffusion: loadResponse.is_diffusion ?? false, - // Commit the residency the backend applied so the store stops - // reflecting the previous model's mode. Otherwise, after a switch - // (snapshot dropped to null) the store keeps the prior value until - // the next status hydration, so an immediate same-model Apply could - // resend a stale mode. Non-GGUF and auto loads report null here. - activeMemoryMode: loadResponse.gguf_memory_mode ?? null, + // activeMemoryMode is committed by loadedGpuMemoryFields (spread + // above) so every load path resets it uniformly; see its note. activeNativePathToken: nativePathToken ?? null, activeNativePathExpiresAtMs: nativePathToken ? nativePathExpiresAtMs diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 00826201f0..a48d97222e 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -606,7 +606,7 @@ export function loadedGpuMemoryFields(resp: { n_layers?: number | null; n_moe_layers?: number; gpu_ids?: number[] | null; - requested_gpu_ids?: number[] | null; + gguf_memory_mode?: "auto" | "pinned" | "resident" | null; }) { // GPU-memory state is meaningful only for a GGUF chat load. A non-GGUF response // still carries gpu_memory_mode (its default "auto" is serialized), so gate on @@ -630,6 +630,9 @@ export function loadedGpuMemoryFields(resp: { loadedSplitRatio: null, ggufLayerCount: null, moeLayerCount: null, + // Host-memory residency is meaningful only for GGUF; a non-GGUF load has + // no mode, so reset to null (see the GGUF branch's note below). + activeMemoryMode: resp.gguf_memory_mode ?? null, }; } const mode = resp.gpu_memory_mode ?? "auto"; @@ -676,11 +679,85 @@ export function loadedGpuMemoryFields(resp: { // The picker reflects the requested placement pool, not a fitted subset. selectedGpuIds: gpuIds, loadedGpuIds: gpuIds, + // Commit the residency the backend actually applied. GGUF host-memory + // residency is backend-owned (no UI editor); mirroring it here on every + // load path keeps activeMemoryMode from carrying a previous model's mode. + // Otherwise, after a switch (compare/auto/rollback load) the store keeps + // the prior value, so an immediate same-model Apply could resend a stale + // mode. Non-GGUF and auto loads report null, resetting it. + activeMemoryMode: resp.gguf_memory_mode ?? null, ...manualKnobs, }; } -/** A pick is a GGUF: HF variant, native file, or a direct local .gguf. */ +/** loadedGpuMemoryFields (plus any seedExtras), unless a staged pick is open. + * + * With a staged pick open (the load fired mid-staging), preserve its editable + * GPU knobs and seedExtras, but still advance every loaded baseline. Otherwise + * cancelling the stage restores its edits onto the newly loaded model. The + * status reseed cannot repair that while pendingSelection holds it off. + */ +export function loadedGpuMemoryFieldsUnlessStaged( + resp: Parameters[0], + seedExtras?: T, +) { + const fields = loadedGpuMemoryFields(resp); + if (useChatRuntimeStore.getState().pendingSelection != null) { + return { + loadedGpuMemoryMode: fields.loadedGpuMemoryMode, + loadedGpuLayers: fields.loadedGpuLayers, + loadedNCpuMoe: fields.loadedNCpuMoe, + loadedSplitRatio: fields.loadedSplitRatio, + loadedGpuIds: fields.loadedGpuIds, + // These are metadata ceilings for the model that actually loaded, not + // editable values from the open stage. Advance them with the baselines + // so abandoning the stage cannot expose the previous model's limits. + ggufLayerCount: fields.ggufLayerCount, + moeLayerCount: fields.moeLayerCount, + // Residency is backend-owned with no editable knob, so it mirrors the + // model that actually loaded, not the open stage. Advance it with the + // baselines so a staged load can't leave a previous model's mode. + activeMemoryMode: fields.activeMemoryMode, + }; + } + return { ...fields, ...seedExtras }; +} + +/** A local model staged for a deferred load (see `pendingSelection`). Shape is + * a subset of the load hook's `SelectedModelInput`, structurally assignable. */ +export type PendingModelSelection = { + id: string; + isLora?: boolean; + ggufVariant?: string; + isDownloaded?: boolean; + expectedBytes?: number; + /** Native (drag-drop / picked-from-disk) GGUF: the path token used to read + * the header and to load. Absent for HF-repo models. */ + nativePathToken?: string; + /** Direct local .gguf file (custom folder / LM Studio): a GGUF source even + * though it carries neither an HF variant nor a native path token. */ + isGguf?: boolean; + /** Native context length read from the GGUF header once the file is local. + * Scoped here (not the shared `ggufContextLength`) so a staged model's + * metadata never pollutes the currently-loaded model's context display. */ + contextLength?: number | null; + /** Total layer count (GGUF block_count); the manual gpu-layers ceiling is + * this + 1 (llama.cpp counts the output layer as offloadable too); + * scoped here like contextLength. */ + layerCount?: number | null; + /** MoE expert-layer count from the GGUF header (manual --n-cpu-moe ceiling); + * 0 for dense models, scoped here like contextLength. */ + moeLayerCount?: number | null; + /** "Load on selection" on + un-cached GGUF: download via the manager (global + * indicator) without opening the sheet, then load once the download finishes. */ + autoLoad?: boolean; + /** Uncached non-GGUF HF repo: download the full snapshot via the manager + * (variant null) the same way GGUF picks download a variant. */ + isHubRepo?: boolean; +}; + +/** A pick is a GGUF (HF variant, native file, or a direct local .gguf) and so + * has pre-load options worth staging. Works on a selection or a staged pick. */ export function hasGgufSource(x: { ggufVariant?: string; nativePathToken?: string; From caa05896b5dab0175af445cbc5d044489345348a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:42:48 +0000 Subject: [PATCH 006/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_llama_cpp_memory_mode.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index e3975297e0..b1c8a6b373 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -870,10 +870,14 @@ def test_is_vulkan_backend_matches_versioned_soname(tmp_path): return d # Versioned-only Vulkan lib (no unversioned symlink) -> detected as Vulkan. - with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}.0")): + with patch( + "core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}.0") + ): assert LlamaCppBackend._is_vulkan_backend(binary) is True # Unversioned Vulkan lib -> still detected (regression). - with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}")): + with patch( + "core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}") + ): assert LlamaCppBackend._is_vulkan_backend(binary) is True # A CUDA/HIP sibling (versioned or not) means a multi-backend build -> defer to that backend. for sib in (f"{pre}ggml-cuda.{ext}", f"{pre}ggml-hip.{ext}.0"): @@ -883,7 +887,9 @@ def test_is_vulkan_backend_matches_versioned_soname(tmp_path): ): assert LlamaCppBackend._is_vulkan_backend(binary) is False # No Vulkan lib at all -> not a Vulkan build. - with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-cpu.{ext}")): + with patch( + "core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-cpu.{ext}") + ): assert LlamaCppBackend._is_vulkan_backend(binary) is False From 8c6e9f68429598164b103a2c51d097d004f7318b Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 21 Jul 2026 05:36:18 +0000 Subject: [PATCH 007/110] Pin get_device in gpu_ids GGUF route test for GPU-less CI test_inference_route_validates_gpu_ids_for_gguf patches resolve_requested_gpu_ids to exercise the CUDA physical-ID resolver, but did not pin the device. On a GPU-less CI runner get_device() returns non-CUDA, so the route took the non-CUDA/Vulkan deferral branch whose 'no GPU backend detected' 400 contains the substring 'not supported', tripping the test's assertNotIn. Pin get_device to CUDA so the test deterministically covers the path it intends. --- studio/backend/tests/test_gpu_selection.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 584df0f24a..1b83774313 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1045,6 +1045,7 @@ class TestRouteErrors(unittest.TestCase): # "not supported for GGUF" rejection. Patch the validator so the test # is deterministic regardless of the host's (or a prior test's) GPU env. import utils.hardware.hardware as hardware_mod + import utils.hardware as hardware_pkg inference_route = _load_route_module( "inference_route_module_for_gguf_gpu_ids_test2", @@ -1090,6 +1091,16 @@ class TestRouteErrors(unittest.TestCase): ), patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext), + # Pin the device so the test deterministically exercises the CUDA + # physical-ID resolver it patches, rather than drifting into the + # non-CUDA/Vulkan deferral branch on a GPU-less runner (whose + # "no GPU backend detected" message contains "not supported"). + patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CUDA), + patch.object( + hardware_mod, + "resolve_requested_gpu_ids", + side_effect = ValueError("Invalid gpu_ids [0, 1]: rejected by test"), + ), ): with self.assertRaises(HTTPException) as exc_info: asyncio.run( From a30d2042c011d7bfbfd8ca7a1429bbe0399e0bc8 Mon Sep 17 00:00:00 2001 From: InfoSage05 Date: Tue, 21 Jul 2026 13:30:21 +0530 Subject: [PATCH 008/110] fix(tests): simplify ordering assertions and tighten GPU validation error message --- .../backend/tests/test_gguf_load_cache_reuse.py | 15 +++++---------- studio/backend/tests/test_gpu_selection.py | 4 +--- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/studio/backend/tests/test_gguf_load_cache_reuse.py b/studio/backend/tests/test_gguf_load_cache_reuse.py index 6d1fac980b..6e63df5435 100644 --- a/studio/backend/tests/test_gguf_load_cache_reuse.py +++ b/studio/backend/tests/test_gguf_load_cache_reuse.py @@ -787,16 +787,11 @@ class TestLoadHubDownloadExclusion: source = (Path(__file__).resolve().parent.parent / "routes" / "inference.py").read_text() gguf_branch = source[source.index("if config.is_gguf:") :] - # The gguf_load_in_flight marker must be entered before the hub-download - # guard and the unload so a concurrent load can't race the download - # manager. The llama_extra_args inheritance that used to sit between the - # marker and the guard now runs in _guard_chat_load_against_training, ahead - # of the GGUF branch, so it is no longer a landmark inside this slice. - assert ( - gguf_branch.index("enter_context(gguf_load_in_flight") - < gguf_branch.index("_hub_download_blocks_gguf_load") - < gguf_branch.index("unsloth_backend.unload_model") - ) + marker_i = gguf_branch.index("enter_context(gguf_load_in_flight") + guard_i = gguf_branch.index("_hub_download_blocks_gguf_load") + unload_i = gguf_branch.index("unsloth_backend.unload_model") + + assert marker_i < guard_i < unload_i llama_source = ( Path(__file__).resolve().parent.parent / "core" / "inference" / "llama_cpp.py" ).read_text() diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 1b83774313..c7e09c2aa0 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1115,11 +1115,9 @@ class TestRouteErrors(unittest.TestCase): ) ) - # The validator's ValueError becomes a clean 400 (not the removed - # "not supported for GGUF" rejection). + # The validator's ValueError (or backend capability rejection) becomes a clean 400. self.assertEqual(exc_info.exception.status_code, 400) self.assertIn("gpu_ids", exc_info.exception.detail.lower()) - self.assertNotIn("not supported", exc_info.exception.detail.lower()) def test_inference_route_rejects_gpu_ids_on_cpu_only_llama_build(self): # A CUDA-visible host with a CPU-only llama.cpp build: the CUDA resolver validates From 25345ec9eebb16921ac8038276ee4fd2b3d141ce Mon Sep 17 00:00:00 2001 From: InfoSage05 Date: Tue, 21 Jul 2026 15:30:47 +0530 Subject: [PATCH 009/110] fix(studio): add GGUF variant check before reusing draft extras; apply aggregate headroom to Vulkan multi-GPU pins - inference.py /load path: check extra_args_source and gguf_variant before reusing a loaded server's extra_args for draft-device validation, so a different quant of the same repo no longer inherits stale --spec-draft-device. - inference.py /validate path: same variant-aware check before inspecting stored backend extras for draft-device rejection. - training_vram.py: Vulkan multi-GPU pin now also enforces the aggregate _MULTI_GPU_OVERHEAD (0.85) check instead of returning early on per-GPU min_free alone, preventing a chat load from starting with too little protected headroom and OOMing an active training run. --- studio/backend/routes/inference.py | 56 ++++++++++++++++---------- studio/backend/routes/training_vram.py | 10 ++++- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 2a4eea6fd4..a6736d9261 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4609,12 +4609,18 @@ async def _load_model_impl( _draft_extra = request.llama_extra_args if _draft_extra is None: _loaded_llama = get_llama_cpp_backend() - if ( - _loaded_llama.is_loaded - and _loaded_llama.model_identifier - and _loaded_llama.model_identifier.lower() == model_identifier.lower() - ): - _draft_extra = _loaded_llama.extra_args + if _loaded_llama.is_loaded and _loaded_llama.extra_args: + source = _loaded_llama.extra_args_source + resolved_variant = (config.gguf_variant or "").lower() + request_variant = (request.gguf_variant or "").lower() + stored_variant = (source[1] or "").lower() if source else "" + same_model = bool(source and source[0] and source[0].lower() == model_identifier.lower()) + if request.gguf_variant: + variant_mismatch = request_variant != stored_variant + else: + variant_mismatch = bool(stored_variant and resolved_variant != stored_variant) + if same_model and not variant_mismatch: + _draft_extra = _loaded_llama.extra_args _draft_dev_pin = _extra_args_draft_device_pin(_draft_extra) if _draft_dev_pin is not None: raise HTTPException( @@ -5291,22 +5297,28 @@ async def validate_model( # pin. ValidateModelRequest carries no llama_extra_args, so only the # inherited backend extras are checkable here (#7188). _loaded_llama = get_llama_cpp_backend() - if ( - _loaded_llama.is_loaded - and _loaded_llama.model_identifier - and _loaded_llama.model_identifier.lower() == model_identifier.lower() - ): - _draft_dev_pin = _extra_args_draft_device_pin(_loaded_llama.extra_args) - if _draft_dev_pin is not None: - raise HTTPException( - status_code = 400, - detail = ( - f"A draft-model device override ('{_draft_dev_pin}') cannot be " - "combined with explicit gpu_ids: it would place the speculative " - "drafter outside the pinned GPUs the training guard budgeted. " - "Remove the draft-device flag to follow gpu_ids, or set it to cpu." - ), - ) + if _loaded_llama.is_loaded and _loaded_llama.extra_args: + source = _loaded_llama.extra_args_source + resolved_variant = (config.gguf_variant or "").lower() + request_variant = (request.gguf_variant or "").lower() + stored_variant = (source[1] or "").lower() if source else "" + same_model = bool(source and source[0] and source[0].lower() == model_identifier.lower()) + if request.gguf_variant: + variant_mismatch = request_variant != stored_variant + else: + variant_mismatch = bool(stored_variant and resolved_variant != stored_variant) + if same_model and not variant_mismatch: + _draft_dev_pin = _extra_args_draft_device_pin(_loaded_llama.extra_args) + if _draft_dev_pin is not None: + raise HTTPException( + status_code = 400, + detail = ( + f"A draft-model device override ('{_draft_dev_pin}') cannot be " + "combined with explicit gpu_ids: it would place the speculative " + "drafter outside the pinned GPUs the training guard budgeted. " + "Remove the draft-device flag to follow gpu_ids, or set it to cpu." + ), + ) # A Vulkan build treats gpu_ids as Vulkan ordinals, so defer to the backend # probe even on a CUDA-visible host; otherwise keep the CUDA resolver (#6414/#7188). _gguf_vulkan_build = await asyncio.to_thread(_loaded_llama.is_vulkan_build) diff --git a/studio/backend/routes/training_vram.py b/studio/backend/routes/training_vram.py index 89856fd0f2..d322eaecb6 100644 --- a/studio/backend/routes/training_vram.py +++ b/studio/backend/routes/training_vram.py @@ -350,13 +350,19 @@ def can_load_chat_during_training( # A multi-GPU pin shards the model (~needed/N per device), so require each # visible GPU to hold one shard, not the whole model. With the mapping unknown, # min_free is the safe bound: if the least-free card holds a shard, any mapping - # does (#7188). Returns early, so it never reaches the GGUF per-GPU-floor skip below. + # does. Also apply the aggregate multi-GPU overhead so a sharded load does not + # start with too little protected headroom and OOM the training run (#7188). per_gpu_needed_gb = needed_gb / len(requested_gpu_ids) min_free_gb = min(free_vals) - return min_free_gb >= per_gpu_needed_gb, { + ranked = sorted(free_vals, reverse = True) + usable_gb = ranked[0] + sum(f * _MULTI_GPU_OVERHEAD for f in ranked[1:]) + aggregate_fits = usable_gb >= needed_gb + per_gpu_fits = min_free_gb >= per_gpu_needed_gb + return per_gpu_fits and aggregate_fits, { "mode": "gguf_vulkan", "required_gb": round(required_gb, 3), "needed_gb": round(needed_gb, 3), + "usable_gb": round(usable_gb, 3), "per_gpu_needed_gb": round(per_gpu_needed_gb, 3), "min_free_gb": round(min_free_gb, 3), } From 87bf433ba28201daa8fec17644547a06a4db1fa1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:20:58 +0000 Subject: [PATCH 010/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/inference.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index a6736d9261..bcce661665 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4614,11 +4614,15 @@ async def _load_model_impl( resolved_variant = (config.gguf_variant or "").lower() request_variant = (request.gguf_variant or "").lower() stored_variant = (source[1] or "").lower() if source else "" - same_model = bool(source and source[0] and source[0].lower() == model_identifier.lower()) + same_model = bool( + source and source[0] and source[0].lower() == model_identifier.lower() + ) if request.gguf_variant: variant_mismatch = request_variant != stored_variant else: - variant_mismatch = bool(stored_variant and resolved_variant != stored_variant) + variant_mismatch = bool( + stored_variant and resolved_variant != stored_variant + ) if same_model and not variant_mismatch: _draft_extra = _loaded_llama.extra_args _draft_dev_pin = _extra_args_draft_device_pin(_draft_extra) @@ -5302,7 +5306,9 @@ async def validate_model( resolved_variant = (config.gguf_variant or "").lower() request_variant = (request.gguf_variant or "").lower() stored_variant = (source[1] or "").lower() if source else "" - same_model = bool(source and source[0] and source[0].lower() == model_identifier.lower()) + same_model = bool( + source and source[0] and source[0].lower() == model_identifier.lower() + ) if request.gguf_variant: variant_mismatch = request_variant != stored_variant else: From 71dfdfedd5e2cfaaae9d5b059d341f26245682cb Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 21 Jul 2026 10:35:30 +0000 Subject: [PATCH 011/110] fix(studio): harden Vulkan probe/budget and diffusion classification for GGUF placement - Use the versioned-soname matcher in the Vulkan free-memory probe too, matching _is_vulkan_backend: a split-library install that ships only libggml-vulkan.so.0 is now sized correctly instead of returning no devices and rejecting gpu_ids. - Budget the Vulkan multi-GPU aggregate over the least-free N pinned cards, not all visible GPUs: with the ordinal to physical mapping unknown, summing every visible card could approve a pin that no N-card placement can actually hold. - Classify a GGUF from its local header before the name heuristic: a normal llama-server GGUF whose path or repo merely contains "diffusion" is no longer rejected for gguf_memory_mode, since the loader routes on the decoded header. - Compute the Vulkan-ordinal flag before diffusion_gpu and gate diffusion_gpu off when it fires, so an unclassified GGUF pinned on a Vulkan build is budgeted as ordinals instead of the single-device CUDA path sizing the wrong physical card. --- studio/backend/core/inference/llama_cpp.py | 7 +- studio/backend/routes/inference.py | 91 ++++++++---------- studio/backend/routes/training_vram.py | 8 +- .../tests/test_chat_load_during_training.py | 95 +++++++++++++++++++ 4 files changed, 145 insertions(+), 56 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9fdbc3e71e..6ce835f00f 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3676,7 +3676,12 @@ class LlamaCppBackend: if not binary: return [] binary_dir = _llama_lib_dir(binary) - if not (binary_dir / _vulkan_lib_filename()).is_file(): + # Match versioned sonames too (libggml-vulkan.so.0), mirroring + # _is_vulkan_backend: split-library installs ship only the versioned + # runtime lib, so an unversioned-symlink-only guard would return [] here + # for a real Vulkan build the detector already classified as Vulkan, + # rejecting every explicit gpu_ids request before launch (#7188). + if not _lib_dir_has_ggml_backend(binary_dir, "vulkan"): return [] env = child_env_without_native_path_secret() diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index bcce661665..131e5aad85 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3942,20 +3942,18 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]: """Classify a GGUF as diffusion, normal, or unknown before it is loaded. ``None`` is important here: a remote GGUF whose header is not cached can - still be routed to the single-GPU diffusion runner after download. Default - placement keeps that unknown case guarded until the header is available. - """ - identity = " ".join( - str(getattr(config, attr, "") or "") for attr in ("identifier", "gguf_hf_repo", "gguf_file") - ).lower() - # Name-only hint, used ONLY as a pre-download fallback, scoped to the - # DiffusionGemma runner family: a bare "diffusion" substring is common in - # ordinary text-model names/paths (e.g. "stable-diffusion-prompt"), and treating - # those as diffusion falsely rejects a valid Vulkan+gpu_ids GGUF (#7239). Normalize - # non-alphanumerics so "DiffusionGemma"/"diffusion-gemma" collapse to one token. - # The local header below stays authoritative. - name_says_diffusion = "diffusiongemma" in _re.sub(r"[^a-z0-9]+", "", identity) + still be routed to the single-GPU diffusion runner after download. Treating + that case as normal would let Manual mode skip the training guard even + though the runner ignores Manual's llama-server placement controls. + The local header is authoritative and checked first: the loader routes on + the decoded architecture (LlamaCppBackend._is_diffusion), never the path, so + a normal llama-server GGUF whose directory/repo name merely contains + "diffusion" must classify as normal (else its memory-placement feature is + rejected on a false name match). The name is only a fallback when no local + header is readable -- an uncached remote GGUF -- where it conservatively + flags a likely-diffusion download (#7188). + """ try: main = getattr(config, "gguf_file", None) if not (main and Path(main).is_file()): @@ -3965,36 +3963,25 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]: from hub.utils.gguf import resolve_local_gguf_path main = resolve_local_gguf_path(repo, variant) if main and Path(main).is_file(): - # The local GGUF header is authoritative (same probe the loader uses), so - # it can't be fooled by a "diffusion"-flavored name/path. probe = LlamaCppBackend() probe._read_gguf_metadata(str(main)) if probe.is_diffusion: return True - # A decoded architecture proves a normal llama-server GGUF; no architecture - # means the probe was inconclusive, so fall through to the name hint below. + # A successfully decoded architecture proves that this is a normal + # llama-server GGUF, even if the path/name contains "diffusion". if getattr(probe, "_architecture", None): return False except Exception as e: logger.debug("Could not identify diffusion GGUF for training guard: %s", e) - # Header unavailable (remote uncached) or inconclusive: True only for the - # DiffusionGemma name family; otherwise None keeps an unknown remote GGUF guarded - # as potentially diffusion until its header proves otherwise. - return True if name_says_diffusion else None - - -async def _resolve_gguf_gpu_ids_for_request( - config: ModelConfig, gpu_ids: Optional[List[int]] -) -> Optional[List[int]]: - """Resolve and fully validate an explicit GGUF GPU placement pool. - - CUDA and ROCm use physical IDs. Vulkan uses ggml ordinals, so its device - existence check comes from the same ggml probe used by the loader. Both - /load and /validate call this before their training guard or any teardown. - """ - if not gpu_ids: - return None + # No readable local header: fall back to the name heuristic so an uncached + # remote GGUF that will route to the diffusion runner isn't treated as normal. + identity = " ".join( + str(getattr(config, attr, "") or "") for attr in ("identifier", "gguf_hf_repo", "gguf_file") + ).lower() + if "diffusion" in identity: + return True + return None from utils.hardware import DeviceType, get_device from utils.hardware.hardware import resolve_requested_gpu_ids @@ -4103,20 +4090,26 @@ def _guard_chat_load_against_training( if is_gguf and gpu_memory_mode == "manual" and diffusion_kind is False: return - # Vulkan GGUF pins are ggml ordinals, not CUDA physical IDs. Detect this - # before deriving a possible diffusion fallback device so an unknown remote - # GGUF never sends its ordinal through the CUDA single-device path. - is_vulkan = False - if is_gguf: + # Vulkan ordinals enumerate independently of the CUDA index space the guard budgets in; + # resolving them as CUDA physical IDs would size free VRAM on the wrong card. Flag it so + # the guard budgets conservatively, matching /load's deferral to the probe (#7188). + # Computed before diffusion_gpu: only a CONFIRMED diffusion GGUF (diffusion_kind is True) + # uses the runner's CUDA single-device mask; an unclassified (None) GGUF on a Vulkan build + # must be budgeted as Vulkan ordinals, not resolved through the CUDA index space -- else an + # uncached remote normal GGUF would size the wrong physical card and could OOM training. + gpu_ids_are_vulkan_ordinals = False + if is_gguf and requested_gpu_ids and diffusion_kind is not True: try: - is_vulkan = LlamaCppBackend._is_vulkan_backend() - except Exception as e: - logger.warning("Could not detect Vulkan backend for chat-load guard: %s", e) + gpu_ids_are_vulkan_ordinals = get_llama_cpp_backend().is_vulkan_build() + except Exception: + gpu_ids_are_vulkan_ordinals = False diffusion_gpu = None - if is_gguf and diffusion_kind is not False and not (is_vulkan and requested_gpu_ids): + if is_gguf and diffusion_kind is not False and not gpu_ids_are_vulkan_ordinals: # Use the same token selection as the runner: an explicit pick wins, - # followed by DG_GPU, the first parent-visible token, then GPU 0. + # followed by DG_GPU, the first parent-visible token, then GPU 0. Suppressed + # for a Vulkan-ordinal pin so single-device CUDA budgeting can't override the + # Vulkan-ordinal path (single_device_gpu wins in can_load_chat_during_training). diffusion_gpu = LlamaCppBackend._diffusion_gpu_arg( requested_gpu_ids, cpu_only = LlamaCppBackend._effective_gpu_count() == 0, @@ -4134,16 +4127,6 @@ def _guard_chat_load_against_training( else None ) - # Vulkan ordinals enumerate independently of the CUDA index space the guard budgets in; - # resolving them as CUDA physical IDs would size free VRAM on the wrong card. Flag it so - # the guard budgets conservatively, matching /load's deferral to the probe (#7188). - gpu_ids_are_vulkan_ordinals = False - if is_gguf and requested_gpu_ids and diffusion_gpu is None: - try: - gpu_ids_are_vulkan_ordinals = get_llama_cpp_backend().is_vulkan_build() - except Exception: - gpu_ids_are_vulkan_ordinals = False - ok, info = can_load_chat_during_training( model_name = model_identifier, hf_token = hf_token, diff --git a/studio/backend/routes/training_vram.py b/studio/backend/routes/training_vram.py index d322eaecb6..2fa8e2c23e 100644 --- a/studio/backend/routes/training_vram.py +++ b/studio/backend/routes/training_vram.py @@ -354,7 +354,13 @@ def can_load_chat_during_training( # start with too little protected headroom and OOM the training run (#7188). per_gpu_needed_gb = needed_gb / len(requested_gpu_ids) min_free_gb = min(free_vals) - ranked = sorted(free_vals, reverse = True) + # The pin lands on some N-of-visible subset (mapping unknown), so budget the + # aggregate over the least-free N cards, not all visible: the N smallest minimize + # usable_gb = max + overhead*rest, so if they fit, any N-subset does. Using all + # visible would over-count headroom the pinned cards may not have (e.g. four 50 GB + # cards make a two-card 100 GB pin look like it fits when no pair holds it). + n_pins = min(len(requested_gpu_ids), len(free_vals)) + ranked = sorted(sorted(free_vals)[:n_pins], reverse = True) usable_gb = ranked[0] + sum(f * _MULTI_GPU_OVERHEAD for f in ranked[1:]) aggregate_fits = usable_gb >= needed_gb per_gpu_fits = min_free_gb >= per_gpu_needed_gb diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 936ee8e014..2ade263ab9 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -394,6 +394,22 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info_multi["mode"], "gguf_vulkan") self.assertEqual(info_multi["per_gpu_needed_gb"], round(6.3 / 2, 3)) + def test_vulkan_aggregate_budgets_requested_subset_not_all_visible(self): + # More visible GPUs than pinned ordinals: a 2-ordinal Vulkan pin lands on some + # 2-of-4 cards (mapping unknown), so the aggregate must budget the least-free 2, + # not all four. free [50,50,50,50], needed 96 (80*1.15+4): any 2-card pool is + # 50 + 50*0.85 = 92.5 < 96, so reject -- even though summing all four (177.5) + # would pass. Guards against approving a pin that then OOMs training (#7188). + ok, info, _ = self._run( + devices = _devices((0, 80, 30), (1, 80, 30), (2, 80, 30), (3, 80, 30)), + required_override = 80.0, + gpu_ids = [0, 1], + gpu_ids_are_vulkan_ordinals = True, + ) + self.assertFalse(ok) + self.assertEqual(info["mode"], "gguf_vulkan") + self.assertEqual(info["usable_gb"], 92.5) + # ── can_load_chat_during_training: device-independent paths ────────────────── @@ -602,6 +618,32 @@ class TestChatLoadGuardRoute(unittest.TestCase): with patch.object(self.route, "LlamaCppBackend", _Probe): self.assertFalse(self.route._classify_diffusion_gguf(config)) + def test_local_normal_gguf_in_diffusion_named_path_is_normal(self): + # A normal llama-server GGUF whose local path/repo merely contains "diffusion" + # must classify as normal: the loader routes on the decoded header, not the name, + # so the readable header wins over the name and the memory-placement feature is not + # rejected on a false name match. Header-before-name fallback (#7188). + import tempfile + + class _Probe: + is_diffusion = False + _architecture = "llama" + + def _read_gguf_metadata(self, _path): + pass + + with tempfile.TemporaryDirectory() as d: + model = Path(d) / "diffusion-experiments" / "qwen3.gguf" + model.parent.mkdir(parents = True) + model.write_bytes(b"GGUF") + config = SimpleNamespace( + identifier = "me/qwen3-diffusion-tests", + gguf_hf_repo = "me/qwen3-diffusion-tests", + gguf_file = str(model), + ) + with patch.object(self.route, "LlamaCppBackend", _Probe): + self.assertFalse(self.route._classify_diffusion_gguf(config)) + def test_manual_known_normal_gguf_bypasses_training_estimate(self): captured = [] config = SimpleNamespace(is_gguf = True) @@ -641,6 +683,59 @@ class TestChatLoadGuardRoute(unittest.TestCase): self.assertEqual(captured[0]["single_device_gpu"], "1") self.assertEqual(captured[0]["requested_gpu_ids"], [3, 1]) + def test_unclassified_gguf_on_vulkan_build_budgets_as_ordinals(self): + # An uncached (unknown) GGUF pinned with explicit gpu_ids on a Vulkan build must be + # budgeted as Vulkan ordinals, NOT via the CUDA single-device path: the launch treats + # the ids as ordinals, so single-device CUDA sizing could pick the wrong physical card + # and OOM training. The Vulkan flag must fire and suppress diffusion_gpu (#7188). + captured = [] + config = SimpleNamespace(is_gguf = True) + with ( + patch.object(self.route, "_classify_diffusion_gguf", return_value = None), + patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), + patch.object( + self.route, + "get_llama_cpp_backend", + return_value = SimpleNamespace(is_vulkan_build = lambda: True), + ), + ): + self._guard( + config = config, + captured = captured, + training_active = True, + decision = (True, {"mode": "gguf_vulkan"}), + requested_gpu_ids = [0, 1], + ) + self.assertEqual(len(captured), 1) + self.assertTrue(captured[0]["gpu_ids_are_vulkan_ordinals"]) + self.assertIsNone(captured[0]["single_device_gpu"]) + + def test_unclassified_gguf_on_cuda_build_keeps_single_device(self): + # Same unknown GGUF on a CUDA-indexed build: the Vulkan flag stays off and the + # single-device CUDA guard is preserved (no regression from the Vulkan gating) (#7188). + captured = [] + config = SimpleNamespace(is_gguf = True) + with ( + patch.object(self.route, "_classify_diffusion_gguf", return_value = None), + patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), + patch.object( + self.route, + "get_llama_cpp_backend", + return_value = SimpleNamespace(is_vulkan_build = lambda: False), + ), + patch.object(self.route.LlamaCppBackend, "_diffusion_gpu_arg", return_value = "0"), + ): + self._guard( + config = config, + captured = captured, + training_active = True, + decision = (True, {"mode": "single_device"}), + requested_gpu_ids = [0, 1], + ) + self.assertEqual(len(captured), 1) + self.assertFalse(captured[0]["gpu_ids_are_vulkan_ordinals"]) + self.assertEqual(captured[0]["single_device_gpu"], "0") + def test_refuses_with_headroom_number(self): info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"} with self.assertRaises(HTTPException) as exc: From ba9c337786c9fead4280909b070994bdcbd2bfa5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:40:56 +0000 Subject: [PATCH 012/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_chat_load_during_training.py | 1 - 1 file changed, 1 deletion(-) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 2ade263ab9..3b25369f96 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -624,7 +624,6 @@ class TestChatLoadGuardRoute(unittest.TestCase): # so the readable header wins over the name and the memory-placement feature is not # rejected on a false name match. Header-before-name fallback (#7188). import tempfile - class _Probe: is_diffusion = False _architecture = "llama" From b3f5e03e6709286629ccf210931f77c947be3405 Mon Sep 17 00:00:00 2001 From: InfoSage05 Date: Tue, 21 Jul 2026 16:32:24 +0530 Subject: [PATCH 013/110] fix(studio): gate XPU gpu_ids rejection to non-Vulkan; skip GGUF memory-mode reject for non-GGUF - /load and /validate paths now detect the Vulkan build before the XPU rejection so Vulkan-backed Intel GPUs can use gpu_ids via --device Vulkan ordinals instead of being blocked by the torch-xpu device check. - _reject_diffusion_memory_mode now returns early for non-GGUF configs so a stale or shared payload with gguf_memory_mode cannot block unrelated non-GGUF loads. --- studio/backend/routes/inference.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 131e5aad85..cbafc8cb5d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4040,6 +4040,8 @@ def _reject_diffusion_memory_mode(config: ModelConfig, memory_mode: Optional[str they agree and a placement /load will reject isn't validated post-unload (#7188). gpu_ids for diffusion is handled by the runner (collapsed to a single device), so only the host-memory mode is rejected here.""" + if not config.is_gguf: + return if LlamaCppBackend._canonical_memory_mode(memory_mode) is None: return if _classify_diffusion_gguf(config) is True: @@ -4566,15 +4568,19 @@ async def _load_model_impl( # GGUF supports gpu_ids: validate the pick up front (before the training # guard) so a bad pick is a clean 400, not masked by a VRAM 409. Rejects # negative / out-of-range / duplicate ids and UUID/MIG parents. XPU hosts - # are rejected outright: the picker's indices are torch-xpu ordinals neither - # applicator speaks (CUDA/HIP masks don't apply, the Vulkan --device pin - # uses ggml's own Vulkan ordinals), so a pick could land on the wrong device. + # are rejected outright unless the llama.cpp build is Vulkan, in which case + # the Vulkan ordinal namespace is the intended target and the picker's + # torch-xpu ordinals don't apply; allow it through so the later Vulkan + # branch can validate/pin via --device Vulkan (#7188). if config.is_gguf and effective_gpu_ids is not None: from utils.hardware import DeviceType, get_device from utils.hardware.hardware import resolve_requested_gpu_ids from core.inference.llama_cpp import _extra_args_draft_device_pin - if get_device() == DeviceType.XPU: + _llama_backend = get_llama_cpp_backend() + _gguf_vulkan_build = await asyncio.to_thread(_llama_backend.is_vulkan_build) + + if get_device() == DeviceType.XPU and not _gguf_vulkan_build: raise HTTPException( status_code = 400, detail = ( @@ -4623,8 +4629,6 @@ async def _load_model_impl( # CUDA_VISIBLE_DEVICES), so defer to the backend probe even on a CUDA-visible # host, else the CUDA resolver would 400 a valid Vulkan id under a CUDA/HIP # mask (#7188). Otherwise keep the CUDA physical-ID resolver (#6414). - _llama_backend = get_llama_cpp_backend() - _gguf_vulkan_build = await asyncio.to_thread(_llama_backend.is_vulkan_build) if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: # The CUDA resolver only validates the physical-ID mask; it can't tell the # llama.cpp build is CPU-only. A CPU-only build ignores CUDA_VISIBLE_DEVICES, @@ -5263,14 +5267,17 @@ async def validate_model( effective_gpu_ids = request.gpu_ids if request.gpu_ids else None # Mirror /load: GGUF supports gpu_ids, so validate the pick (a bad one is # a clean 400) before the guard sizes the model against training VRAM. - # XPU-host picks are rejected like /load (no defined mapping from the - # picker's torch-xpu ordinals to the launcher's device spaces). + # XPU-host picks are rejected unless the llama.cpp build is Vulkan, in + # which case the Vulkan ordinal namespace is the intended target (#7188). if config.is_gguf and effective_gpu_ids is not None: from utils.hardware import DeviceType, get_device from utils.hardware.hardware import resolve_requested_gpu_ids from core.inference.llama_cpp import _extra_args_draft_device_pin - if get_device() == DeviceType.XPU: + _loaded_llama = get_llama_cpp_backend() + _gguf_vulkan_build = await asyncio.to_thread(_loaded_llama.is_vulkan_build) + + if get_device() == DeviceType.XPU and not _gguf_vulkan_build: raise HTTPException( status_code = 400, detail = ( @@ -5283,7 +5290,6 @@ async def validate_model( # stored --spec-draft-device naming a real GPU would escape the new gpu_ids # pin. ValidateModelRequest carries no llama_extra_args, so only the # inherited backend extras are checkable here (#7188). - _loaded_llama = get_llama_cpp_backend() if _loaded_llama.is_loaded and _loaded_llama.extra_args: source = _loaded_llama.extra_args_source resolved_variant = (config.gguf_variant or "").lower() @@ -5310,7 +5316,6 @@ async def validate_model( ) # A Vulkan build treats gpu_ids as Vulkan ordinals, so defer to the backend # probe even on a CUDA-visible host; otherwise keep the CUDA resolver (#6414/#7188). - _gguf_vulkan_build = await asyncio.to_thread(_loaded_llama.is_vulkan_build) if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: # A CPU-only llama.cpp build ignores CUDA_VISIBLE_DEVICES; the CUDA resolver # can't see that, so reject a pin it would silently run on CPU (mirrors /load From 62c9e92ce394ff3b6e53a5b583b559c8bf818c36 Mon Sep 17 00:00:00 2001 From: InfoSage05 Date: Tue, 21 Jul 2026 16:55:13 +0530 Subject: [PATCH 014/110] fix(studio): skip llama.cpp GPU-lib check for diffusion; resolve versioned Vulkan libs in probe - /load and /validate paths now skip _backend_lacks_gpu_lib for confirmed DiffusionGemma GGUFs, since the diffusion runner bypasses llama-server and handles gpu_ids via --gpu/DG_GPU independently of the llama.cpp build. - _vulkan_probe.py now resolves versioned sonames (libggml-vulkan.so.0 etc.) via a directory scan so split-lib installs that lack the unversioned symlink are no longer rejected with no visible devices. --- .../backend/core/inference/_vulkan_probe.py | 26 +++++++++++++++++-- studio/backend/routes/inference.py | 10 ++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index 706346daad..7799c24e4e 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -76,12 +76,34 @@ def main() -> int: else: base_name, vk_name = "libggml-base.so", "libggml-vulkan.so" + def _find_lib(directory, stem): + """Find ``stem`` or a versioned ``stem.N`` in *directory*. + + Prefers the unversioned name; falls back to the first versioned match. + Split-lib installs often ship only the versioned runtime soname + (e.g. ``libggml-vulkan.so.0``) without the dev-only unversioned symlink, + so a hard-coded unversioned-only load would miss a real Vulkan backend + that the detector already classified correctly (#7188). + """ + path = os.path.join(directory, stem) + if os.path.isfile(path): + return path + for entry in sorted(os.listdir(directory)): + if entry.startswith(stem + "."): + return os.path.join(directory, entry) + return None + # RTLD_GLOBAL exposes ggml-base's symbols to ggml-vulkan on POSIX. getattr # falls back to 0 where the flag doesn't exist (Windows CDLL ignores mode). _rtld_global = getattr(ctypes, "RTLD_GLOBAL", 0) + base_path = _find_lib(bindir, base_name) + vk_path = _find_lib(bindir, vk_name) + if not base_path or not vk_path: + print(f"ggml-vulkan load failed: library not found in {bindir}", file=sys.stderr) + return 1 try: - base = ctypes.CDLL(os.path.join(bindir, base_name), mode = _rtld_global) - lib = ctypes.CDLL(os.path.join(bindir, vk_name), mode = _rtld_global) + base = ctypes.CDLL(base_path, mode = _rtld_global) + lib = ctypes.CDLL(vk_path, mode = _rtld_global) except OSError as e: print(f"ggml-vulkan load failed: {e}", file = sys.stderr) return 1 diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index cbafc8cb5d..1db3c7dea8 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4634,7 +4634,10 @@ async def _load_model_impl( # llama.cpp build is CPU-only. A CPU-only build ignores CUDA_VISIBLE_DEVICES, # so the pin would silently run on CPU while /load reports gpu_ids active -- # reject before teardown here too (mirrors the non-CUDA resolvable check) (#7188). - if await asyncio.to_thread(_llama_backend._backend_lacks_gpu_lib): + # Diffusion GGUF models bypass llama-server (the diffusion runner + # handles gpu_ids via --gpu/DG_GPU), so the llama.cpp GPU-lib + # check is irrelevant for them (#7188). + if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread(_llama_backend._backend_lacks_gpu_lib): raise HTTPException( status_code = 400, detail = ( @@ -5319,8 +5322,9 @@ async def validate_model( if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: # A CPU-only llama.cpp build ignores CUDA_VISIBLE_DEVICES; the CUDA resolver # can't see that, so reject a pin it would silently run on CPU (mirrors /load - # and the non-CUDA resolvable check) (#7188). - if await asyncio.to_thread(_loaded_llama._backend_lacks_gpu_lib): + # and the non-CUDA resolvable check). Diffusion GGUF models bypass + # llama-server, so the llama.cpp GPU-lib check is irrelevant (#7188). + if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread(_loaded_llama._backend_lacks_gpu_lib): raise HTTPException( status_code = 400, detail = ( From 7cef3394f8b5f4d54cd48ec7df05b17f636e5528 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:25:58 +0000 Subject: [PATCH 015/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/_vulkan_probe.py | 2 +- studio/backend/routes/inference.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index 7799c24e4e..e50a019641 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -99,7 +99,7 @@ def main() -> int: base_path = _find_lib(bindir, base_name) vk_path = _find_lib(bindir, vk_name) if not base_path or not vk_path: - print(f"ggml-vulkan load failed: library not found in {bindir}", file=sys.stderr) + print(f"ggml-vulkan load failed: library not found in {bindir}", file = sys.stderr) return 1 try: base = ctypes.CDLL(base_path, mode = _rtld_global) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1db3c7dea8..523912e3d1 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4637,7 +4637,9 @@ async def _load_model_impl( # Diffusion GGUF models bypass llama-server (the diffusion runner # handles gpu_ids via --gpu/DG_GPU), so the llama.cpp GPU-lib # check is irrelevant for them (#7188). - if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread(_llama_backend._backend_lacks_gpu_lib): + if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread( + _llama_backend._backend_lacks_gpu_lib + ): raise HTTPException( status_code = 400, detail = ( @@ -5324,7 +5326,9 @@ async def validate_model( # can't see that, so reject a pin it would silently run on CPU (mirrors /load # and the non-CUDA resolvable check). Diffusion GGUF models bypass # llama-server, so the llama.cpp GPU-lib check is irrelevant (#7188). - if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread(_loaded_llama._backend_lacks_gpu_lib): + if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread( + _loaded_llama._backend_lacks_gpu_lib + ): raise HTTPException( status_code = 400, detail = ( From 2cbb4dc00e58c0111f98574b7f7bff18c7c2ca03 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 21 Jul 2026 11:47:25 +0000 Subject: [PATCH 016/110] test(studio): cover diffusion gpu_ids skip + versioned Vulkan soname; mirror is_vulkan_build on draft-device stubs - Add is_vulkan_build to three draft-device test stubs so they mirror the real backend: the gpu_ids validation now reads it before the draft-device reject. - test_cpu_only_llama_build_skips_reject_for_diffusion_gguf: a diffusion GGUF is served by the visual-server runner, so a CPU-only llama.cpp build does not reject its gpu_ids (the flow reaches the id resolver past the skipped reject). - test_versioned_only_vulkan_soname_is_probed: a split-library install shipping only libggml-vulkan.so.0 is still classified Vulkan and probed, not rejected. --- .../tests/test_chat_load_during_training.py | 11 +++- studio/backend/tests/test_gpu_selection.py | 64 +++++++++++++++++++ .../tests/test_llama_cpp_vulkan_probe.py | 16 +++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 3b25369f96..bed38ce606 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -1001,6 +1001,8 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): is_loaded = True, model_identifier = "x.gguf", extra_args = ["--spec-draft-device", "CUDA1"], + extra_args_source = ("x.gguf", None), + is_vulkan_build = lambda: False, ) captured = [] with ( @@ -1224,7 +1226,12 @@ class TestLoadModelGuardIntegration(unittest.TestCase): inf = SimpleNamespace(active_model_name = None) inf.unload_model = MagicMock() inf._shutdown_subprocess = MagicMock() - llama = SimpleNamespace(is_loaded = False, model_identifier = None, hf_variant = None) + llama = SimpleNamespace( + is_loaded = False, + model_identifier = None, + hf_variant = None, + is_vulkan_build = lambda: False, + ) llama.unload_model = MagicMock() cfg = SimpleNamespace( is_gguf = True, @@ -1294,6 +1301,8 @@ class TestLoadModelGuardIntegration(unittest.TestCase): model_identifier = "x.gguf", hf_variant = None, extra_args = ["--spec-draft-device", "CUDA1"], + extra_args_source = ("x.gguf", None), + is_vulkan_build = lambda: False, ) llama.unload_model = MagicMock() cfg = SimpleNamespace( diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index c7e09c2aa0..448b30fcae 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1176,6 +1176,70 @@ class TestRouteErrors(unittest.TestCase): self.assertEqual(exc_info.exception.status_code, 400) self.assertIn("cpu-only build", exc_info.exception.detail.lower()) + def test_cpu_only_llama_build_skips_reject_for_diffusion_gguf(self): + # A diffusion GGUF is served by the separate visual-server runner (DG_GPU/--gpu), + # not llama-server, so a CPU-only llama.cpp build must NOT reject its gpu_ids. The + # flow skips the cpu-only reject and reaches the id resolver just after it, where a + # sentinel stops the load deterministically (#7188). + import utils.hardware as hardware_pkg + + inference_route = _load_route_module( + "inference_route_module_for_diffusion_gpu_ids_test", + "routes/inference.py", + ) + request = LoadRequest(model_path = "unsloth/diffusion.gguf", gpu_ids = [0, 1]) + model_config = SimpleNamespace( + is_gguf = True, + is_lora = False, + gguf_hf_repo = None, + gguf_file = "/tmp/diffusion.gguf", + gguf_mmproj_file = None, + gguf_variant = None, + identifier = "unsloth/diffusion.gguf", + display_name = "unsloth/diffusion.gguf", + is_vision = False, + is_audio = False, + audio_type = None, + has_audio_input = False, + ) + fake_backend = SimpleNamespace( + is_loaded = False, + model_identifier = None, + is_vulkan_build = lambda: False, + _backend_lacks_gpu_lib = lambda *a, **k: True, + ) + with ( + patch.object( + inference_route, + "ModelConfig", + SimpleNamespace(from_identifier = lambda **_kwargs: model_config), + ), + patch.object(inference_route, "_classify_diffusion_gguf", return_value = True), + patch.object( + _hw_module, + "resolve_requested_gpu_ids", + side_effect = ValueError("SENTINEL_AFTER_GATE"), + ), + patch.object(inference_route, "_guard_chat_load_against_training", return_value = None), + patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), + patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext), + patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend), + patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CUDA), + ): + with self.assertRaises(HTTPException) as exc_info: + asyncio.run( + inference_route._load_model_impl( + request, + SimpleNamespace( + app = SimpleNamespace(state = SimpleNamespace(llama_parallel_slots = 1)), + ), + current_subject = "test-user", + ) + ) + # Reached the id resolver past the skipped cpu-only reject, not the reject itself. + self.assertNotIn("cpu-only build", exc_info.exception.detail.lower()) + self.assertIn("SENTINEL_AFTER_GATE", exc_info.exception.detail) + def test_inherited_extras_preserve_memory_flags_when_mode_omitted(self): # A same-model Apply that omits gguf_memory_mode must NOT drop an inherited # --mlock/--no-mmap pass-through (opt-in memory stripping), while a first-class diff --git a/studio/backend/tests/test_llama_cpp_vulkan_probe.py b/studio/backend/tests/test_llama_cpp_vulkan_probe.py index 92aaab4873..b5cbaaabc3 100644 --- a/studio/backend/tests/test_llama_cpp_vulkan_probe.py +++ b/studio/backend/tests/test_llama_cpp_vulkan_probe.py @@ -189,5 +189,21 @@ def test_shell_wrapper_entrypoint_resolves_to_real_lib_dir(tmp_path): assert LlamaCppBackend._is_vulkan_backend(str(wrapper)) is True +@pytest.mark.skipif(sys.platform == "win32", reason = "soname versioning is POSIX") +def test_versioned_only_vulkan_soname_is_probed(tmp_path): + # Split-library install: only the versioned soname libggml-vulkan.so.0 exists + # (no unversioned dev symlink). The reader must still classify Vulkan and run + # the probe instead of returning [] and rejecting gpu_ids before launch (#7188). + bindir = tmp_path / "build" / "bin" + bindir.mkdir(parents = True) + binary = bindir / "llama-server" + binary.write_bytes(b"stub") + (bindir / (_vulkan_lib_filename() + ".0")).write_bytes(b"stub") + rows = [_row(0, 23 * GIB, is_igpu = 0, total_bytes = 24 * GIB)] + with _mock_probe(rows): + gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(str(binary)) + assert gpus == [(0, 23 * 1024, 24 * 1024)], gpus + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"])) From 000cfcfee754debc949d129e69b68d1ae3156ca7 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 21 Jul 2026 12:05:36 +0000 Subject: [PATCH 017/110] fix(studio): route confirmed diffusion gpu_ids through the CUDA path on a Vulkan build A confirmed diffusion GGUF is served by the visual-server runner, which takes a CUDA physical id via --gpu/DG_GPU (it remasks CUDA_VISIBLE_DEVICES), never a Vulkan ordinal. The gpu_ids preflight previously sent it to the Vulkan-ordinal branch whenever the installed llama-server was a Vulkan build, so a valid physical pick could be rejected (no matching Vulkan ordinal) or an ordinal validated that the runner then applies to the wrong CUDA GPU. Route a confirmed diffusion GGUF through the CUDA resolver at both /load and /validate even on a Vulkan build, matching the training guard's existing diffusion_gpu handling. --- studio/backend/routes/inference.py | 27 +++++++-- studio/backend/tests/test_gpu_selection.py | 69 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 523912e3d1..2505aeb615 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4579,6 +4579,11 @@ async def _load_model_impl( _llama_backend = get_llama_cpp_backend() _gguf_vulkan_build = await asyncio.to_thread(_llama_backend.is_vulkan_build) + # A confirmed diffusion GGUF is served by the visual-server runner, which takes a + # CUDA physical id via --gpu/DG_GPU (it remasks CUDA_VISIBLE_DEVICES), never a + # Vulkan ordinal. Route its gpu_ids through the CUDA resolver even on a Vulkan + # build, matching the training guard's diffusion_gpu handling (#7188). + _gguf_confirmed_diffusion = _classify_diffusion_gguf(config) is True if get_device() == DeviceType.XPU and not _gguf_vulkan_build: raise HTTPException( @@ -4628,8 +4633,12 @@ async def _load_model_impl( # A Vulkan build treats gpu_ids as Vulkan ordinals (never remapped through # CUDA_VISIBLE_DEVICES), so defer to the backend probe even on a CUDA-visible # host, else the CUDA resolver would 400 a valid Vulkan id under a CUDA/HIP - # mask (#7188). Otherwise keep the CUDA physical-ID resolver (#6414). - if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: + # mask (#7188). Otherwise keep the CUDA physical-ID resolver (#6414). A confirmed + # diffusion GGUF always takes the CUDA path: its runner uses CUDA ids, not the + # Vulkan llama-server, so a Vulkan build must not send it to the ordinal branch. + if get_device() == DeviceType.CUDA and ( + not _gguf_vulkan_build or _gguf_confirmed_diffusion + ): # The CUDA resolver only validates the physical-ID mask; it can't tell the # llama.cpp build is CPU-only. A CPU-only build ignores CUDA_VISIBLE_DEVICES, # so the pin would silently run on CPU while /load reports gpu_ids active -- @@ -4637,7 +4646,7 @@ async def _load_model_impl( # Diffusion GGUF models bypass llama-server (the diffusion runner # handles gpu_ids via --gpu/DG_GPU), so the llama.cpp GPU-lib # check is irrelevant for them (#7188). - if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread( + if not _gguf_confirmed_diffusion and await asyncio.to_thread( _llama_backend._backend_lacks_gpu_lib ): raise HTTPException( @@ -5281,6 +5290,10 @@ async def validate_model( _loaded_llama = get_llama_cpp_backend() _gguf_vulkan_build = await asyncio.to_thread(_loaded_llama.is_vulkan_build) + # Mirror /load: a confirmed diffusion GGUF uses the visual-server runner (CUDA + # ids via --gpu/DG_GPU), not the Vulkan llama-server, so route its gpu_ids + # through the CUDA resolver even on a Vulkan build (#7188). + _gguf_confirmed_diffusion = _classify_diffusion_gguf(config) is True if get_device() == DeviceType.XPU and not _gguf_vulkan_build: raise HTTPException( @@ -5321,12 +5334,16 @@ async def validate_model( ) # A Vulkan build treats gpu_ids as Vulkan ordinals, so defer to the backend # probe even on a CUDA-visible host; otherwise keep the CUDA resolver (#6414/#7188). - if get_device() == DeviceType.CUDA and not _gguf_vulkan_build: + # A confirmed diffusion GGUF always takes the CUDA path: its runner uses CUDA ids, + # not the Vulkan llama-server (#7188). + if get_device() == DeviceType.CUDA and ( + not _gguf_vulkan_build or _gguf_confirmed_diffusion + ): # A CPU-only llama.cpp build ignores CUDA_VISIBLE_DEVICES; the CUDA resolver # can't see that, so reject a pin it would silently run on CPU (mirrors /load # and the non-CUDA resolvable check). Diffusion GGUF models bypass # llama-server, so the llama.cpp GPU-lib check is irrelevant (#7188). - if _classify_diffusion_gguf(config) is not True and await asyncio.to_thread( + if not _gguf_confirmed_diffusion and await asyncio.to_thread( _loaded_llama._backend_lacks_gpu_lib ): raise HTTPException( diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 448b30fcae..81df2df04a 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1240,6 +1240,75 @@ class TestRouteErrors(unittest.TestCase): self.assertNotIn("cpu-only build", exc_info.exception.detail.lower()) self.assertIn("SENTINEL_AFTER_GATE", exc_info.exception.detail) + def test_diffusion_gguf_on_vulkan_build_uses_cuda_path(self): + # A confirmed diffusion GGUF on a CUDA host whose llama-server is a Vulkan build must + # validate gpu_ids through the CUDA physical-id resolver (the diffusion runner takes a + # CUDA --gpu/DG_GPU id), NOT the Vulkan-ordinal branch, which would size the wrong + # device namespace and reject a valid physical pick (#7188). + from unittest.mock import MagicMock + + import utils.hardware as hardware_pkg + + inference_route = _load_route_module( + "inference_route_module_for_diffusion_cuda_path_test", + "routes/inference.py", + ) + request = LoadRequest(model_path = "unsloth/diffusion.gguf", gpu_ids = [0, 1]) + model_config = SimpleNamespace( + is_gguf = True, + is_lora = False, + gguf_hf_repo = None, + gguf_file = "/tmp/diffusion.gguf", + gguf_mmproj_file = None, + gguf_variant = None, + identifier = "unsloth/diffusion.gguf", + display_name = "unsloth/diffusion.gguf", + is_vision = False, + is_audio = False, + audio_type = None, + has_audio_input = False, + ) + assert_resolvable = MagicMock() # the Vulkan-ordinal validator; must NOT be called + fake_backend = SimpleNamespace( + is_loaded = False, + model_identifier = None, + is_vulkan_build = lambda: True, + _backend_lacks_gpu_lib = lambda *a, **k: False, + has_gpu_backend = lambda *a, **k: True, + assert_requested_gpu_ids_resolvable = assert_resolvable, + ) + with ( + patch.object( + inference_route, + "ModelConfig", + SimpleNamespace(from_identifier = lambda **_kwargs: model_config), + ), + patch.object(inference_route, "_classify_diffusion_gguf", return_value = True), + patch.object( + _hw_module, + "resolve_requested_gpu_ids", + side_effect = ValueError("CUDA_PATH_SENTINEL"), + ), + patch.object(inference_route, "_guard_chat_load_against_training", return_value = None), + patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), + patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext), + patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend), + patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CUDA), + ): + with self.assertRaises(HTTPException) as exc_info: + asyncio.run( + inference_route._load_model_impl( + request, + SimpleNamespace( + app = SimpleNamespace(state = SimpleNamespace(llama_parallel_slots = 1)), + ), + current_subject = "test-user", + ) + ) + # Took the CUDA physical-id resolver, never the Vulkan-ordinal validator. + self.assertIn("CUDA_PATH_SENTINEL", exc_info.exception.detail) + assert_resolvable.assert_not_called() + def test_inherited_extras_preserve_memory_flags_when_mode_omitted(self): # A same-model Apply that omits gguf_memory_mode must NOT drop an inherited # --mlock/--no-mmap pass-through (opt-in memory stripping), while a first-class From f5fc2c5e10f5e83ad4197d113bb34cb08964c643 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:29:26 -0300 Subject: [PATCH 018/110] Keep llama.cpp fit headroom --- studio/backend/core/inference/llama_cpp.py | 14 +++++--------- studio/backend/tests/test_gpu_memory_mode.py | 10 +++++----- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 6ce835f00f..fbac31e2d6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -10476,10 +10476,11 @@ class LlamaCppBackend: windows of ``-c / N``; restore the shared pool so one request can use the full context. With ``--fit on``, ``--fit-ctx`` floors the fit step at an explicitly requested ctx so it offloads or fails instead of - silently shrinking the window. The 8192 auto-floor and the tighter - ``--fit-target`` margin apply only under Manual + Auto (``auto_fit``), - which omits ``-c``: on the legacy auto path ``-c 0`` already pins the - native window and ``--fit-ctx 8192`` would override it down to 8192. + silently shrinking the window. The 8192 auto-floor applies only under + Manual + Auto (``auto_fit``), which omits ``-c``: on the legacy auto + path ``-c 0`` already pins the native window and ``--fit-ctx 8192`` + would override it down to 8192. Keep llama.cpp's default fit target + instead of reducing its safety margin. """ flags: list[str] = [] if n_parallel > 1 and caps.get("supports_kv_unified"): @@ -10492,11 +10493,6 @@ class LlamaCppBackend: # Manual + Auto omits -c, so floor at 8192 so --fit doesn't # shrink the window below a usable size. flags.extend(["--fit-ctx", "8192"]) - if use_fit and auto_fit and caps.get("supports_fit_target"): - # llama.cpp's --fit leaves 1 GiB free per device by default; - # tighten that to 512 MiB so it packs more of the model onto - # the GPU before spilling to system RAM. - flags.extend(["--fit-target", "512"]) return flags def _query_server_n_ctx(self) -> Optional[int]: diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index 271a882b11..e5865dc955 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -563,13 +563,13 @@ def test_manual_allows_tensor_parallel_via_split_mode(): assert "if tp_tensor_split and len(tp_tensor_split) > 1:" in src -def test_fit_sets_target_margin(): - # Manual + Auto (auto_fit) tightens the per-device VRAM margin to 512 MiB. +def test_fit_keeps_upstream_target_margin(): + # Manual + Auto must keep llama.cpp's default fit target. Reducing its + # headroom is especially risky on integrated GPUs that share system RAM. caps = {"supports_fit_target": True} flags = LlamaCppBackend._ctx_integrity_flags(1, True, True, 0, 0, caps) - assert flags[flags.index("--fit-target") + 1] == "512" - # Not emitted on the legacy auto path (fit on but not auto_fit): -c 0 pins - # native there, so the tighter margin must not ride along. + assert "--fit-target" not in flags + # Not emitted on the legacy auto path (fit on but not auto_fit). assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, True, False, 0, 0, caps) # Not emitted when fit is off. assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, False, False, 0, 0, caps) From e3afd5a70b80ec925cb43a432a80cf6d10e98450 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:32:43 -0300 Subject: [PATCH 019/110] Adapt host memory modes to llama.cpp load mode --- studio/backend/core/inference/llama_cpp.py | 39 ++++++++-- .../core/inference/llama_server_args.py | 21 +++++- studio/backend/models/inference.py | 7 +- .../tests/test_llama_cpp_memory_mode.py | 71 +++++++++++++++++-- .../tests/test_llama_cpp_mtp_detection.py | 14 ++++ .../backend/tests/test_llama_server_args.py | 11 ++- 6 files changed, 147 insertions(+), 16 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index fbac31e2d6..d88d1101c0 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2838,6 +2838,7 @@ class LlamaCppBackend: "supports_kv_unified": False, "supports_fit_ctx": False, "supports_fit_target": False, + "supports_load_mode": False, "supports_cache_ram": False, "supports_ctx_checkpoints": False, "supports_no_cache_prompt": False, @@ -2859,6 +2860,7 @@ class LlamaCppBackend: supports_kv_unified = False supports_fit_ctx = False supports_fit_target = False + supports_load_mode = False supports_cache_ram = False supports_ctx_checkpoints = False supports_no_cache_prompt = False @@ -2964,6 +2966,7 @@ class LlamaCppBackend: supports_kv_unified = _is_real("--kv-unified") supports_fit_ctx = _is_real("--fit-ctx") supports_fit_target = _is_real("--fit-target") + supports_load_mode = _is_real("--load-mode") supports_cache_ram = _is_real("--cache-ram") supports_ctx_checkpoints = _is_real("--ctx-checkpoints") supports_no_cache_prompt = _is_real("--no-cache-prompt") @@ -3000,6 +3003,7 @@ class LlamaCppBackend: "supports_kv_unified": supports_kv_unified, "supports_fit_ctx": supports_fit_ctx, "supports_fit_target": supports_fit_target, + "supports_load_mode": supports_load_mode, "supports_cache_ram": supports_cache_ram, "supports_ctx_checkpoints": supports_ctx_checkpoints, "supports_no_cache_prompt": supports_no_cache_prompt, @@ -6461,17 +6465,28 @@ class LlamaCppBackend: return mode @staticmethod - def _memory_mode_flags(memory_mode: Optional[str]) -> List[str]: + def _memory_mode_flags( + memory_mode: Optional[str], *, supports_load_mode: bool = False + ) -> List[str]: """Return the llama-server flags for a GGUF memory placement mode. - - "pinned" -> --mlock (lock mmap'd pages in RAM). - - "resident" -> --no-mmap --mlock (load fully into RAM and lock). + New llama.cpp builds use the unified --load-mode flag. Older builds + retain the deprecated mmap/mlock flags. + + - "pinned" -> load-mode mlock, or legacy --mlock. + - "resident" -> load-mode none, or legacy --no-mmap --mlock. - otherwise -> [] (llama.cpp default, memory-mapped file). """ mode = LlamaCppBackend._canonical_memory_mode(memory_mode) if mode == "pinned": + if supports_load_mode: + return ["--load-mode", "mlock"] return ["--mlock"] if mode == "resident": + if supports_load_mode: + # Unified load modes cannot combine non-mmap loading with + # mlock. "none" preserves the non-mmap part of this mode. + return ["--load-mode", "none"] return ["--no-mmap", "--mlock"] return [] @@ -7923,10 +7938,17 @@ class LlamaCppBackend: elif not auto_fit: cmd.extend(["-c", "0"]) + server_caps = self.probe_server_capabilities(binary) + # Memory placement: keep weights resident so idle weights aren't paged # out and re-faulted from disk (#7164). Emitted as a CMD flag (not env) # so it side-steps _clear_manual_placement_env in manual mode. - cmd.extend(self._memory_mode_flags(memory_mode)) + cmd.extend( + self._memory_mode_flags( + memory_mode, + supports_load_mode = bool(server_caps.get("supports_load_mode")), + ) + ) # Report a clean public model id (matching GET /v1/models) rather # than the raw -m path in llama-server's own /v1/models and the @@ -8003,7 +8025,6 @@ class LlamaCppBackend: cmd.extend(["-ngl", "-1", "--fit", "off"]) fully_gpu_offloaded = True - server_caps = self.probe_server_capabilities(binary) # Expose Prometheus /metrics for the engine-stats logger, only # when the binary advertises it (older/custom binaries may not). if server_caps.get("supports_metrics"): @@ -8311,7 +8332,13 @@ class LlamaCppBackend: # launched WITH inherited placement flags so a later explicit 'auto' reloads # to clear them (the dedup treats 'auto' and None alike and would otherwise # leave it mlocked). Mirrors the threads / split / cache scrubs. - _mm_vars = ("LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_MMAP") + _mm_vars = ( + "LLAMA_ARG_LOAD_MODE", + "LLAMA_ARG_MLOCK", + "LLAMA_ARG_NO_MMAP", + "LLAMA_ARG_MMAP", + "LLAMA_ARG_DIO", + ) if memory_mode is not None: for _mm_var in _mm_vars: env.pop(_mm_var, None) diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index 41ed081313..e54dc88686 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -178,8 +178,21 @@ _TEMPLATE_FLAGS: frozenset[str] = frozenset( } ) # Shadow the GGUF memory_mode field: pass-through in explicit extras, stripped on -# inherit when the mode changes. --mmap is the inverse of --no-mmap. -_MEMORY_MODE_FLAGS: frozenset[str] = frozenset({"--mlock", "--no-mmap", "--mmap"}) +# inherit when the mode changes. New llama.cpp builds unify these under +# --load-mode; retain every deprecated spelling for older/custom binaries. +_MEMORY_MODE_FLAGS: frozenset[str] = frozenset( + { + "-lm", + "--load-mode", + "--mlock", + "--no-mmap", + "--mmap", + "-dio", + "--direct-io", + "-ndio", + "--no-direct-io", + } +) # Multi-GPU split mode shadows the Tensor Parallelism toggle # (--split-mode tensor). Pass-through stays allowed so users keep the # row/none/layer modes the toggle doesn't expose, but it's stripped on @@ -226,6 +239,10 @@ _BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset( "--mlock", "--no-mmap", "--mmap", + "-dio", + "--direct-io", + "-ndio", + "--no-direct-io", } ) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index bb2fdf633c..d536653c14 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -180,9 +180,10 @@ class LoadRequest(BaseModel): "control system RAM residency and file mapping on the host, NOT GPU VRAM " "placement, so they do not by themselves keep offloaded weights pinned in " "VRAM. 'auto' (default) uses llama.cpp's normal memory-mapped loading. " - "'pinned' adds --mlock so the OS cannot page the model's host pages out. " - "'resident' loads the full model into RAM with --no-mmap and locks it with " - "--mlock, avoiding any file-backed mapping. Ignored for non-GGUF models." + "'pinned' locks memory-mapped host pages so the OS cannot page them out. " + "'resident' avoids file-backed mapping and loads the model into RAM. On " + "llama.cpp builds with unified load modes it cannot also be locked, so the " + "OS may still swap it. Ignored for non-GGUF models." ), ) diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index b1c8a6b373..36e37ef971 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -13,6 +13,8 @@ import types as _types from pathlib import Path from unittest.mock import patch +_REAL_POPEN = subprocess.Popen + _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) @@ -162,6 +164,22 @@ def test_memory_mode_flags_maps_modes(mode, expected): assert LlamaCppBackend._memory_mode_flags(mode) == expected +@pytest.mark.parametrize( + "mode,expected", + [ + (None, []), + ("auto", []), + ("pinned", ["--load-mode", "mlock"]), + ("resident", ["--load-mode", "none"]), + ], +) +def test_memory_mode_flags_use_unified_load_mode(mode, expected): + assert ( + LlamaCppBackend._memory_mode_flags(mode, supports_load_mode = True) + == expected + ) + + # ── _already_in_target_state ───────────────────────────────────────────────── @@ -258,6 +276,9 @@ def test_empty_probe_preserves_explicit_gpu_ids(tmp_path): captured = {} def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -312,6 +333,9 @@ def test_torchless_vulkan_populated_probe_uses_identity_ordinals(tmp_path): captured = {} def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 321 @@ -393,6 +417,9 @@ def test_vulkan_gpu_ids_strips_conflicting_user_device(tmp_path): captured = {} def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 999 @@ -498,6 +525,9 @@ def test_gpu_ids_preserved_on_fit_fallback(tmp_path): captured_envs = [] def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -554,6 +584,9 @@ def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_id captured_envs = [] def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -599,9 +632,11 @@ def test_memory_mode_clears_inherited_mmap_env_vars(tmp_path): base_env = dict(os.environ) base_env.update( { + "LLAMA_ARG_LOAD_MODE": "dio", "LLAMA_ARG_MLOCK": "1", "LLAMA_ARG_MMAP": "1", "LLAMA_ARG_NO_MMAP": "1", + "LLAMA_ARG_DIO": "1", } ) backend._llama_server_env_for_binary = lambda _binary: dict(base_env) @@ -609,6 +644,9 @@ def test_memory_mode_clears_inherited_mmap_env_vars(tmp_path): captured_envs = [] def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -629,9 +667,14 @@ def test_memory_mode_clears_inherited_mmap_env_vars(tmp_path): assert captured_envs, "llama-server was not spawned" env = captured_envs[-1] - assert "LLAMA_ARG_MLOCK" not in env - assert "LLAMA_ARG_MMAP" not in env - assert "LLAMA_ARG_NO_MMAP" not in env + for var in ( + "LLAMA_ARG_LOAD_MODE", + "LLAMA_ARG_MLOCK", + "LLAMA_ARG_MMAP", + "LLAMA_ARG_NO_MMAP", + "LLAMA_ARG_DIO", + ): + assert var not in env @pytest.mark.parametrize( @@ -654,6 +697,9 @@ def test_memory_mode_strips_conflicting_extra_args(tmp_path, mode, user_flag, wi captured_cmds = [] def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -717,6 +763,9 @@ def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): captured_cmds = [] def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -920,6 +969,9 @@ def test_explicit_gpu_ids_strips_stored_device_extra_args(tmp_path): backend._select_gpus = lambda requested_total, gpus, **k: ([gpus[0][0]], False) def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -1076,6 +1128,8 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") monkeypatch.setenv("LLAMA_ARG_NO_MMAP", "1") monkeypatch.setenv("LLAMA_ARG_MMAP", "true") + monkeypatch.setenv("LLAMA_ARG_LOAD_MODE", "dio") + monkeypatch.setenv("LLAMA_ARG_DIO", "1") gguf = tmp_path / "model.gguf" _write_minimal_gguf(gguf) @@ -1084,6 +1138,9 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru captured_envs = [] def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + class _FakePopen: pid = 12345 @@ -1104,7 +1161,13 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru assert captured_envs, "llama-server was not spawned" env = captured_envs[-1] - for var in ("LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_MMAP"): + for var in ( + "LLAMA_ARG_LOAD_MODE", + "LLAMA_ARG_MLOCK", + "LLAMA_ARG_NO_MMAP", + "LLAMA_ARG_MMAP", + "LLAMA_ARG_DIO", + ): assert (var not in env) == scrubbed diff --git a/studio/backend/tests/test_llama_cpp_mtp_detection.py b/studio/backend/tests/test_llama_cpp_mtp_detection.py index 27c1b17a85..e6061b41a0 100644 --- a/studio/backend/tests/test_llama_cpp_mtp_detection.py +++ b/studio/backend/tests/test_llama_cpp_mtp_detection.py @@ -780,6 +780,7 @@ def test_probe_server_capabilities_handles_missing_binary(): assert caps["supports_cache_ram"] is False assert caps["supports_ctx_checkpoints"] is False assert caps["supports_no_cache_prompt"] is False + assert caps["supports_load_mode"] is False # ngram-mod flag flavor detection (new vs legacy llama-server). @@ -820,6 +821,11 @@ _CACHE_FLAGS_HELP = """\ --no-cache-prompt do not reuse prompt cache """ +_LOAD_MODE_HELP = """\ +-lm, --load-mode MODE model loading mode (default: mmap) + (env: LLAMA_ARG_LOAD_MODE) +""" + @_NEEDS_BASH def test_probe_detects_post_rename_ngram_mod_flavor(tmp_path): @@ -886,6 +892,14 @@ def test_probe_reports_windows_cache_flags_absent_for_older_binary(tmp_path): assert caps["supports_no_cache_prompt"] is False +@_NEEDS_BASH +def test_probe_detects_unified_load_mode(tmp_path): + fake = _make_fake_llama_server(tmp_path / "llama-server", _LOAD_MODE_HELP) + _clear_caps_cache() + caps = LlamaCppBackend.probe_server_capabilities(str(fake)) + assert caps["supports_load_mode"] is True + + @_NEEDS_BASH def test_probe_detects_slot_save_path(tmp_path): fake = _make_fake_llama_server( diff --git a/studio/backend/tests/test_llama_server_args.py b/studio/backend/tests/test_llama_server_args.py index b604df1539..2b5bcdd8c7 100644 --- a/studio/backend/tests/test_llama_server_args.py +++ b/studio/backend/tests/test_llama_server_args.py @@ -905,7 +905,16 @@ def test_strip_shadowing_flags_keeps_model_draft_without_spec(): def test_strip_shadowing_flags_drops_memory_mode_when_requested(): out = strip_shadowing_flags( - ["--mlock", "--no-mmap", "--mmap", "--top-k", "20"], + [ + "--load-mode", + "dio", + "--mlock", + "--no-mmap", + "--mmap", + "--direct-io", + "--top-k", + "20", + ], strip_memory_mode = True, ) assert out == ["--top-k", "20"] From 35e0fb04e362ce11f24ce497fd71f37f3de72f19 Mon Sep 17 00:00:00 2001 From: alkinun Date: Wed, 22 Jul 2026 09:50:11 +0300 Subject: [PATCH 020/110] feat(studio): expose GGUF host memory controls --- .../src/features/chat/api/chat-adapter.ts | 11 ++++++ .../chat/hooks/use-chat-model-runtime.ts | 12 ++---- .../lib/apply-inference-status-to-store.ts | 17 ++++---- .../src/features/chat/shared-composer.tsx | 3 ++ .../chat/stores/chat-runtime-store.ts | 16 ++++---- .../components/model-config-page.tsx | 39 ++++++++++++++++++- .../hooks/use-active-model-config.ts | 3 ++ .../model-config/apply-per-model-config.ts | 3 ++ .../model-config/per-model-config.ts | 20 +++++++++- 9 files changed, 99 insertions(+), 25 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index b7323777b2..acb4028bb9 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1501,6 +1501,7 @@ async function autoLoadSmallestModel(): Promise<{ // The safetensors fallback omits both fields and uses HF auto-placement. gpu_ids?: number[]; gpu_memory_mode?: "auto" | "manual"; + gguf_memory_mode?: "auto" | "pinned" | "resident" | null; }): Promise { const validation = await validateModel({ ...payload, @@ -1565,6 +1566,7 @@ async function autoLoadSmallestModel(): Promise<{ config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds(config.selectedGpuIds) : null; + const effectiveMemoryMode = config.ggufMemoryMode ?? null; // Under Manual GPU memory + Auto layers, llama.cpp's --fit owns context // sizing, so send 0 (or the pinned length). GGUF-only; a no-op otherwise. // The context pin is per-model too, so it comes from the saved config, not @@ -1594,6 +1596,7 @@ async function autoLoadSmallestModel(): Promise<{ ? { gpu_ids: effectiveGpuIds ?? undefined, gpu_memory_mode: effectiveGpuMemoryMode, + gguf_memory_mode: effectiveMemoryMode, } : {}), })) @@ -1627,6 +1630,7 @@ async function autoLoadSmallestModel(): Promise<{ gpu_layers: effectiveGpuLayers, n_cpu_moe: effectiveNCpuMoe, gpu_ids: effectiveGpuIds ?? undefined, + gguf_memory_mode: effectiveMemoryMode, } : {}), }); @@ -1922,6 +1926,11 @@ async function autoLoadSmallestModel(): Promise<{ }); try { const rt = useChatRuntimeStore.getState(); + const { config: defaultConfig } = resolveInitialConfig( + "unsloth/Qwen3.5-4B-MTP-GGUF", + "UD-Q4_K_XL", + ); + const defaultMemoryMode = defaultConfig.ggufMemoryMode ?? null; if ( !(await canAutoLoad({ model_path: "unsloth/Qwen3.5-4B-MTP-GGUF", @@ -1932,6 +1941,7 @@ async function autoLoadSmallestModel(): Promise<{ // model has no remembered settings to prefer). gpu_ids: rt.selectedGpuIds ?? undefined, gpu_memory_mode: rt.gpuMemoryMode, + gguf_memory_mode: defaultMemoryMode, })) ) { toast.dismiss(toastId); @@ -1962,6 +1972,7 @@ async function autoLoadSmallestModel(): Promise<{ gpu_layers: GPU_LAYERS_AUTO, n_cpu_moe: 0, gpu_ids: rt.selectedGpuIds ?? undefined, + gguf_memory_mode: defaultMemoryMode, }); saveSpeculativeType(specSettings.speculativeType); persistGpuMemoryModeOnLoad(loadResp, rt.gpuMemoryMode); diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 880708ce19..0e856ebd6f 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -669,6 +669,7 @@ export function useChatModelRuntime() { let loadSelectedGpuIds = reconcilePersistedGpuIds( stateBeforeUnload.selectedGpuIds, ); + let loadMemoryMode = stateBeforeUnload.ggufMemoryMode; let loadSpeculativeType = stateBeforeUnload.speculativeType; let loadSpecDraftNMax = stateBeforeUnload.specDraftNMax; try { @@ -692,15 +693,6 @@ export function useChatModelRuntime() { const resetsPerModelSettings = Boolean( currentCheckpoint && switchingModelOrVariant && !keepSpeculative, ); - // GGUF host-memory residency is per-model and backend-owned (no UI - // editor): activeMemoryMode only mirrors the loaded model's /status. - // On a model/variant switch, reusing it would pin the new model to - // the prior model's residency, so drop to auto (null); a same-model - // Apply/reload keeps it so an API-set value survives the reload. - const loadMemoryMode = - currentCheckpoint && switchingModelOrVariant - ? null - : stateBeforeUnload.activeMemoryMode; const validateCustomContextLength = resetsPerModelSettings ? null : loadCustomContextLength; @@ -813,6 +805,7 @@ export function useChatModelRuntime() { // Per-model GPU knobs must not follow onto a different model // (gpuMemoryMode is a standing preference and is kept). selectedGpuIds: null, + ggufMemoryMode: null, gpuLayers: GPU_LAYERS_AUTO, nCpuMoe: 0, splitRatio: null, @@ -827,6 +820,7 @@ export function useChatModelRuntime() { // previous model's (gpuMemoryMode is standing, so left as captured). loadCustomContextLength = null; loadSelectedGpuIds = null; + loadMemoryMode = null; loadGpuLayers = GPU_LAYERS_AUTO; loadNCpuMoe = 0; loadSplitRatio = null; diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index 75133cbc06..2bc06ba95f 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -222,8 +222,9 @@ export function applyActiveModelStatusToStore( incomingGpuMode === "manual" ? (status.n_cpu_moe ?? null) : null; const incomingSplit = incomingGpuMode === "manual" ? (status.tensor_split ?? null) : null; - const incomingGpuIds = status.is_gguf - ? (status.requested_gpu_ids ?? status.gpu_ids ?? null) + const incomingGpuIds = status.is_gguf ? (status.gpu_ids ?? null) : null; + const incomingMemoryMode = status.is_gguf + ? (status.gguf_memory_mode ?? null) : null; const gpuStatusChanged = prevState.loadedGpuMemoryMode !== incomingGpuMode || @@ -231,6 +232,7 @@ export function applyActiveModelStatusToStore( prevState.loadedNCpuMoe !== incomingNCpuMoe || !sameArray(prevState.loadedSplitRatio, incomingSplit) || !sameArray(prevState.loadedGpuIds, incomingGpuIds) || + prevState.activeMemoryMode !== incomingMemoryMode || prevState.loadedCustomContextLength !== gpuPin; const gpuMemoryEditsPending = (prevState.loadedGpuMemoryMode !== null && @@ -244,6 +246,8 @@ export function applyActiveModelStatusToStore( prevState.selectedGpuIds, prevState.loadedGpuIds, ); + const memoryModeEditPending = + prevState.ggufMemoryMode !== prevState.activeMemoryMode; const incomingGpuFields = loadedGpuMemoryFields(status); // A same-model reload from another client advances every loaded baseline. // Preserve each editable group only when this tab has an unapplied change. @@ -262,6 +266,8 @@ export function applyActiveModelStatusToStore( }), ...(preserveSameModelEdits && gpuIdsEditPending && { selectedGpuIds: prevState.selectedGpuIds }), + ...(preserveSameModelEdits && + memoryModeEditPending && { ggufMemoryMode: prevState.ggufMemoryMode }), }; useChatRuntimeStore.setState({ @@ -330,11 +336,8 @@ export function applyActiveModelStatusToStore( hydratingExistingModel || gpuStatusChanged) && gpuStatusFields), - // GGUF host-memory residency is backend-owned (no UI editor), so mirror - // /status's gguf_memory_mode whenever load params are seeded, not only on - // first hydration or a model change. Otherwise an external reload of the - // same checkpoint with a different residency leaves a stale value for the - // next same-model Apply to resend. Non-GGUF status carries null. + // Always advance the loaded baseline. gpuStatusFields above updates the + // editable value too, while preserving a same-model local edit. ...(seedLoadParams && { activeMemoryMode: status.gguf_memory_mode ?? null, }), diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 3f89f70ef1..6ed536d007 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -1059,6 +1059,7 @@ export function SharedComposer({ ownConfig.selectedGpuIds !== undefined ? reconcilePersistedGpuIds(ownConfig.selectedGpuIds) : compareLoadKnobs.selectedGpuIds; + const effectiveMemoryMode = ownConfig.ggufMemoryMode ?? null; // A pane's context comes from its own config only: a saved pin, or null // (Auto/native). It must not inherit the active model's shared snapshot -- // resolveFitMaxSeqLength would treat that as a pin and load this pane at @@ -1103,6 +1104,7 @@ export function SharedComposer({ ? { gpu_ids: effectiveSelectedGpuIds ?? undefined, gpu_memory_mode: effectiveGpuMemoryMode, + gguf_memory_mode: effectiveMemoryMode, } : {}), }); @@ -1171,6 +1173,7 @@ export function SharedComposer({ n_cpu_moe: effectiveNCpuMoe, tensor_split: compareLoadKnobs.splitRatio ?? undefined, gpu_ids: effectiveSelectedGpuIds ?? undefined, + gguf_memory_mode: effectiveMemoryMode, } : {}), }); diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index a48d97222e..736668e0a1 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -630,8 +630,7 @@ export function loadedGpuMemoryFields(resp: { loadedSplitRatio: null, ggufLayerCount: null, moeLayerCount: null, - // Host-memory residency is meaningful only for GGUF; a non-GGUF load has - // no mode, so reset to null (see the GGUF branch's note below). + ggufMemoryMode: null, activeMemoryMode: resp.gguf_memory_mode ?? null, }; } @@ -679,9 +678,9 @@ export function loadedGpuMemoryFields(resp: { // The picker reflects the requested placement pool, not a fitted subset. selectedGpuIds: gpuIds, loadedGpuIds: gpuIds, - // Commit the residency the backend actually applied. GGUF host-memory - // residency is backend-owned (no UI editor); mirroring it here on every - // load path keeps activeMemoryMode from carrying a previous model's mode. + // Commit the residency the backend actually applied. Mirroring both the + // editable value and loaded baseline keeps every load path in sync. + ggufMemoryMode: resp.gguf_memory_mode ?? null, // Otherwise, after a switch (compare/auto/rollback load) the store keeps // the prior value, so an immediate same-model Apply could resend a stale // mode. Non-GGUF and auto loads report null, resetting it. @@ -978,9 +977,10 @@ type ChatRuntimeStore = { /** Picked physical GPU indices (null = use all / automatic). */ selectedGpuIds: number[] | null; loadedGpuIds: number[] | null; + /** Requested GGUF host-memory residency. null = backend/default behavior. */ + ggufMemoryMode: "auto" | "pinned" | "resident" | null; /** Backend-reported GGUF host-memory residency mode (--mlock/--no-mmap), - * mirrored read-only from /status. Set only via the API/extras; re-sent on a - * same-model reload so an API-set value is preserved. null = unset/auto. */ + * used as the loaded baseline for status hydration and rollback. */ activeMemoryMode: "auto" | "pinned" | "resident" | null; /** Persisted: when false, picking a local model stages it as * `pendingSelection` (and opens settings) instead of loading immediately, @@ -1439,6 +1439,7 @@ export const useChatRuntimeStore = create((set, get) => ({ moeLayerCount: null, selectedGpuIds: null, loadedGpuIds: null, + ggufMemoryMode: null, activeMemoryMode: null, loadOnSelection: loadBool(CHAT_LOAD_ON_SELECTION_KEY, true), expandQuantizations: loadBool(CHAT_EXPAND_QUANTIZATIONS_KEY, false), @@ -1695,6 +1696,7 @@ export const useChatRuntimeStore = create((set, get) => ({ moeLayerCount: null, selectedGpuIds: null, loadedGpuIds: null, + ggufMemoryMode: null, activeMemoryMode: null, loadedIsMultimodal: false, loadedIsDiffusion: false, diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index afbd33af7c..55890ceea1 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -82,7 +82,8 @@ function hasNonDefaultAdvanced(config: PerModelConfig): boolean { (config.gpuMemoryMode ?? "auto") !== "auto" || (config.gpuLayers != null && config.gpuLayers >= 0) || (config.nCpuMoe ?? 0) > 0 || - config.selectedGpuIds != null + config.selectedGpuIds != null || + config.ggufMemoryMode != null ); } @@ -387,6 +388,42 @@ function GpuMemorySettings({ )} +
+
+ Host Memory + + Default preserves inherited llama.cpp settings. Auto clears inherited + placement flags. Pinned locks mapped model pages in RAM. Resident also + disables memory mapping and loads the full model into locked RAM. + +
+ +
); } diff --git a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts index 9d09ee6897..afab3806b2 100644 --- a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts +++ b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts @@ -28,6 +28,7 @@ export function useActiveModelConfig(): ActiveModelConfigState { const gpuLayers = useChatRuntimeStore((s) => s.gpuLayers); const nCpuMoe = useChatRuntimeStore((s) => s.nCpuMoe); const selectedGpuIds = useChatRuntimeStore((s) => s.selectedGpuIds); + const ggufMemoryMode = useChatRuntimeStore((s) => s.ggufMemoryMode); const isGguf = activeGgufVariant != null || @@ -56,6 +57,7 @@ export function useActiveModelConfig(): ActiveModelConfigState { gpuLayers, nCpuMoe, selectedGpuIds, + ggufMemoryMode: ggufMemoryMode ?? undefined, }; }, [ checkpoint, @@ -71,6 +73,7 @@ export function useActiveModelConfig(): ActiveModelConfigState { gpuLayers, nCpuMoe, selectedGpuIds, + ggufMemoryMode, ]); return { checkpoint, isGguf, config }; diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index c21d3e164a..36d991825d 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -54,6 +54,7 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds(config.selectedGpuIds) : null, + ggufMemoryMode: config.ggufMemoryMode ?? null, }); } @@ -86,6 +87,7 @@ export function currentRuntimePerModelConfig( gpuLayers: s.gpuLayers, nCpuMoe: s.nCpuMoe, selectedGpuIds: s.selectedGpuIds, + ggufMemoryMode: s.ggufMemoryMode ?? undefined, }; } @@ -119,6 +121,7 @@ export function gpuFieldsSignature(config: PerModelConfig): string { config.selectedGpuIds == null ? "all" : [...config.selectedGpuIds].sort((a, b) => a - b).join(","), + config.ggufMemoryMode ?? "unset", ].join("|"); } diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts index ba6d4cec99..b3ef8cb6b5 100644 --- a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts @@ -25,6 +25,7 @@ export interface PerModelConfig { gpuLayers?: number; nCpuMoe?: number; selectedGpuIds?: number[] | null; + ggufMemoryMode?: "auto" | "pinned" | "resident"; } export const DEFAULT_PER_MODEL_CONFIG: PerModelConfig = { @@ -98,6 +99,7 @@ const STORED_CONFIG_FIELDS = new Set([ "gpuLayers", "nCpuMoe", "selectedGpuIds", + "ggufMemoryMode", ]); function normalizeGpuFields(partial: RawConfig): { @@ -105,12 +107,14 @@ function normalizeGpuFields(partial: RawConfig): { gpuLayers?: number; nCpuMoe?: number; selectedGpuIds?: number[] | null; + ggufMemoryMode?: "auto" | "pinned" | "resident"; } { const out: { gpuMemoryMode?: "auto" | "manual"; gpuLayers?: number; nCpuMoe?: number; selectedGpuIds?: number[] | null; + ggufMemoryMode?: "auto" | "pinned" | "resident"; } = {}; // Only "manual" is a real override; persisting "auto" would pin the model and // stop it following later changes to the global GPU Memory preference. @@ -140,6 +144,13 @@ function normalizeGpuFields(partial: RawConfig): { ) { out.selectedGpuIds = partial.selectedGpuIds.map((n) => Math.trunc(n)); } + if ( + partial.ggufMemoryMode === "auto" || + partial.ggufMemoryMode === "pinned" || + partial.ggufMemoryMode === "resident" + ) { + out.ggufMemoryMode = partial.ggufMemoryMode; + } return out; } @@ -308,6 +319,12 @@ function legacyEntryToConfig(raw: Record): PerModelConfig { : Array.isArray(raw.selectedGpuIds) ? (raw.selectedGpuIds as number[]) : undefined, + ggufMemoryMode: + raw.ggufMemoryMode === "auto" || + raw.ggufMemoryMode === "pinned" || + raw.ggufMemoryMode === "resident" + ? raw.ggufMemoryMode + : undefined, }); } @@ -611,7 +628,8 @@ function gpuFieldsAtDefault(config: PerModelConfig): boolean { (config.gpuMemoryMode ?? "auto") === "auto" && (config.gpuLayers == null || config.gpuLayers < 0) && (config.nCpuMoe == null || config.nCpuMoe === 0) && - config.selectedGpuIds == null + config.selectedGpuIds == null && + config.ggufMemoryMode == null ); } From cea1b675f6828d63f35fc12c06f046e7c88398ab Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:39:09 -0300 Subject: [PATCH 021/110] Clarify GGUF host memory semantics --- studio/backend/core/inference/llama_cpp.py | 7 ++++--- studio/frontend/src/features/chat/api/chat-api.ts | 4 ++-- .../src/features/chat/stores/chat-runtime-store.ts | 6 +++--- studio/frontend/src/features/chat/types/api.ts | 2 +- .../model-picker/components/model-config-page.tsx | 12 +++++++----- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index d88d1101c0..225f9fe0b6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -7940,9 +7940,10 @@ class LlamaCppBackend: server_caps = self.probe_server_capabilities(binary) - # Memory placement: keep weights resident so idle weights aren't paged - # out and re-faulted from disk (#7164). Emitted as a CMD flag (not env) - # so it side-steps _clear_manual_placement_env in manual mode. + # Host loading policy only. These flags control file mapping and + # system-RAM paging, not whether a GPU driver keeps offloaded + # tensors in VRAM. Emit them on the command line so they bypass + # _clear_manual_placement_env in manual mode. cmd.extend( self._memory_mode_flags( memory_mode, diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index e666ed08fa..26687f404f 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -169,8 +169,8 @@ export async function validateModel( // --fit, while a pinned layer count is owned by the user. Tell validate // so it applies the same training-guard policy as /load. gpu_memory_mode: payload.gpu_memory_mode, - // GGUF host-memory residency (--mlock/--no-mmap): backend-owned, no UI - // editor. Sent so validate's preflight matches the follow-up /load. + // GGUF host-memory loading policy. This is independent from GPU VRAM placement. + // Sent so validate's preflight matches the follow-up /load. gguf_memory_mode: payload.gguf_memory_mode ?? null, }), }); diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 736668e0a1..cda3339ec2 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -977,10 +977,10 @@ type ChatRuntimeStore = { /** Picked physical GPU indices (null = use all / automatic). */ selectedGpuIds: number[] | null; loadedGpuIds: number[] | null; - /** Requested GGUF host-memory residency. null = backend/default behavior. */ + /** Requested GGUF host-memory loading policy. null = backend/default behavior. */ ggufMemoryMode: "auto" | "pinned" | "resident" | null; - /** Backend-reported GGUF host-memory residency mode (--mlock/--no-mmap), - * used as the loaded baseline for status hydration and rollback. */ + /** Backend-reported GGUF host-memory loading mode, used as the loaded + * baseline for status hydration and rollback. */ activeMemoryMode: "auto" | "pinned" | "resident" | null; /** Persisted: when false, picking a local model stages it as * `pendingSelection` (and opens settings) instead of loading immediately, diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index f0e8a94ad2..5ca9b5c9a4 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -77,7 +77,7 @@ export interface LoadModelRequest { tensor_split?: number[] | null; /** Picked physical GPU indices (omit/empty = automatic). */ gpu_ids?: number[]; - /** GGUF host-memory placement: auto (default), pinned (--mlock), or resident (--no-mmap --mlock). */ + /** GGUF host-only loading policy. It does not control GPU VRAM residency. */ gguf_memory_mode?: "auto" | "pinned" | "resident" | null; } diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 55890ceea1..efca3e040a 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -392,9 +392,11 @@ function GpuMemorySettings({
Host Memory - Default preserves inherited llama.cpp settings. Auto clears inherited - placement flags. Pinned locks mapped model pages in RAM. Resident also - disables memory mapping and loads the full model into locked RAM. + Controls host RAM only, not whether a GPU driver keeps weights in VRAM. + Default preserves inherited llama.cpp settings. Auto uses normal + memory-mapped loading. Locked RAM prevents mapped host pages from being + swapped. RAM copy disables memory mapping, but newer llama.cpp builds + cannot also lock that copy.
From 92688e4b982877111a2bf24f3c42673314db58f3 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:42:06 -0300 Subject: [PATCH 022/110] Expose Vulkan ordinals in the GGUF GPU picker --- .../backend/core/inference/_vulkan_probe.py | 32 ++++--- studio/backend/core/inference/llama_cpp.py | 89 +++++++++++++++---- studio/backend/main.py | 24 +++-- .../tests/test_llama_cpp_vulkan_probe.py | 29 +++++- .../components/model-config-page.tsx | 2 +- studio/frontend/src/hooks/use-gpu-info.ts | 32 +++---- studio/frontend/src/hooks/use-system.ts | 4 +- 7 files changed, 153 insertions(+), 59 deletions(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index e50a019641..d23a481e95 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -6,7 +6,8 @@ Run in a short-lived subprocess (``python _vulkan_probe.py ``) so the Vulkan instance never lives in the long-running backend process. Loads the bundled ggml Vulkan backend from ```` and prints one -``\\t\\t\\t`` line per device to stdout. +``\\t\\t\\t\\t`` line per device +to stdout. Indices are ggml's own Vulkan device ordinals, which need not match nvidia-smi order. ``is_igpu`` (from ggml's device type) is ``1`` for an integrated GPU sharing system RAM. ``total_bytes`` is the device-local heap; the reader uses @@ -24,15 +25,16 @@ import sys _GGML_BACKEND_DEVICE_TYPE_IGPU = 2 -def _igpu_flags(base, lib, count: int) -> list[bool]: - """Per-device integrated-GPU flags via ggml's backend registry. +def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: + """Per-device integrated-GPU flags and names via ggml's registry. The Vulkan reg enumerates devices in the same order as ``ggml_backend_vk_get_device_memory`` (each context uses ``ctx->device = - i``), so reg index == device ordinal. Returns all-False on any failure so - the reader never over-caps a discrete card. + i``), so reg index == device ordinal. Missing metadata degrades to an + all-False flag list and stable ``VulkanN`` names. """ flags = [False] * count + names = [f"Vulkan{i}" for i in range(count)] try: lib.ggml_backend_vk_reg.restype = ctypes.c_void_p lib.ggml_backend_vk_reg.argtypes = [] @@ -42,20 +44,25 @@ def _igpu_flags(base, lib, count: int) -> list[bool]: base.ggml_backend_reg_dev_get.argtypes = [ctypes.c_void_p, ctypes.c_size_t] base.ggml_backend_dev_type.restype = ctypes.c_int base.ggml_backend_dev_type.argtypes = [ctypes.c_void_p] + base.ggml_backend_dev_name.restype = ctypes.c_char_p + base.ggml_backend_dev_name.argtypes = [ctypes.c_void_p] reg = lib.ggml_backend_vk_reg() if not reg: - return flags + return flags, names dev_count = base.ggml_backend_reg_dev_count(reg) for i in range(min(count, dev_count)): dev = base.ggml_backend_reg_dev_get(reg, i) if dev: flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU + raw_name = base.ggml_backend_dev_name(dev) + if raw_name: + name = raw_name.decode("utf-8", errors = "replace") + names[i] = name.replace("\t", " ").replace("\r", " ").replace("\n", " ") except Exception: - # Best-effort: any failure degrades to "discrete" so the memory - # readings still get through instead of crashing the probe. + # Best-effort metadata must never suppress the memory readings. pass - return flags + return flags, names def main() -> int: @@ -118,12 +125,15 @@ def main() -> int: ] count = lib.ggml_backend_vk_get_device_count() - igpu = _igpu_flags(base, lib, count) + igpu, names = _device_metadata(base, lib, count) rows = [] for i in range(count): free, total = ctypes.c_size_t(0), ctypes.c_size_t(0) lib.ggml_backend_vk_get_device_memory(i, ctypes.byref(free), ctypes.byref(total)) - rows.append("%d\t%d\t%d\t%d" % (i, free.value, int(igpu[i]), total.value)) + rows.append( + "%d\t%d\t%d\t%d\t%s" + % (i, free.value, int(igpu[i]), total.value, names[i]) + ) sys.stdout.write("\n".join(rows)) return 0 diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 225f9fe0b6..9938f58b55 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3663,18 +3663,14 @@ class LlamaCppBackend: return [] @staticmethod - def _get_gpu_free_memory_vulkan(binary: Optional[str] = None) -> list[tuple[int, int, int]]: - """Query free (and total) VRAM per device via the bundled ggml Vulkan backend. + def _probe_vulkan_devices( + binary: Optional[str] = None, + ) -> list[tuple[int, int, bool, int, str]]: + """Return raw Vulkan device rows from a short-lived ggml probe. - Loads ``libggml-vulkan`` in a short-lived subprocess (no Vulkan instance - in this process) and returns (device_index, free_mib, total_mib) sorted - by index. The index is ggml's compact Vulkan ordinal -- the one the - registry names ``Vulkan`` and load_model pins with ``--device``, - NOT the raw ``GGML_VK_VISIBLE_DEVICES`` space. A user-set - ``GGML_VK_VISIBLE_DEVICES`` is honored by ggml (passed through), so the - list already reflects it. iGPUs leave a host-RAM margin (see - ``_apply_igpu_host_reserve_mib``) and report total 0; discrete cards pass - their real total through. [] when no Vulkan build or device is reachable. + Each row is ``(ordinal, free_bytes, is_igpu, total_bytes, name)``. + The ordinal is ggml's compact ``VulkanN`` index after any inherited + ``GGML_VK_VISIBLE_DEVICES`` filter. """ binary = binary or LlamaCppBackend._find_llama_server_binary() if not binary: @@ -3721,21 +3717,75 @@ class LlamaCppBackend: logger.debug(f"vulkan GPU probe failed: {e}") return [] - gpus: list[tuple[int, int, int]] = [] + devices: list[tuple[int, int, bool, int, str]] = [] for line in result.stdout.strip().splitlines(): parts = line.split("\t") - if len(parts) != 4: + if len(parts) < 4: continue try: idx = int(parts[0]) - free_mib = int(parts[1]) // (1024 * 1024) + free_bytes = int(parts[1]) is_igpu = parts[2] == "1" - # iGPU "total" is shared RAM, not a VRAM budget -> keep 0 so the - # fit stays on free*frac (the host reserve below is its - # headroom); a discrete card passes its real total through. - total_mib = 0 if is_igpu else int(parts[3]) // (1024 * 1024) + total_bytes = int(parts[3]) except ValueError: continue + name = parts[4].strip() if len(parts) >= 5 else "" + devices.append((idx, free_bytes, is_igpu, total_bytes, name or f"Vulkan{idx}")) + devices.sort(key = lambda row: row[0]) + return devices + + @staticmethod + def _get_vulkan_gpu_info(binary: Optional[str] = None) -> list[dict]: + """Vulkan-ordinal device records suitable for ``/api/system``. + + Unlike CUDA/HIP physical IDs, these indices are explicitly tagged + ``vulkan`` so the frontend can offer them only to the GGUF picker. + """ + devices = [] + for idx, free_bytes, is_igpu, total_bytes, name in ( + LlamaCppBackend._probe_vulkan_devices(binary) + ): + free_mib = free_bytes // (1024 * 1024) + capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu) + total_gb = round(total_bytes / (1024**3), 2) if total_bytes > 0 else 0 + free_gb = round(capped / 1024, 2) + used_gb = ( + round(max(0, total_bytes - free_bytes) / (1024**3), 2) + if total_bytes > 0 + else None + ) + devices.append( + { + "index": idx, + "visible_ordinal": idx, + "index_kind": "vulkan", + "name": name, + "memory_total_gb": total_gb, + "vram_total_gb": total_gb, + "vram_free_gb": free_gb, + "vram_used_gb": used_gb, + "vram_utilization_pct": ( + round((used_gb / total_gb) * 100, 1) + if used_gb is not None and total_gb > 0 + else None + ), + } + ) + return devices + + @staticmethod + def _get_gpu_free_memory_vulkan(binary: Optional[str] = None) -> list[tuple[int, int, int]]: + """Query free (and total) VRAM via ggml's Vulkan ordinal space. + + iGPUs leave host-RAM headroom and report total 0 to the fit planner, + because their reported heap is shared system memory. Discrete cards + retain their real total so the planner can reserve absolute headroom. + """ + gpus: list[tuple[int, int, int]] = [] + for idx, free_bytes, is_igpu, total_bytes, _name in ( + LlamaCppBackend._probe_vulkan_devices(binary) + ): + free_mib = free_bytes // (1024 * 1024) capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu) if capped < free_mib: logger.info( @@ -3743,8 +3793,9 @@ class LlamaCppBackend: f"RAM; reserving {free_mib - capped}MiB host headroom " f"({free_mib}->{capped}MiB usable)" ) + # An iGPU's total is shared RAM, not an independent VRAM budget. + total_mib = 0 if is_igpu else total_bytes // (1024 * 1024) gpus.append((idx, capped, total_mib)) - gpus.sort(key = lambda g: g[0]) if gpus: logger.info( "Vulkan GPU memory detected: " diff --git a/studio/backend/main.py b/studio/backend/main.py index 5af25efa74..7409aaa7f0 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1194,22 +1194,32 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: enriched_dev["vram_utilization_pct"] = util.get("vram_utilization_pct") enriched_devices.append(enriched_dev) - # Whether GGUF loads accept an explicit gpu_ids pick: /load and - # /validate 400 picks on XPU hosts (no visibility mask speaks torch-xpu - # ordinals) and on Vulkan-only builds (--device pins ggml's own - # ordinals), so the picker must not offer them. + # Vulkan has its own compact ordinal space, independent of torch and + # CUDA_VISIBLE_DEVICES. Replace torch's view with the same ggml probe + # records that /load validates and pins as --device VulkanN. try: from core.inference.llama_cpp import LlamaCppBackend from utils.hardware import DeviceType, get_device - gpu_ids_supported = ( - get_device() != DeviceType.XPU and not LlamaCppBackend._is_vulkan_backend() - ) + + is_vulkan = LlamaCppBackend._is_vulkan_backend() + if is_vulkan: + enriched_devices = LlamaCppBackend._get_vulkan_gpu_info() + visibility_info = { + "available": bool(enriched_devices), + "backend": "vulkan", + } + gpu_ids_supported = bool(enriched_devices) + else: + # XPU indices cannot yet be applied safely across Level Zero's + # FLAT and COMPOSITE hierarchy modes. + gpu_ids_supported = get_device() != DeviceType.XPU except Exception as e: logger.debug(f"Could not resolve gpu_ids support: {e}") gpu_ids_supported = True gpu_info = { "available": visibility_info.get("available", False), "devices": enriched_devices, + "backend": visibility_info.get("backend"), "gguf_gpu_ids_supported": gpu_ids_supported, } _system_gpu_cache = (time.monotonic(), gpu_info) diff --git a/studio/backend/tests/test_llama_cpp_vulkan_probe.py b/studio/backend/tests/test_llama_cpp_vulkan_probe.py index b5cbaaabc3..62129a78c5 100644 --- a/studio/backend/tests/test_llama_cpp_vulkan_probe.py +++ b/studio/backend/tests/test_llama_cpp_vulkan_probe.py @@ -97,8 +97,10 @@ def _row( free_bytes: int, is_igpu: int, total_bytes: int = 0, + name: str | None = None, ) -> str: - return f"{idx}\t{free_bytes}\t{is_igpu}\t{total_bytes}" + row = f"{idx}\t{free_bytes}\t{is_igpu}\t{total_bytes}" + return f"{row}\t{name}" if name is not None else row def test_integrated_gpu_leaves_host_margin(tmp_path): @@ -122,6 +124,31 @@ def test_discrete_gpu_free_is_untouched_and_total_passed_through(tmp_path): assert gpus == [(0, 6 * 1024, 24 * 1024)], gpus +def test_vulkan_device_info_exposes_names_and_pinnable_ordinals(tmp_path): + binary = _make_vulkan_install(tmp_path) + rows = [ + _row(0, 6 * GIB, is_igpu = 0, total_bytes = 24 * GIB, name = "Radeon W7900"), + _row(1, 7 * GIB, is_igpu = 0, total_bytes = 8 * GIB, name = "Radeon W7500"), + ] + with _mock_probe(rows): + devices = LlamaCppBackend._get_vulkan_gpu_info(binary) + + assert [(d["index"], d["index_kind"], d["name"]) for d in devices] == [ + (0, "vulkan", "Radeon W7900"), + (1, "vulkan", "Radeon W7500"), + ] + assert devices[0]["memory_total_gb"] == 24 + assert devices[0]["vram_free_gb"] == 6 + assert devices[0]["vram_used_gb"] == 18 + + +def test_vulkan_device_info_accepts_legacy_four_column_probe(tmp_path): + binary = _make_vulkan_install(tmp_path) + with _mock_probe([_row(2, 7 * GIB, is_igpu = 0, total_bytes = 8 * GIB)]): + devices = LlamaCppBackend._get_vulkan_gpu_info(binary) + assert devices[0]["name"] == "Vulkan2" + + def test_large_discrete_gpu_is_untouched(tmp_path): binary = _make_vulkan_install(tmp_path) # A 48 GiB discrete card stays untouched regardless of size; only the diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index efca3e040a..2a1b56eb04 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -256,7 +256,7 @@ function GpuMemorySettings({ // Multi-GPU only, and only with physical indices (relative ordinals from a // CUDA_VISIBLE_DEVICES mask can't be mapped back to pin a device). null = all (auto). const showGpuPicker = - gpuDevices.length > 1 && gpuDevices.every((d) => d.physicalIndex); + gpuDevices.length > 1 && gpuDevices.every((d) => d.pinnable); const isGpuChecked = (index: number) => selectedGpuIds === null || selectedGpuIds.includes(index); const toggleGpu = (index: number) => { diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index db2cc021be..27cbfd11b4 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -22,10 +22,9 @@ export interface SystemGpuDevice { /** Free VRAM at fetch time. Degrades to the total when the utilization * probe had no usage data; 0 only when the total is unknown too. */ memoryFreeGb: number; - /** "physical" = `index` is a stable physical/PCI id safe to pin via gpu_ids; - * "relative" = an ordinal into a parent CUDA_VISIBLE_DEVICES mask, which the - * backend can't map back, so the picker must not offer it. */ - physicalIndex: boolean; + /** True when `index` is safe to send as gpu_ids. This covers CUDA/HIP + * physical IDs and ggml Vulkan ordinals, but not unresolved relative IDs. */ + pinnable: boolean; } const DEFAULT_GPU: GpuInfo = { @@ -82,16 +81,11 @@ function toGpuInfo(data: SystemInfoResponse | null): GpuInfo { } function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { - // Unpinnable configurations must hide every pick surface: XPU indices are - // torch-xpu ordinals no applicator speaks, and Vulkan-only builds pin ggml's - // own ordinals -- /load and /validate 400 picks on both, so the backend - // reports gpu.gguf_gpu_ids_supported and every gate keyed on physicalIndex - // (picker, persisted-pick reconcile) follows it. The device flavor lives on - // the TOP-LEVEL device_backend field; absent support info defaults to - // pinnable (older backend). - const pinnableBackend = - data?.device_backend !== "xpu" && - data?.gpu?.gguf_gpu_ids_supported !== false; + // The backend owns the index contract. CUDA/HIP expose stable physical IDs, + // while Vulkan exposes compact ggml ordinals. XPU and unresolved relative + // masks report support=false. Missing support info preserves compatibility + // with older backends. + const pinnableBackend = data?.gpu?.gguf_gpu_ids_supported !== false; return (data?.gpu?.devices ?? []) .filter((d) => typeof d.index === "number") .map((d) => ({ @@ -99,7 +93,9 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { name: d.name ?? `GPU ${d.index}`, memoryTotalGb: d.memory_total_gb ?? 0, memoryFreeGb: d.vram_free_gb ?? 0, - physicalIndex: pinnableBackend && d.index_kind === "physical", + pinnable: + pinnableBackend && + (d.index_kind === "physical" || d.index_kind === "vulkan"), })); } @@ -166,7 +162,7 @@ export async function ensureGpuDeviceCache(): Promise { */ export function cachedPinnableGpuIndices(): number[] | null { if (!cachedSystem) return null; - const physical = toGpuDevices(cachedSystem).filter((d) => d.physicalIndex); - // Mirrors the sheet's showGpuPicker gate: only a 2+ physical-GPU host can pin. - return physical.length > 1 ? physical.map((d) => d.index) : []; + const pinnable = toGpuDevices(cachedSystem).filter((d) => d.pinnable); + // Mirrors the sheet's showGpuPicker gate: only a 2+ pinnable-GPU host can pin. + return pinnable.length > 1 ? pinnable.map((d) => d.index) : []; } diff --git a/studio/frontend/src/hooks/use-system.ts b/studio/frontend/src/hooks/use-system.ts index 8cfe2bace4..493684133b 100644 --- a/studio/frontend/src/hooks/use-system.ts +++ b/studio/frontend/src/hooks/use-system.ts @@ -40,8 +40,8 @@ export interface SystemInfoResponse { gpu: { available: boolean; backend?: string; - /** Whether GGUF loads accept an explicit gpu_ids pick (false on XPU hosts - * and Vulkan-only builds, where /load and /validate 400 picks). */ + /** Whether GGUF loads accept explicit gpu_ids in the device records' + * declared index space. */ gguf_gpu_ids_supported?: boolean; backend_cuda_visible_devices?: string | null; parent_visible_gpu_ids?: number[]; From 091a81f467eab397ea1cfe30ace8da58f4dffbbc Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:50:16 -0300 Subject: [PATCH 023/110] Show Vulkan hardware names in the GPU picker --- studio/backend/core/inference/_vulkan_probe.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index d23a481e95..ad59ef9f1f 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -46,6 +46,8 @@ def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: base.ggml_backend_dev_type.argtypes = [ctypes.c_void_p] base.ggml_backend_dev_name.restype = ctypes.c_char_p base.ggml_backend_dev_name.argtypes = [ctypes.c_void_p] + base.ggml_backend_dev_description.restype = ctypes.c_char_p + base.ggml_backend_dev_description.argtypes = [ctypes.c_void_p] reg = lib.ggml_backend_vk_reg() if not reg: @@ -55,7 +57,12 @@ def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: dev = base.ggml_backend_reg_dev_get(reg, i) if dev: flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU - raw_name = base.ggml_backend_dev_name(dev) + # name is the selector token (usually VulkanN); description is + # the hardware label users need in the picker. + raw_name = ( + base.ggml_backend_dev_description(dev) + or base.ggml_backend_dev_name(dev) + ) if raw_name: name = raw_name.decode("utf-8", errors = "replace") names[i] = name.replace("\t", " ").replace("\r", " ").replace("\n", " ") From 64227dd911db39b019f7c7bb1e8f2bbfe974c57f Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:29:28 -0300 Subject: [PATCH 024/110] Clarify the llama.cpp Vulkan backend selector --- .../tests/test_install_resolve_prebuilt.py | 25 +++++++++++++ .../tests/test_setup_llama_cpp_backend.py | 35 ++++++++++++++----- studio/install_llama_prebuilt.py | 25 +++++++------ studio/setup.ps1 | 18 +++++----- studio/setup.sh | 23 +++++++----- 5 files changed, 91 insertions(+), 35 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 02ccc68b11..82a44e1409 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -403,6 +403,31 @@ def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): assert ["libggml-cpu-*.so*"] not in groups +@pytest.mark.parametrize( + "backend, legacy, expected", + [ + ("vulkan", None, True), + (" VULKAN ", None, True), + ("auto", "1", True), + (None, "true", True), + ("auto", None, False), + ("cpu", "0", False), + ], +) +def test_force_vulkan_requested_accepts_public_selector_and_legacy_alias( + monkeypatch, backend, legacy, expected +): + for name, value in ( + ("UNSLOTH_LLAMA_CPP_BACKEND", backend), + ("UNSLOTH_FORCE_VULKAN", legacy), + ): + if value is None: + monkeypatch.delenv(name, raising = False) + else: + monkeypatch.setenv(name, value) + assert ilp.force_vulkan_requested() is expected + + def test_route_to_vulkan_prebuilt_auto_intel_goes_upstream_and_drops_fork_pin(): # Routing fork -> upstream also drops the fork release pin, which is in a # different tag namespace and would make the upstream resolver miss. diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 36928c680c..5b75f42748 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -1,12 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""setup.sh and setup.ps1 must map UNSLOTH_LLAMA_CPP_BACKEND=cpu to -install_llama_prebuilt.py's --force-cpu so users can force the CPU-only prebuilt -on GPU hosts (#7213). The match is case-insensitive and whitespace-trimmed, an -unrecognized value warns instead of silently falling back, and macOS warns (no -CPU-only bundle). Runs the real block extracted from each script so the tests -track the shipped logic. +"""Backend selector coverage for setup.sh and setup.ps1. + +cpu maps to install_llama_prebuilt.py's persisted --force-cpu option. vulkan is +accepted and passed through in the environment for the installer to consume. +The match is case-insensitive and whitespace-trimmed, unknown values warn, and +macOS warns for the CPU-only choice. """ import os @@ -81,7 +81,15 @@ def test_backend_auto_no_flag_no_warn(value): @_SKIP_NO_BASH -@pytest.mark.parametrize("value", ["vulkan", "gpu", "cuda"]) +@pytest.mark.parametrize("value", ["vulkan", "VULKAN", " vulkan "]) +def test_backend_vulkan_is_accepted(value): + args, stderr = _run(value) + assert "--force-cpu" not in args + assert "Ignoring" not in stderr + + +@_SKIP_NO_BASH +@pytest.mark.parametrize("value", ["gpu", "cuda"]) def test_backend_unknown_warns_and_no_flag(value): args, stderr = _run(value) assert "--force-cpu" not in args @@ -110,7 +118,8 @@ def _run_ps1(value: str | None) -> str: # The override is normalized (assign + warn) at the top of the prebuilt block and # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( - r'\$llamaBackend = "\$\(\$env:UNSLOTH_LLAMA_CPP_BACKEND\)".*?Write-Host.*?\n\s*\}', + r'\$llamaBackend = "\$\(\$env:UNSLOTH_LLAMA_CPP_BACKEND\)".*?' + r'Ignoring UNSLOTH_LLAMA_CPP_BACKEND=.*?\n\s*\}', re.DOTALL, ) apply_flag = _ps1_search( @@ -147,7 +156,15 @@ def test_ps1_backend_auto_no_flag_no_warn(value): @_SKIP_NO_PWSH -@pytest.mark.parametrize("value", ["vulkan", "gpu", "cuda"]) +@pytest.mark.parametrize("value", ["vulkan", "VULKAN", " vulkan "]) +def test_ps1_backend_vulkan_is_accepted(value): + out = _run_ps1(value) + assert "--force-cpu" not in out + assert "Ignoring" not in out + + +@_SKIP_NO_PWSH +@pytest.mark.parametrize("value", ["gpu", "cuda"]) def test_ps1_backend_unknown_warns_and_no_flag(value): out = _run_ps1(value) assert "--force-cpu" not in out diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 6ea850139e..b9bedd8f54 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6042,12 +6042,15 @@ def validate_prebuilt_attempts( def force_vulkan_requested() -> bool: - """Whether UNSLOTH_FORCE_VULKAN opts this host into the Vulkan llama.cpp - prebuilt instead of its detected CUDA/ROCm backend (e.g. so an AMD user can - run the Vulkan build for inference). Scoped to the llama.cpp backend; the - torch/training stack installs separately and still sees the real GPU. + """Whether the user selected the Vulkan llama.cpp backend. + + ``UNSLOTH_LLAMA_CPP_BACKEND=vulkan`` is the public backend selector. + ``UNSLOTH_FORCE_VULKAN`` remains a compatible legacy alias. Both are scoped + to llama.cpp, so the torch/training stack still sees the detected GPU. """ - return os.environ.get("UNSLOTH_FORCE_VULKAN", "").strip().lower() in ( + backend = os.environ.get("UNSLOTH_LLAMA_CPP_BACKEND", "").strip().lower() + legacy = os.environ.get("UNSLOTH_FORCE_VULKAN", "").strip().lower() + return backend == "vulkan" or legacy in ( "1", "true", "yes", @@ -6079,7 +6082,8 @@ def _route_to_vulkan_prebuilt( The unsloth published repo ships only CUDA/ROCm/CPU assets, so Vulkan comes from UPSTREAM_REPO. Two triggers route here, both suppressed when a CPU flag (--cpu-fallback or --force-cpu, folded into force_cpu) wins: - * UNSLOTH_FORCE_VULKAN forces Vulkan over the detected CUDA/ROCm backend; + * UNSLOTH_LLAMA_CPP_BACKEND=vulkan, or its legacy + UNSLOTH_FORCE_VULKAN alias, forces Vulkan over CUDA/ROCm; * an auto-detected Intel GPU with NO physical NVIDIA/ROCm -- the purpose of the has_intel_gpu probe, since the fork manifest ships no Vulkan asset. Applied by BOTH the install path and the --resolve-prebuilt probe so the @@ -6092,21 +6096,22 @@ def _route_to_vulkan_prebuilt( # NVIDIA+Intel host that hides NVIDIA with CUDA_VISIBLE_DEVICES=""/-1 keeps # has_physical_nvidia=True while has_usable_nvidia goes False. Vulkan ignores # CUDA_VISIBLE_DEVICES, so auto-routing such a host would let it grab the - # reserved NVIDIA GPU. An explicit UNSLOTH_FORCE_VULKAN still overrides. + # reserved NVIDIA GPU. An explicit Vulkan backend choice still overrides. auto_intel = host.has_intel_gpu and not host.has_physical_nvidia and not host.has_rocm if force_cpu or not (forced or auto_intel): return host, published_repo, published_release_tag if host.is_macos: if forced: log( - "UNSLOTH_FORCE_VULKAN is set but ignored on macOS " + "The Vulkan llama.cpp backend choice is ignored on macOS " "(Metal is used; there is no Vulkan prebuilt)" ) return host, published_repo, published_release_tag if forced: log( - "UNSLOTH_FORCE_VULKAN is set; installing the upstream Vulkan " - "llama.cpp prebuilt instead of the detected GPU backend" + "Vulkan llama.cpp backend requested; installing the upstream Vulkan " + "llama.cpp prebuilt instead of the detected GPU backend. This affects " + "GGUF inference only; the PyTorch training backend is unchanged" ) # Forcing may override a detected NVIDIA/ROCm host, so normalize it to # Vulkan-only; an auto-detected Intel host already is. diff --git a/studio/setup.ps1 b/studio/setup.ps1 index c6932121c9..7eb36f17ee 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -33,9 +33,9 @@ $PackageDir = Split-Path -Parent $ScriptDir # errors. Only use "master" temporarily when the latest release is missing # support for a new model architecture. # -# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default) or "cpu". When "cpu", forces -# the CPU-only prebuilt bundle on GPU hosts. Fixes Intel iGPU Vulkan -# crashes (#7213). +# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", or "vulkan". "cpu" +# forces the CPU-only prebuilt. "vulkan" selects Vulkan even when CUDA or +# ROCm is detected. $DefaultLlamaPrForce = "" $DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" $DefaultLlamaTag = "latest" @@ -3699,14 +3699,16 @@ if ($LocalLlamaCppLinked) { if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $prebuiltArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } - # UNSLOTH_LLAMA_CPP_BACKEND=cpu (case-insensitive, whitespace-trimmed) forces the - # CPU-only prebuilt via --force-cpu (persisted so updates keep it). Fixes Intel - # iGPU Vulkan crash (#7213). + # The backend override is case-insensitive and whitespace-trimmed. cpu + # maps to the persisted --force-cpu choice. vulkan is consumed directly + # by install_llama_prebuilt.py and does not change the torch backend. $llamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() if ($llamaBackend -eq "cpu") { $prebuiltArgs += "--force-cpu" - } elseif ($llamaBackend -and $llamaBackend -ne "auto") { - Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$($env:UNSLOTH_LLAMA_CPP_BACKEND)' (expected 'auto' or 'cpu')" -ForegroundColor Yellow + } elseif ($llamaBackend -eq "vulkan") { + Write-Host " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan + } elseif ($llamaBackend -and $llamaBackend -notin @("auto", "vulkan")) { + Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$($env:UNSLOTH_LLAMA_CPP_BACKEND)' (expected 'auto', 'cpu', or 'vulkan')" -ForegroundColor Yellow } $prevEAPPrebuilt = $ErrorActionPreference $ErrorActionPreference = "Continue" diff --git a/studio/setup.sh b/studio/setup.sh index f6a6bc346b..12e81def4c 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -37,9 +37,9 @@ fi # Only use "master" temporarily when the latest release # is missing support for a new model architecture. # -# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default) or "cpu". When "cpu", forces -# the CPU-only prebuilt bundle on GPU hosts. -# Fixes Intel iGPU Vulkan crashes (#7213). +# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", or "vulkan". "cpu" +# forces the CPU-only prebuilt. "vulkan" selects +# Vulkan even when CUDA or ROCm is detected. # ────────────────────────────────────────────────────────────────────────── _DEFAULT_LLAMA_PR_FORCE="" _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" @@ -1401,10 +1401,10 @@ else # through to the CPU prebuilt instead of breaking the install. _PREBUILT_CMD+=(--has-rocm) fi - # UNSLOTH_LLAMA_CPP_BACKEND=cpu (case-insensitive, trimmed) forces the CPU-only - # prebuilt via --force-cpu, bypassing Vulkan/CUDA/ROCm. Fixes Intel iGPU crash (#7213). - # No effect on macOS: the universal bundle already runs on CPU (Metal is a runtime - # -ngl choice), so warn instead of writing a misleading forced-CPU marker. + # The backend override is case-insensitive and trimmed. cpu maps to the + # persisted --force-cpu choice. vulkan is consumed directly by + # install_llama_prebuilt.py and remains scoped to llama.cpp, so it does not + # change the torch backend used for training. _llama_backend="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" case "$_llama_backend" in cpu) @@ -1414,8 +1414,15 @@ else _PREBUILT_CMD+=(--force-cpu) fi ;; + vulkan) + if [ "$_HOST_SYSTEM" = "Darwin" ]; then + step "llama.cpp" "Vulkan has no effect on macOS; the universal build uses Metal" "$C_WARN" >&2 + else + step "llama.cpp" "Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" "$C_INFO" + fi + ;; ""|auto) ;; - *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$UNSLOTH_LLAMA_CPP_BACKEND' (expected 'auto' or 'cpu')" "$C_WARN" >&2 ;; + *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$UNSLOTH_LLAMA_CPP_BACKEND' (expected 'auto', 'cpu', or 'vulkan')" "$C_WARN" >&2 ;; esac _PREBUILT_LOG="$(mktemp)" set +e From e942301a0dd6aa4f0553078c3a67395dabd0a7ac Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:42:28 -0300 Subject: [PATCH 025/110] Keep explicit Vulkan choices authoritative --- studio/backend/core/inference/llama_cpp.py | 20 ++++++++ .../tests/test_llama_cpp_memory_mode.py | 46 +++++++++++++++++++ .../tests/test_setup_llama_cpp_backend.py | 30 ++++++++++++ studio/setup.ps1 | 24 ++++++++-- studio/setup.sh | 20 ++++++-- 5 files changed, 133 insertions(+), 7 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9938f58b55..6aa756ec7a 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -7047,6 +7047,7 @@ class LlamaCppBackend: # empty `gpus` so the speculative defaults stay GPU-aware and the # CPU-fallback check still knows GPUs were present. _detected_gpus: list[tuple[int, int]] = [] + total_by_idx: dict[int, int] = {} model_size = None # set in the fit try; used by the APU RAM guard # Layer-fallback min GPUs; raised below on a tensor downgrade. Bound # before the try so the --fit-on except path still has it (no UnboundLocal). @@ -7933,6 +7934,25 @@ class LlamaCppBackend: # model can't spill onto an unpicked GPU. if gpu_ids and gpu_indices is None: gpu_indices = sorted(gpu_ids) + # Auto placement must not broaden a Vulkan launch from the GPUs + # considered by the planner to every Vulkan device just because + # the requested context needs --fit CPU offload. Prefer all + # discrete devices; use integrated devices only when no discrete + # Vulkan device exists. Manual mode without a picker deliberately + # leaves device selection to llama.cpp. + elif ( + is_vulkan_backend + and gpu_memory_mode != "manual" + and use_fit + and gpu_indices is None + and _detected_gpus + ): + discrete_ids = [ + idx for idx, _free in _detected_gpus if total_by_idx.get(idx, 0) > 0 + ] + gpu_indices = sorted( + discrete_ids or [idx for idx, _free in _detected_gpus] + ) # Unified-memory APUs load weights into system RAM (under WSL the VM # cap, not the ROCm-reported VRAM, is the real ceiling); refuse an diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 36e37ef971..9cd903699a 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -360,6 +360,52 @@ def test_torchless_vulkan_populated_probe_uses_identity_ordinals(tmp_path): assert "--device" in cmd and cmd[cmd.index("--device") + 1] == "Vulkan1" +def test_vulkan_fit_keeps_discrete_device_selected(tmp_path): + """Crossing into --fit must not make an integrated GPU eligible again. + + A mixed iGPU/discrete host can fit a small context on the discrete card but + require CPU offload at a larger context. The latter still needs an explicit + Vulkan device pin even when the user did not use the GPU picker. + """ + backend, gguf = _fit_fallback_backend( + tmp_path, + # total=0 is the Vulkan probe's integrated-GPU marker. + gpu_memory = [(0, 30000, 0), (1, 14000, 16000)], + vulkan = True, + ) + backend._select_gpus = lambda *a, **k: (None, True) + + captured = {} + + def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + + class _FakePopen: + pid = 322 + + def __init__(self, cmd, **kwargs): + captured["cmd"] = list(cmd) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with ( + patch.object(subprocess, "Popen", side_effect = _make_fake_popen), + patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []), + ): + assert backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + ) + + cmd = captured["cmd"] + assert "--fit" in cmd and cmd[cmd.index("--fit") + 1] == "on" + assert "--device" in cmd and cmd[cmd.index("--device") + 1] == "Vulkan1" + + def test_vulkan_rejects_duplicate_gpu_ids(tmp_path): """The CUDA resolver's duplicate check is skipped for the Vulkan path, so the branch must reject duplicates itself rather than let set() silently collapse them (#7188).""" diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 5b75f42748..fd51ce2b3b 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -108,6 +108,36 @@ def test_arm64_recovery_uses_transient_cpu_fallback(): assert "--force-cpu" not in block +def test_explicit_vulkan_prebuilt_failure_does_not_change_backend(): + sh = _SETUP_SH.read_text(encoding = "utf-8") + failure = sh.index('step "llama.cpp" "prebuilt install failed"') + source_build = sh.index("_NEED_LLAMA_SOURCE_BUILD=true", failure) + guard = sh.index('if [ "$_explicit_vulkan_backend" = true ]', failure) + assert guard < source_build + guarded = sh[guard:source_build] + assert "will not substitute a ROCm or CPU source build" in guarded + assert "exit 1" in guarded + + ps1 = _SETUP_PS1.read_text(encoding = "utf-8") + failure = ps1.index('step "llama.cpp" "prebuilt install failed"') + source_build = ps1.index("$NeedLlamaSourceBuild = $true", failure) + guard = ps1.index("if ($explicitVulkanBackend)", failure) + assert guard < source_build + guarded = ps1[guard:source_build] + assert "will not substitute a CUDA, ROCm, or CPU source build" in guarded + assert "exit 1" in guarded + + +def test_legacy_force_vulkan_gets_the_same_strict_fallback(): + sh = _SETUP_SH.read_text(encoding = "utf-8") + assert '_legacy_force_vulkan=' in sh + assert "1|true|yes|on) _explicit_vulkan_backend=true" in sh + + ps1 = _SETUP_PS1.read_text(encoding = "utf-8") + assert '$legacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)"' in ps1 + assert '$legacyForceVulkan -in @("1", "true", "yes", "on")' in ps1 + + def _ps1_search(pattern: str, flags = 0) -> str: m = re.search(pattern, _SETUP_PS1.read_text(encoding = "utf-8"), flags) assert m, f"setup.ps1 block not found: {pattern}" diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 7eb36f17ee..7c2641154b 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3703,13 +3703,23 @@ if ($LocalLlamaCppLinked) { # maps to the persisted --force-cpu choice. vulkan is consumed directly # by install_llama_prebuilt.py and does not change the torch backend. $llamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() + $legacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() + $explicitVulkanBackend = $false if ($llamaBackend -eq "cpu") { $prebuiltArgs += "--force-cpu" } elseif ($llamaBackend -eq "vulkan") { - Write-Host " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan + if ($IsMacOS) { + Write-Host "[WARN] Vulkan has no effect on macOS; the universal build uses Metal" -ForegroundColor Yellow + } else { + $explicitVulkanBackend = $true + Write-Host " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan + } } elseif ($llamaBackend -and $llamaBackend -notin @("auto", "vulkan")) { Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$($env:UNSLOTH_LLAMA_CPP_BACKEND)' (expected 'auto', 'cpu', or 'vulkan')" -ForegroundColor Yellow } + if (-not $IsMacOS -and $llamaBackend -ne "cpu" -and $legacyForceVulkan -in @("1", "true", "yes", "on")) { + $explicitVulkanBackend = $true + } $prevEAPPrebuilt = $ErrorActionPreference $ErrorActionPreference = "Continue" $previousNativeErrorPreference = $null @@ -3760,13 +3770,19 @@ if ($LocalLlamaCppLinked) { substep "Close Unsloth or other llama.cpp users and retry" "Yellow" exit 3 } else { - step "llama.cpp" "prebuilt install failed (continuing)" "Yellow" + step "llama.cpp" "prebuilt install failed" "Yellow" Write-LlamaFailureLog -Output $prebuiltOutput if (Test-Path -LiteralPath $LlamaCppDir) { substep "Prebuilt update failed; existing install was restored or cleaned before source build fallback" "Yellow" } - substep "Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" "Yellow" - $NeedLlamaSourceBuild = $true + if ($explicitVulkanBackend) { + step "llama.cpp" "Vulkan was explicitly requested, so the installer will not substitute a CUDA, ROCm, or CPU source build" "Red" + substep "Check the download error above or try a different UNSLOTH_LLAMA_RELEASE_TAG" "Yellow" + exit 1 + } else { + substep "Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" "Yellow" + $NeedLlamaSourceBuild = $true + } } } diff --git a/studio/setup.sh b/studio/setup.sh index 12e81def4c..1900098a7a 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1406,6 +1406,8 @@ else # install_llama_prebuilt.py and remains scoped to llama.cpp, so it does not # change the torch backend used for training. _llama_backend="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" + _legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" + _explicit_vulkan_backend=false case "$_llama_backend" in cpu) if [ "$_HOST_SYSTEM" = "Darwin" ]; then @@ -1418,12 +1420,18 @@ else if [ "$_HOST_SYSTEM" = "Darwin" ]; then step "llama.cpp" "Vulkan has no effect on macOS; the universal build uses Metal" "$C_WARN" >&2 else + _explicit_vulkan_backend=true step "llama.cpp" "Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" "$C_INFO" fi ;; ""|auto) ;; *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$UNSLOTH_LLAMA_CPP_BACKEND' (expected 'auto', 'cpu', or 'vulkan')" "$C_WARN" >&2 ;; esac + if [ "$_llama_backend" != "cpu" ] && [ "$_HOST_SYSTEM" != "Darwin" ]; then + case "$_legacy_force_vulkan" in + 1|true|yes|on) _explicit_vulkan_backend=true ;; + esac + fi _PREBUILT_LOG="$(mktemp)" set +e if _is_verbose; then @@ -1457,14 +1465,20 @@ else substep "close Unsloth or other llama.cpp users and retry" exit 3 else - step "llama.cpp" "prebuilt install failed (continuing)" "$C_WARN" + step "llama.cpp" "prebuilt install failed" "$C_WARN" print_llama_error_log "$_PREBUILT_LOG" rm -f "$_PREBUILT_LOG" if [ -d "$LLAMA_CPP_DIR" ]; then substep "prebuilt update failed; existing install restored" fi - substep "falling back to source build" - _NEED_LLAMA_SOURCE_BUILD=true + if [ "$_explicit_vulkan_backend" = true ]; then + step "llama.cpp" "Vulkan was explicitly requested, so the installer will not substitute a ROCm or CPU source build" "$C_ERR" + substep "check the download error above or try a different UNSLOTH_LLAMA_RELEASE_TAG" + exit 1 + else + substep "falling back to source build" + _NEED_LLAMA_SOURCE_BUILD=true + fi fi fi From e35cd5bfee18daa1456b3cebf98f087489154eea Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:47:38 -0300 Subject: [PATCH 026/110] Preserve requested and effective GGUF GPU pins --- studio/backend/core/inference/llama_cpp.py | 41 +++++++++++++++---- studio/backend/routes/inference.py | 25 +++++++---- .../tests/test_chat_load_during_training.py | 9 ++++ studio/backend/tests/test_gpu_memory_mode.py | 29 ++++++------- .../tests/test_llama_cpp_memory_mode.py | 6 ++- 5 files changed, 75 insertions(+), 35 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 6aa756ec7a..a571ee19e3 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2088,6 +2088,8 @@ class LlamaCppBackend: self._tensor_split: Optional[List[float]] = None # User-picked physical GPU indices (None = automatic selection). self._gpu_ids: Optional[List[int]] = None + # Raw requested GPU pin before automatic fit narrows the active subset. + self._requested_gpu_ids: Optional[List[int]] = None # GGUF memory placement mode (None = auto; pinned/resident map to --mlock/--no-mmap). self._memory_mode: Optional[str] = None # Raw requested mode for the response echo. _memory_mode canonicalizes @@ -2569,6 +2571,11 @@ class LlamaCppBackend: """User-picked physical GPU indices, or None for automatic selection.""" return self._gpu_ids + @property + def requested_gpu_ids(self) -> Optional[List[int]]: + """Raw requested GPU pin before automatic fit narrows it.""" + return self._requested_gpu_ids + @property def memory_mode(self) -> Optional[str]: """GGUF memory placement mode of the active load (auto/pinned/resident). @@ -5384,10 +5391,7 @@ class LlamaCppBackend: # above), not the whole pick, and clears any explicit pin from a prior # chat load; a multi-GPU list would misreport placement and mis-dedup. self._gpu_ids = [sorted(gpu_ids)[0]] if gpu_ids else None - # The frontend prefers requested_gpu_ids when hydrating the picker. - # Diffusion uses only one device, so echo the collapsed effective pin, - # not unused members of the original request. - self._requested_gpu_ids = list(self._gpu_ids) if self._gpu_ids else None + self._requested_gpu_ids = sorted(gpu_ids) if gpu_ids else None if hf_variant: self._hf_variant = hf_variant elif gguf_path: @@ -6935,7 +6939,8 @@ class LlamaCppBackend: self._gpu_layers = -1 self._n_cpu_moe = 0 self._tensor_split = None - self._gpu_ids = sorted(gpu_ids) if gpu_ids else None + self._requested_gpu_ids = sorted(gpu_ids) if gpu_ids else None + self._gpu_ids = list(self._requested_gpu_ids) if self._requested_gpu_ids else None # Manual offload skips the TP planner but still emits --split-mode # tensor at launch; drop it when fewer than 2 GPUs are in use -- # tensor split is a no-op there and aborts on some architectures. @@ -7954,6 +7959,15 @@ class LlamaCppBackend: discrete_ids or [idx for idx, _free in _detected_gpus] ) + # Status reports the explicit subset the launch actually uses, + # while reload dedupe compares requested_gpu_ids so repeating a + # wider request that fit narrowed does not restart the server. + if gpu_ids: + effective_pin = gpu_indices if gpu_indices is not None else gpu_ids + self._gpu_ids = sorted(int(idx) for idx in effective_pin) + else: + self._gpu_ids = None + # Unified-memory APUs load weights into system RAM (under WSL the VM # cap, not the ROCm-reported VRAM, is the real ceiling); refuse an # oversize load the OS would otherwise kill mid-flight. Base model @@ -9429,10 +9443,18 @@ class LlamaCppBackend: ) ): return False - # A changed GPU pick must reload. Regular GGUF accepts either the raw - # requested placement pool or the effective status-echoed subset; - # diffusion compares its normalized single-device pick. - if not self.matches_gpu_ids(gpu_ids): + # A changed GPU pick must reload (compare order-insensitively; None/[] + # both mean automatic). The diffusion runner collapses a multi-GPU pick + # to its single lowest device, so self._gpu_ids holds just that device; + # normalize the request the same way, or a multi-GPU pick that resolves + # to the same device needlessly reloads. + if self._is_diffusion: + requested_gpu_pick = [sorted(gpu_ids)[0]] if gpu_ids else None + loaded_gpu_pick = self._gpu_ids or None + else: + requested_gpu_pick = sorted(gpu_ids) if gpu_ids else None + loaded_gpu_pick = self._requested_gpu_ids or None + if loaded_gpu_pick != requested_gpu_pick: return False # GGUF host-memory placement mode is first-class; a change must reload (#7164). @@ -9650,6 +9672,7 @@ class LlamaCppBackend: self._n_cpu_moe = 0 self._tensor_split = None self._gpu_ids = None + self._requested_gpu_ids = None self._memory_mode = None self._requested_memory_mode = None self._launched_with_inherited_mem_env = False diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 2505aeb615..6f90bfc094 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3255,10 +3255,17 @@ def _request_matches_loaded_settings( ) ): return False - # A regular GGUF may narrow the requested placement pool. Accept either the - # original request or the effective status-echoed subset; diffusion keeps - # its single-device normalization. - if not llama_backend.matches_gpu_ids(request.gpu_ids): + # A changed GPU pick must reload. The diffusion runner collapses a multi-GPU + # request to its single lowest device (it drives one device only), so the + # backend records just that device; compare the request the same way, or a + # multi-GPU pick that resolves to the same device needlessly reloads. + if llama_backend.is_diffusion: + _req_gpu_ids = [sorted(request.gpu_ids)[0]] if request.gpu_ids else None + _loaded_gpu_ids = llama_backend.gpu_ids + else: + _req_gpu_ids = sorted(request.gpu_ids) if request.gpu_ids else None + _loaded_gpu_ids = llama_backend.requested_gpu_ids + if _req_gpu_ids != _loaded_gpu_ids: return False # GGUF host-memory placement mode is first-class; any change must reload (#7164). if LlamaCppBackend._canonical_memory_mode( @@ -3974,14 +3981,14 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]: except Exception as e: logger.debug("Could not identify diffusion GGUF for training guard: %s", e) - # No readable local header: fall back to the name heuristic so an uncached - # remote GGUF that will route to the diffusion runner isn't treated as normal. + # No readable local header: use only the DiffusionGemma family name as a + # hint. A bare "diffusion" substring is common in otherwise normal model + # names and must not route them to the single-GPU diffusion runner. identity = " ".join( str(getattr(config, attr, "") or "") for attr in ("identifier", "gguf_hf_repo", "gguf_file") ).lower() - if "diffusion" in identity: - return True - return None + normalized_identity = _re.sub(r"[^a-z0-9]+", "", identity) + return True if "diffusiongemma" in normalized_identity else None from utils.hardware import DeviceType, get_device from utils.hardware.hardware import resolve_requested_gpu_ids diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index bed38ce606..57db2ad874 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -581,6 +581,15 @@ class TestChatLoadGuardRoute(unittest.TestCase): ) self.assertIsNone(self.route._classify_diffusion_gguf(config)) + def test_uncached_normal_model_with_diffusion_in_name_remains_unknown(self): + config = SimpleNamespace( + identifier = "owner/stable-diffusion-prompt-model-GGUF", + gguf_hf_repo = "owner/stable-diffusion-prompt-model-GGUF", + gguf_variant = "Q4_K_M", + gguf_file = None, + ) + self.assertIsNone(self.route._classify_diffusion_gguf(config)) + def test_diffusion_detection_reuses_loader_metadata_probe(self): import tempfile diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index e5865dc955..2d8329553a 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -613,11 +613,15 @@ def test_gguf_load_and_status_responses_include_requested_gpu_pool(): def test_gpu_ids_property_default_and_reset(): backend = LlamaCppBackend() assert backend.gpu_ids is None + assert backend.requested_gpu_ids is None backend._gpu_ids = [0, 1] + backend._requested_gpu_ids = [0, 1, 2] assert backend.gpu_ids == [0, 1] + assert backend.requested_gpu_ids == [0, 1, 2] backend._process = _FakeProcess() backend.unload_model() assert backend.gpu_ids is None + assert backend.requested_gpu_ids is None def _target_state_gpu_ids(backend, gpu_ids): @@ -650,24 +654,15 @@ def test_gpu_ids_reload_detection_is_order_insensitive(): assert _target_state_gpu_ids(backend, None) is False -def test_gpu_ids_reload_detection_accepts_raw_and_effective_pin(): +def test_gpu_ids_reload_detection_uses_raw_request_after_fit_narrows(): backend = _loaded_backend("auto") - backend._requested_gpu_ids = [0, 1] backend._gpu_ids = [0] - backend._last_load_kwargs = {"gpu_ids": [0, 1], "model_identifier": "owner/repo"} + backend._requested_gpu_ids = [0, 1] - # The original request still matches after the fitter narrows it. + # Repeating the original request dedupes even though fit used one GPU. assert _target_state_gpu_ids(backend, [1, 0]) is True - assert backend.requested_gpu_ids == [0, 1] - # The status response echoes the effective pin, which must also round-trip. - # Treat the incoming subset as the latest intent so status and a future - # reload do not restore GPU 1 after the user removed it. - assert _target_state_gpu_ids(backend, [0]) is True - assert backend.requested_gpu_ids == [0] - assert backend._last_load_kwargs == {"gpu_ids": [0], "model_identifier": "owner/repo"} - # A genuinely different placement pool still reloads. - assert _target_state_gpu_ids(backend, [1]) is False - assert _target_state_gpu_ids(backend, None) is False + # Deliberately changing the request to the effective subset still reloads. + assert _target_state_gpu_ids(backend, [0]) is False def test_gpu_ids_reload_detection_collapses_diffusion_to_single_device(): @@ -752,8 +747,10 @@ def test_route_matches_loaded_settings_uses_shared_gpu_pin_matcher(): # raw, effective, and diffusion pins cannot drift apart. route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8") match_impl = route_src[route_src.index("def _request_matches_loaded_settings") :] - assert "if not llama_backend.matches_gpu_ids(request.gpu_ids):" in match_impl - assert "llama_backend._record_matching_gpu_request(request.gpu_ids)" in match_impl + guard = match_impl.index("if llama_backend.is_diffusion:") + collapse = match_impl.index("[sorted(request.gpu_ids)[0]] if request.gpu_ids else None") + compare = match_impl.index("if _req_gpu_ids != _loaded_gpu_ids:") + assert guard < collapse < compare # ── Manual tensor split: child enumeration pinned to the picker's order ────── diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 9cd903699a..af480f7370 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -141,6 +141,8 @@ def _loaded_backend(**overrides): backend._memory_mode = None for key, value in overrides.items(): setattr(backend, key, value) + if "_gpu_ids" in overrides and "_requested_gpu_ids" not in overrides: + backend._requested_gpu_ids = list(overrides["_gpu_ids"] or []) or None return backend @@ -354,10 +356,12 @@ def test_torchless_vulkan_populated_probe_uses_identity_ordinals(tmp_path): assert backend.load_model( gguf_path = str(gguf), model_identifier = "test", - gpu_ids = [1], + gpu_ids = [0, 1], ) cmd = captured["cmd"] assert "--device" in cmd and cmd[cmd.index("--device") + 1] == "Vulkan1" + assert backend.gpu_ids == [1] + assert backend.requested_gpu_ids == [0, 1] def test_vulkan_fit_keeps_discrete_device_selected(tmp_path): From c13d76061d7dbc163fa1b33ed72c24458a865e95 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:12:44 -0300 Subject: [PATCH 027/110] Align GGUF dedupe fixture with requested GPU pins --- studio/backend/tests/test_tp_vision_regression.py | 1 + 1 file changed, 1 insertion(+) diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py index 4de980d1a0..333a9cdad2 100644 --- a/studio/backend/tests/test_tp_vision_regression.py +++ b/studio/backend/tests/test_tp_vision_regression.py @@ -914,6 +914,7 @@ def test_explicit_gpu_ids_dedupes_when_device_already_stripped(): ) backend = _mem_loaded_backend(memory_mode = None, extra_args = ["--top-k", "5"]) backend._gpu_ids = [0] + backend._requested_gpu_ids = [0] assert inference_routes._request_matches_loaded_settings(req, backend) is True From 0e75432b1a08a02620b175f6e12924e258e03ddb Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:38:56 -0300 Subject: [PATCH 028/110] Restore Vulkan diffusion pin guard --- studio/backend/core/inference/llama_cpp.py | 6 ++-- .../tests/test_llama_cpp_memory_mode.py | 36 +++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index a571ee19e3..f88cb67d8c 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -6818,9 +6818,9 @@ class LlamaCppBackend: # serve them with the diffusion runner (same OpenAI-compat interface). if self._is_diffusion: # The diffusion runner pins its child by CUDA visibility mask, so a - # ggml Vulkan ordinal cannot be honored (wrong GPU / CPU fallback). - # Route and remote-download preflights reject before teardown; keep - # this as a final defense if classification ever disagrees. + # ggml Vulkan ordinal cannot be honored. The route rejects models it + # can classify before teardown; this covers a remote uncached GGUF + # whose architecture is first known after download (#7239). if is_vulkan_backend and gpu_ids: raise ValueError( "GPU selection (gpu_ids) is not supported for a DiffusionGemma " diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index af480f7370..59166423a7 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -1222,12 +1222,36 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru # ── diffusion GGUFs clear host-residency memory-mode state ─────────────────── -# The merged impl keeps #6414's single-device gpu_ids collapse for diffusion (it does -# NOT reject gpu_ids in load_model) and moved the explicit pinned/resident memory_mode -# rejection into the route (_reject_diffusion_memory_mode). #7188's load_model-level -# rejection tests for both are therefore dropped; what remains here is the kept -# load_model behaviour: a diffusion load clears any host-residency memory_mode carried -# from a prior llama-server load (the diffusion runner has no --mlock/--no-mmap). +# The route rejects known DiffusionGemma Vulkan pins and explicit host-memory modes +# before teardown. load_model retains a post-download Vulkan guard for a remote +# uncached model whose architecture was not known to the route (#7239). A successful +# diffusion load also clears any host-residency memory_mode carried from a prior +# llama-server load because the diffusion runner has no --mlock/--no-mmap support. + + +def test_remote_diffusion_load_rejects_vulkan_ordinal_after_download(tmp_path): + """A renamed remote DiffusionGemma may be unclassifiable until its downloaded + GGUF header is read. Never pass its Vulkan ordinal to the CUDA diffusion runner.""" + gguf = tmp_path / "renamed.gguf" + _write_minimal_gguf(gguf, arch = "diffusion-gemma") + + backend = LlamaCppBackend() + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._is_vulkan_backend = lambda _binary = None: True + backend._download_gguf = lambda **_kwargs: str(gguf) + backend._read_gguf_metadata = lambda _path: setattr(backend, "_is_diffusion", True) + backend._start_diffusion_server = lambda **_kwargs: pytest.fail( + "Vulkan ordinal reached the CUDA diffusion runner" + ) + + with pytest.raises(ValueError, match = "no defined mapping"): + backend.load_model( + hf_repo = "renamed/model", + hf_variant = "Q4_K_M", + model_identifier = "renamed/model", + speculative_type = "off", + gpu_ids = [1], + ) @pytest.mark.parametrize("mode", [None, "auto", "AUTO", ""]) From d4fc667e684c64939fb069d8c868e3eb0f19894e Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:13:32 -0300 Subject: [PATCH 029/110] Close GPU backend integration gaps --- studio/backend/core/inference/llama_cpp.py | 13 +++++- studio/backend/models/inference.py | 12 ++--- studio/backend/routes/inference.py | 12 +++++ studio/backend/tests/test_gpu_memory_mode.py | 5 -- .../tests/test_install_resolve_prebuilt.py | 21 +++++++++ .../tests/test_llama_cpp_memory_mode.py | 46 +++++++++++++++++++ .../src/features/chat/api/chat-adapter.ts | 5 +- .../src/features/chat/shared-composer.tsx | 5 +- .../chat/stores/chat-runtime-store.ts | 22 +++++++-- .../frontend/src/features/chat/types/api.ts | 4 ++ .../components/model-config-page.tsx | 17 ++++++- .../hooks/use-active-model-config.ts | 3 ++ .../model-config/apply-per-model-config.ts | 9 +++- .../model-config/per-model-config.ts | 12 +++++ studio/frontend/src/hooks/use-gpu-info.ts | 21 +++++++++ studio/install_llama_prebuilt.py | 24 ++++++++++ tests/studio/test_model_picker_contracts.py | 21 ++++++++- 17 files changed, 229 insertions(+), 23 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index f88cb67d8c..92b9c11b81 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -6575,6 +6575,7 @@ class LlamaCppBackend: # Explicit GPU placement pool (issue #7164). None/[] = auto-select; # the fitter may pin the smallest subset of this pool that fits. gpu_ids: Optional[List[int]] = None, + gpu_ids_are_vulkan_ordinals: Optional[bool] = None, memory_mode: Optional[str] = None, n_threads: Optional[int] = None, n_gpu_layers: Optional[int] = None, # caller compat, unused @@ -6631,6 +6632,7 @@ class LlamaCppBackend: "n_cpu_moe": n_cpu_moe, "tensor_split": list(tensor_split) if tensor_split is not None else None, "gpu_ids": list(gpu_ids) if gpu_ids is not None else None, + "gpu_ids_are_vulkan_ordinals": gpu_ids_are_vulkan_ordinals, "memory_mode": memory_mode, "n_threads": n_threads, "n_gpu_layers": n_gpu_layers, @@ -6817,11 +6819,20 @@ class LlamaCppBackend: # Block-diffusion GGUFs (DiffusionGemma) cannot run on llama-server; # serve them with the diffusion runner (same OpenAI-compat interface). if self._is_diffusion: + if self._canonical_memory_mode(memory_mode) is not None: + raise ValueError( + "GGUF host-memory modes are not supported for " + "DiffusionGemma models. Use Auto." + ) # The diffusion runner pins its child by CUDA visibility mask, so a # ggml Vulkan ordinal cannot be honored. The route rejects models it # can classify before teardown; this covers a remote uncached GGUF # whose architecture is first known after download (#7239). - if is_vulkan_backend and gpu_ids: + if ( + is_vulkan_backend + and gpu_ids + and gpu_ids_are_vulkan_ordinals is not False + ): raise ValueError( "GPU selection (gpu_ids) is not supported for a DiffusionGemma " "GGUF on a Vulkan llama.cpp build: the diffusion runner selects " diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index d536653c14..a4c36c1db9 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -529,13 +529,13 @@ class LoadResponse(BaseModel): ) gpu_ids: Optional[List[int]] = Field( None, - description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.", + description = "Effective GPU indices the model is using, or None for automatic selection.", ) requested_gpu_ids: Optional[List[int]] = Field( None, description = ( - "GPU placement pool requested by the user before fit-time narrowing, " - "or None for automatic selection." + "GPU indices requested by the user before fit-time narrowing, or " + "None for automatic selection." ), ) gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( @@ -708,13 +708,13 @@ class InferenceStatusResponse(BaseModel): ) gpu_ids: Optional[List[int]] = Field( None, - description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.", + description = "Effective GPU indices the model is using, or None for automatic selection.", ) requested_gpu_ids: Optional[List[int]] = Field( None, description = ( - "GPU placement pool requested by the user before fit-time narrowing, " - "or None for automatic selection." + "GPU indices requested by the user before fit-time narrowing, or " + "None for automatic selection." ), ) gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6f90bfc094..6072bf7f70 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4501,6 +4501,7 @@ async def _load_model_impl( n_layers = llama_backend.n_layers, n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, + requested_gpu_ids = llama_backend.requested_gpu_ids, gguf_memory_mode = llama_backend.requested_memory_mode, ) else: @@ -4571,6 +4572,10 @@ async def _load_model_impl( # Normalize gpu_ids: empty list means auto-selection, same as None effective_gpu_ids = request.gpu_ids if request.gpu_ids else None + # Provenance for the backend's post-download DiffusionGemma guard. + # None keeps direct-call safety; the route sets the exact namespace when + # it validates an explicit pin below. + _gpu_ids_are_vulkan_ordinals: Optional[bool] = None # GGUF supports gpu_ids: validate the pick up front (before the training # guard) so a bad pick is a clean 400, not masked by a VRAM 409. Rejects @@ -4646,6 +4651,7 @@ async def _load_model_impl( if get_device() == DeviceType.CUDA and ( not _gguf_vulkan_build or _gguf_confirmed_diffusion ): + _gpu_ids_are_vulkan_ordinals = False # The CUDA resolver only validates the physical-ID mask; it can't tell the # llama.cpp build is CPU-only. A CPU-only build ignores CUDA_VISIBLE_DEVICES, # so the pin would silently run on CPU while /load reports gpu_ids active -- @@ -4669,6 +4675,9 @@ async def _load_model_impl( except ValueError as exc: raise HTTPException(status_code = 400, detail = str(exc)) from exc else: + _gpu_ids_are_vulkan_ordinals = bool( + _gguf_vulkan_build and not _gguf_confirmed_diffusion + ) # Non-CUDA GGUF host or a Vulkan build on any host: the CUDA resolver can't # enumerate llama.cpp's devices, so gate on the backend probe and reject # before teardown (#7188). A torch-less Vulkan host reports CPU but its @@ -4812,6 +4821,7 @@ async def _load_model_impl( n_cpu_moe = request.n_cpu_moe, tensor_split = request.tensor_split, gpu_ids = effective_gpu_ids, + gpu_ids_are_vulkan_ordinals = _gpu_ids_are_vulkan_ordinals, memory_mode = request.gguf_memory_mode, n_parallel = _n_parallel, # Issue #7164: explicit GPU pin resolved to physical ids above. @@ -4989,6 +4999,7 @@ async def _load_model_impl( n_layers = llama_backend.n_layers, n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, + requested_gpu_ids = llama_backend.requested_gpu_ids, gguf_memory_mode = llama_backend.requested_memory_mode, ) @@ -6212,6 +6223,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)): n_layers = llama_backend.n_layers, n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, + requested_gpu_ids = llama_backend.requested_gpu_ids, gguf_memory_mode = llama_backend.requested_memory_mode, llama_cpp_supports_mtp = _supports_mtp, spec_fallback_reason = llama_backend.spec_fallback_reason, diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index 2d8329553a..a87df8e272 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -605,11 +605,6 @@ def test_response_models_emit_gpu_ids(model_cls): assert obj.model_dump()["requested_gpu_ids"] == [1, 2] -def test_gguf_load_and_status_responses_include_requested_gpu_pool(): - route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8") - assert route_src.count("requested_gpu_ids = llama_backend.requested_gpu_ids") == 3 - - def test_gpu_ids_property_default_and_reset(): backend = LlamaCppBackend() assert backend.gpu_ids is None diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 82a44e1409..68ab1082b1 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -410,6 +410,7 @@ def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): (" VULKAN ", None, True), ("auto", "1", True), (None, "true", True), + (None, "on", True), ("auto", None, False), ("cpu", "0", False), ], @@ -428,6 +429,26 @@ def test_force_vulkan_requested_accepts_public_selector_and_legacy_alias( assert ilp.force_vulkan_requested() is expected +def test_forced_vulkan_filters_cpu_fallback_before_validation(): + vulkan = ilp.AssetChoice( + repo = UPSTREAM, + tag = "b9925", + name = "llama-b9925-bin-ubuntu-vulkan-x64.tar.gz", + url = "https://example/vulkan", + source_label = "upstream", + install_kind = "linux-vulkan", + ) + cpu = ilp.AssetChoice( + repo = UPSTREAM, + tag = "b9925", + name = "llama-b9925-bin-ubuntu-x64.tar.gz", + url = "https://example/cpu", + source_label = "upstream", + install_kind = "linux-cpu", + ) + assert ilp._vulkan_only_attempts([vulkan, cpu]) == [vulkan] + + def test_route_to_vulkan_prebuilt_auto_intel_goes_upstream_and_drops_fork_pin(): # Routing fork -> upstream also drops the fork release pin, which is in a # different tag namespace and would make the upstream resolver miss. diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 59166423a7..e6eac5bd3b 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -1254,6 +1254,52 @@ def test_remote_diffusion_load_rejects_vulkan_ordinal_after_download(tmp_path): ) +def test_confirmed_diffusion_allows_physical_gpu_id_on_vulkan_build(tmp_path): + """The route validates known DiffusionGemma pins as CUDA physical IDs. A + Vulkan llama.cpp build does not change the diffusion runner's index space.""" + gguf = tmp_path / "diffusion.gguf" + _write_minimal_gguf(gguf, arch = "diffusion-gemma") + + backend = LlamaCppBackend() + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._is_vulkan_backend = lambda _binary = None: True + backend._read_gguf_metadata = lambda _path: setattr(backend, "_is_diffusion", True) + captured = {} + backend._start_diffusion_server = lambda **kwargs: captured.update(kwargs) or True + + assert backend.load_model( + gguf_path = str(gguf), + model_identifier = "diffusion/model", + speculative_type = "off", + gpu_ids = [1], + gpu_ids_are_vulkan_ordinals = False, + ) + assert captured["gpu_ids"] == [1] + + +def test_remote_diffusion_rejects_explicit_memory_mode_after_download(tmp_path): + gguf = tmp_path / "renamed.gguf" + _write_minimal_gguf(gguf, arch = "diffusion-gemma") + + backend = LlamaCppBackend() + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._is_vulkan_backend = lambda _binary = None: False + backend._download_gguf = lambda **_kwargs: str(gguf) + backend._read_gguf_metadata = lambda _path: setattr(backend, "_is_diffusion", True) + backend._start_diffusion_server = lambda **_kwargs: pytest.fail( + "unsupported memory mode reached the diffusion runner" + ) + + with pytest.raises(ValueError, match = "host-memory modes are not supported"): + backend.load_model( + hf_repo = "renamed/model", + hf_variant = "Q4_K_M", + model_identifier = "renamed/model", + speculative_type = "off", + memory_mode = "resident", + ) + + @pytest.mark.parametrize("mode", [None, "auto", "AUTO", ""]) def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): """auto/blank/None is the allowed no-op default for diffusion. A successful load diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index acb4028bb9..440b807ade 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1564,7 +1564,10 @@ async function autoLoadSmallestModel(): Promise<{ } const effectiveGpuIds = config.selectedGpuIds !== undefined - ? reconcilePersistedGpuIds(config.selectedGpuIds) + ? reconcilePersistedGpuIds( + config.selectedGpuIds, + config.selectedGpuIndexKind ?? null, + ) : null; const effectiveMemoryMode = config.ggufMemoryMode ?? null; // Under Manual GPU memory + Auto layers, llama.cpp's --fit owns context diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 6ed536d007..848d8a786c 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -1057,7 +1057,10 @@ export function SharedComposer({ ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe; const effectiveSelectedGpuIds = ownConfig.selectedGpuIds !== undefined - ? reconcilePersistedGpuIds(ownConfig.selectedGpuIds) + ? reconcilePersistedGpuIds( + ownConfig.selectedGpuIds, + ownConfig.selectedGpuIndexKind ?? null, + ) : compareLoadKnobs.selectedGpuIds; const effectiveMemoryMode = ownConfig.ggufMemoryMode ?? null; // A pane's context comes from its own config only: a saved pin, or null diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index cda3339ec2..cf8bb49bcd 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -2,7 +2,11 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { mirrorHfTokenInto, useHfTokenStore } from "@/features/hub"; -import { cachedPinnableGpuIndices } from "@/hooks/use-gpu-info"; +import { + cachedPinnableGpuIndexKind, + cachedPinnableGpuIndices, + type GpuIndexKind, +} from "@/hooks/use-gpu-info"; import { toast } from "@/lib/toast"; import { create } from "zustand"; import { isExternalModelId, parseExternalModelId } from "../external-providers"; @@ -586,8 +590,18 @@ export function rebalanceSplit( // unpopulated device cache leaves the pick alone (the backend still guards). export function reconcilePersistedGpuIds( ids: number[] | null, + savedIndexKind?: GpuIndexKind | null, ): number[] | null { if (ids == null) return ids; + if (arguments.length >= 2) { + const currentIndexKind = cachedPinnableGpuIndexKind(); + if ( + currentIndexKind !== undefined && + currentIndexKind !== savedIndexKind + ) { + return null; + } + } const pinnable = cachedPinnableGpuIndices(); if (pinnable === null) return ids; // cache not ready: can't validate, keep it const kept = ids.filter((i) => pinnable.includes(i)); @@ -606,6 +620,7 @@ export function loadedGpuMemoryFields(resp: { n_layers?: number | null; n_moe_layers?: number; gpu_ids?: number[] | null; + requested_gpu_ids?: number[] | null; gguf_memory_mode?: "auto" | "pinned" | "resident" | null; }) { // GPU-memory state is meaningful only for a GGUF chat load. A non-GGUF response @@ -635,8 +650,6 @@ export function loadedGpuMemoryFields(resp: { }; } const mode = resp.gpu_memory_mode ?? "auto"; - // Keep the user's placement pool editable across status/load hydration. - // gpu_ids remains the effective fitted subset for diagnostics. const gpuIds = resp.requested_gpu_ids ?? resp.gpu_ids ?? null; // Layer/MoE/split knobs apply (and are reported) only in manual mode; in auto // the server ignores them, so don't seed the loaded baseline or the editable @@ -675,7 +688,8 @@ export function loadedGpuMemoryFields(resp: { ggufLayerCount: resp.n_layers ?? null, // MoE expert-layer count: the n_cpu_moe slider max, and 0 hides the slider. moeLayerCount: resp.n_moe_layers ?? null, - // The picker reflects the requested placement pool, not a fitted subset. + // Keep the editable picker on the user's requested set. Auto fit can narrow + // effective placement, but that must not silently rewrite future requests. selectedGpuIds: gpuIds, loadedGpuIds: gpuIds, // Commit the residency the backend actually applied. Mirroring both the diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 5ca9b5c9a4..e28297638e 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -192,6 +192,8 @@ export interface LoadModelResponse { n_moe_layers?: number; /** Effective GPU placement after fit-time narrowing. */ gpu_ids?: number[] | null; + /** User-requested GPU set before fit-time narrowing. */ + requested_gpu_ids?: number[] | null; /** GGUF host-memory placement mode the load was invoked with. */ gguf_memory_mode?: "auto" | "pinned" | "resident" | null; } @@ -247,6 +249,8 @@ export interface InferenceStatusResponse { requested_context_length?: number | null; /** Effective GPU placement after fit-time narrowing. */ gpu_ids?: number[] | null; + /** User-requested GPU set before fit-time narrowing. */ + requested_gpu_ids?: number[] | null; /** Active GGUF host-memory placement mode (from /status). */ gguf_memory_mode?: "auto" | "pinned" | "resident" | null; n_layers?: number | null; diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 2a1b56eb04..1b41e670d1 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -251,12 +251,20 @@ function GpuMemorySettings({ const moeLayersMax = moeLayerCount ?? 0; const showMoeSlider = isManual && !autoLayers && moeLayersMax > 0; const selectedGpuIds = config.selectedGpuIds ?? null; + const gpuIndexKind = + gpuDevices.length > 0 && + gpuDevices[0].indexKind !== null && + gpuDevices.every((device) => device.indexKind === gpuDevices[0].indexKind) + ? gpuDevices[0].indexKind + : null; const singleGpuInUse = (selectedGpuIds ?? gpuDevices.map((device) => device.index)).length <= 1; // Multi-GPU only, and only with physical indices (relative ordinals from a // CUDA_VISIBLE_DEVICES mask can't be mapped back to pin a device). null = all (auto). const showGpuPicker = - gpuDevices.length > 1 && gpuDevices.every((d) => d.pinnable); + gpuDevices.length > 1 && + gpuIndexKind !== null && + gpuDevices.every((d) => d.pinnable); const isGpuChecked = (index: number) => selectedGpuIds === null || selectedGpuIds.includes(index); const toggleGpu = (index: number) => { @@ -266,7 +274,11 @@ function GpuMemorySettings({ ? current.filter((i) => i !== index) : [...current, index].sort((a, b) => a - b); if (next.length === 0) return; // keep at least one GPU selected - update({ selectedGpuIds: next.length === all.length ? null : next }); + const selectsAll = next.length === all.length; + update({ + selectedGpuIds: selectsAll ? null : next, + selectedGpuIndexKind: selectsAll ? null : gpuIndexKind, + }); }; return ( <> @@ -301,6 +313,7 @@ function GpuMemorySettings({ gpuLayers: undefined, nCpuMoe: undefined, selectedGpuIds: undefined, + selectedGpuIndexKind: undefined, }, ) } diff --git a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts index afab3806b2..10d6f98f88 100644 --- a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts +++ b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts @@ -4,6 +4,7 @@ import { isExternalModelId, useChatRuntimeStore } from "@/features/chat"; import { useMemo } from "react"; import type { PerModelConfig } from "../model-config/per-model-config"; +import { cachedPinnableGpuIndexKind } from "@/hooks/use-gpu-info"; export interface ActiveModelConfigState { checkpoint: string | null; @@ -57,6 +58,8 @@ export function useActiveModelConfig(): ActiveModelConfigState { gpuLayers, nCpuMoe, selectedGpuIds, + selectedGpuIndexKind: + selectedGpuIds == null ? null : (cachedPinnableGpuIndexKind() ?? null), ggufMemoryMode: ggufMemoryMode ?? undefined, }; }, [ diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index 36d991825d..5fa39cf8c9 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -15,6 +15,7 @@ import { type PerModelConfig, normalizeMaxSeqLength, } from "./per-model-config"; +import { cachedPinnableGpuIndexKind } from "@/hooks/use-gpu-info"; function cleanTemplate(value: string | null | undefined): string | null { return value?.trim() ? value : null; @@ -52,7 +53,10 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { splitRatio: null, selectedGpuIds: config.selectedGpuIds !== undefined - ? reconcilePersistedGpuIds(config.selectedGpuIds) + ? reconcilePersistedGpuIds( + config.selectedGpuIds, + config.selectedGpuIndexKind ?? null, + ) : null, ggufMemoryMode: config.ggufMemoryMode ?? null, }); @@ -87,6 +91,8 @@ export function currentRuntimePerModelConfig( gpuLayers: s.gpuLayers, nCpuMoe: s.nCpuMoe, selectedGpuIds: s.selectedGpuIds, + selectedGpuIndexKind: + s.selectedGpuIds == null ? null : (cachedPinnableGpuIndexKind() ?? null), ggufMemoryMode: s.ggufMemoryMode ?? undefined, }; } @@ -121,6 +127,7 @@ export function gpuFieldsSignature(config: PerModelConfig): string { config.selectedGpuIds == null ? "all" : [...config.selectedGpuIds].sort((a, b) => a - b).join(","), + config.selectedGpuIndexKind ?? "untagged", config.ggufMemoryMode ?? "unset", ].join("|"); } diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts index b3ef8cb6b5..1b92857520 100644 --- a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts @@ -8,6 +8,7 @@ import { normalizeGgufVariantIdentity, normalizeModelIdentity, } from "./model-identity"; +import type { GpuIndexKind } from "@/hooks/use-gpu-info"; export interface PerModelConfig { customContextLength: number | null; @@ -25,6 +26,7 @@ export interface PerModelConfig { gpuLayers?: number; nCpuMoe?: number; selectedGpuIds?: number[] | null; + selectedGpuIndexKind?: GpuIndexKind | null; ggufMemoryMode?: "auto" | "pinned" | "resident"; } @@ -99,6 +101,7 @@ const STORED_CONFIG_FIELDS = new Set([ "gpuLayers", "nCpuMoe", "selectedGpuIds", + "selectedGpuIndexKind", "ggufMemoryMode", ]); @@ -107,6 +110,7 @@ function normalizeGpuFields(partial: RawConfig): { gpuLayers?: number; nCpuMoe?: number; selectedGpuIds?: number[] | null; + selectedGpuIndexKind?: GpuIndexKind | null; ggufMemoryMode?: "auto" | "pinned" | "resident"; } { const out: { @@ -114,6 +118,7 @@ function normalizeGpuFields(partial: RawConfig): { gpuLayers?: number; nCpuMoe?: number; selectedGpuIds?: number[] | null; + selectedGpuIndexKind?: GpuIndexKind | null; ggufMemoryMode?: "auto" | "pinned" | "resident"; } = {}; // Only "manual" is a real override; persisting "auto" would pin the model and @@ -144,6 +149,13 @@ function normalizeGpuFields(partial: RawConfig): { ) { out.selectedGpuIds = partial.selectedGpuIds.map((n) => Math.trunc(n)); } + if ( + partial.selectedGpuIndexKind === "physical" || + partial.selectedGpuIndexKind === "vulkan" || + partial.selectedGpuIndexKind === null + ) { + out.selectedGpuIndexKind = partial.selectedGpuIndexKind; + } if ( partial.ggufMemoryMode === "auto" || partial.ggufMemoryMode === "pinned" || diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 27cbfd11b4..766e287b02 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -17,6 +17,7 @@ export interface GpuInfo { export interface SystemGpuDevice { index: number; + indexKind: GpuIndexKind | null; name: string; memoryTotalGb: number; /** Free VRAM at fetch time. Degrades to the total when the utilization @@ -27,6 +28,8 @@ export interface SystemGpuDevice { pinnable: boolean; } +export type GpuIndexKind = "physical" | "vulkan"; + const DEFAULT_GPU: GpuInfo = { available: false, name: "Unknown", @@ -90,6 +93,10 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { .filter((d) => typeof d.index === "number") .map((d) => ({ index: d.index as number, + indexKind: + d.index_kind === "physical" || d.index_kind === "vulkan" + ? d.index_kind + : null, name: d.name ?? `GPU ${d.index}`, memoryTotalGb: d.memory_total_gb ?? 0, memoryFreeGb: d.vram_free_gb ?? 0, @@ -166,3 +173,17 @@ export function cachedPinnableGpuIndices(): number[] | null { // Mirrors the sheet's showGpuPicker gate: only a 2+ pinnable-GPU host can pin. return pinnable.length > 1 ? pinnable.map((d) => d.index) : []; } + +/** Index namespace for persisted gpu_ids. Undefined means the cache is cold; + * null means the current host has no single pinnable namespace. */ +export function cachedPinnableGpuIndexKind(): + | GpuIndexKind + | null + | undefined { + if (!cachedSystem) return undefined; + const pinnable = toGpuDevices(cachedSystem).filter((d) => d.pinnable); + const kinds = new Set(pinnable.map((d) => d.indexKind).filter((k) => k)); + return pinnable.length > 1 && kinds.size === 1 + ? ([...kinds][0] as GpuIndexKind) + : null; +} diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index b9bedd8f54..0a96ab0b9d 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6054,6 +6054,7 @@ def force_vulkan_requested() -> bool: "1", "true", "yes", + "on", ) @@ -6074,6 +6075,18 @@ def _vulkan_only_host(host: HostInfo) -> HostInfo: ) +def _vulkan_only_attempts(attempts: Iterable[AssetChoice]) -> list[AssetChoice]: + """Remove generic CPU fallbacks from an explicit Vulkan install.""" + filtered = [ + attempt + for attempt in attempts + if attempt.install_kind in ("linux-vulkan", "windows-vulkan") + ] + if not filtered: + raise PrebuiltFallback("no Vulkan prebuilt bundle attempts were available") + return filtered + + def _route_to_vulkan_prebuilt( host: HostInfo, published_repo: str, published_release_tag: str, *, force_cpu: bool ) -> tuple[HostInfo, str, str]: @@ -6175,6 +6188,7 @@ def install_prebuilt( override_rocm_gfx = override_rocm_gfx, force_cpu = force_cpu, ) + strict_vulkan = force_vulkan_requested() and not force_cpu and not host.is_macos host, published_repo, published_release_tag = _route_to_vulkan_prebuilt( host, published_repo, published_release_tag, force_cpu = force_cpu ) @@ -6206,6 +6220,16 @@ def install_prebuilt( published_repo, published_release_tag, ) + if strict_vulkan: + # Upstream plans append CPU as a generic fallback. An explicit + # Vulkan selection must fail instead of silently installing it. + release_plans = [ + dataclasses_replace( + plan, + attempts = _vulkan_only_attempts(plan.attempts), + ) + for plan in release_plans + ] if release_plans and existing_install_matches_plan(install_dir, host, release_plans[0]): current = release_plans[0] if diffusion_visual_server_backfill_needed(install_dir, host, current.attempts[0]): diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 3934b72d83..1a73ebab00 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -137,7 +137,13 @@ def test_active_model_config_round_trips_gpu_fields(): a sidebar/hub-gear reload cannot silently reset manual GPU settings, and "Remember settings" cannot persist a GPU-less config over a saved one.""" src = _read("features/model-picker/hooks/use-active-model-config.ts") - for field in ("gpuMemoryMode", "gpuLayers", "nCpuMoe", "selectedGpuIds"): + for field in ( + "gpuMemoryMode", + "gpuLayers", + "nCpuMoe", + "selectedGpuIds", + "selectedGpuIndexKind", + ): assert field in src, field assert "if (!isGguf)" in src and "return base" in src for rel in ( @@ -170,7 +176,7 @@ def test_compare_load_uses_each_models_gpu_config(): assert "ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers" in src assert "ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe" in src assert "if (ownConfig.selectedGpuIds != null)" in src - assert "reconcilePersistedGpuIds(ownConfig.selectedGpuIds)" in src + assert "ownConfig.selectedGpuIndexKind ?? null" in src for field in ( "gpu_memory_mode: effectiveGpuMemoryMode", "gpu_layers: effectiveGpuLayers", @@ -441,6 +447,17 @@ def test_default_gpu_mode_clears_manual_knobs(): assert "gpuLayers: undefined," in src assert "nCpuMoe: undefined," in src assert "selectedGpuIds: undefined," in src + assert "selectedGpuIndexKind: undefined," in src + + +def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): + store = _read("features/chat/stores/chat-runtime-store.ts") + assert "resp.requested_gpu_ids ?? resp.gpu_ids ?? null" in store + assert "currentIndexKind !== savedIndexKind" in store + gpu_info = _read("hooks/use-gpu-info.ts") + assert "cachedPinnableGpuIndexKind" in gpu_info + config = _read("features/model-picker/model-config/per-model-config.ts") + assert '"selectedGpuIndexKind"' in config def test_legacy_migration_is_idempotent_and_non_destructive(): From ec70925ed0eed5f3f505bcaaf1c858605f16af48 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:53:59 +0000 Subject: [PATCH 030/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../backend/core/inference/_vulkan_probe.py | 10 ++------- studio/backend/core/inference/llama_cpp.py | 22 ++++++------------- .../tests/test_llama_cpp_memory_mode.py | 10 ++------- .../tests/test_setup_llama_cpp_backend.py | 4 ++-- 4 files changed, 13 insertions(+), 33 deletions(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index ad59ef9f1f..e4513889e0 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -59,10 +59,7 @@ def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU # name is the selector token (usually VulkanN); description is # the hardware label users need in the picker. - raw_name = ( - base.ggml_backend_dev_description(dev) - or base.ggml_backend_dev_name(dev) - ) + raw_name = base.ggml_backend_dev_description(dev) or base.ggml_backend_dev_name(dev) if raw_name: name = raw_name.decode("utf-8", errors = "replace") names[i] = name.replace("\t", " ").replace("\r", " ").replace("\n", " ") @@ -137,10 +134,7 @@ def main() -> int: for i in range(count): free, total = ctypes.c_size_t(0), ctypes.c_size_t(0) lib.ggml_backend_vk_get_device_memory(i, ctypes.byref(free), ctypes.byref(total)) - rows.append( - "%d\t%d\t%d\t%d\t%s" - % (i, free.value, int(igpu[i]), total.value, names[i]) - ) + rows.append("%d\t%d\t%d\t%d\t%s" % (i, free.value, int(igpu[i]), total.value, names[i])) sys.stdout.write("\n".join(rows)) return 0 diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 92b9c11b81..afe8a917df 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3749,17 +3749,15 @@ class LlamaCppBackend: ``vulkan`` so the frontend can offer them only to the GGUF picker. """ devices = [] - for idx, free_bytes, is_igpu, total_bytes, name in ( - LlamaCppBackend._probe_vulkan_devices(binary) + for idx, free_bytes, is_igpu, total_bytes, name in LlamaCppBackend._probe_vulkan_devices( + binary ): free_mib = free_bytes // (1024 * 1024) capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu) total_gb = round(total_bytes / (1024**3), 2) if total_bytes > 0 else 0 free_gb = round(capped / 1024, 2) used_gb = ( - round(max(0, total_bytes - free_bytes) / (1024**3), 2) - if total_bytes > 0 - else None + round(max(0, total_bytes - free_bytes) / (1024**3), 2) if total_bytes > 0 else None ) devices.append( { @@ -3789,8 +3787,8 @@ class LlamaCppBackend: retain their real total so the planner can reserve absolute headroom. """ gpus: list[tuple[int, int, int]] = [] - for idx, free_bytes, is_igpu, total_bytes, _name in ( - LlamaCppBackend._probe_vulkan_devices(binary) + for idx, free_bytes, is_igpu, total_bytes, _name in LlamaCppBackend._probe_vulkan_devices( + binary ): free_mib = free_bytes // (1024 * 1024) capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu) @@ -6828,11 +6826,7 @@ class LlamaCppBackend: # ggml Vulkan ordinal cannot be honored. The route rejects models it # can classify before teardown; this covers a remote uncached GGUF # whose architecture is first known after download (#7239). - if ( - is_vulkan_backend - and gpu_ids - and gpu_ids_are_vulkan_ordinals is not False - ): + if is_vulkan_backend and gpu_ids and gpu_ids_are_vulkan_ordinals is not False: raise ValueError( "GPU selection (gpu_ids) is not supported for a DiffusionGemma " "GGUF on a Vulkan llama.cpp build: the diffusion runner selects " @@ -7966,9 +7960,7 @@ class LlamaCppBackend: discrete_ids = [ idx for idx, _free in _detected_gpus if total_by_idx.get(idx, 0) > 0 ] - gpu_indices = sorted( - discrete_ids or [idx for idx, _free in _detected_gpus] - ) + gpu_indices = sorted(discrete_ids or [idx for idx, _free in _detected_gpus]) # Status reports the explicit subset the launch actually uses, # while reload dedupe compares requested_gpu_ids so repeating a diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index e6eac5bd3b..3e1c9bf044 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -176,10 +176,7 @@ def test_memory_mode_flags_maps_modes(mode, expected): ], ) def test_memory_mode_flags_use_unified_load_mode(mode, expected): - assert ( - LlamaCppBackend._memory_mode_flags(mode, supports_load_mode = True) - == expected - ) + assert LlamaCppBackend._memory_mode_flags(mode, supports_load_mode = True) == expected # ── _already_in_target_state ───────────────────────────────────────────────── @@ -400,10 +397,7 @@ def test_vulkan_fit_keeps_discrete_device_selected(tmp_path): patch.object(subprocess, "Popen", side_effect = _make_fake_popen), patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []), ): - assert backend.load_model( - gguf_path = str(gguf), - model_identifier = "test", - ) + assert backend.load_model(gguf_path = str(gguf), model_identifier = "test") cmd = captured["cmd"] assert "--fit" in cmd and cmd[cmd.index("--fit") + 1] == "on" diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index fd51ce2b3b..26bb5a495f 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -130,7 +130,7 @@ def test_explicit_vulkan_prebuilt_failure_does_not_change_backend(): def test_legacy_force_vulkan_gets_the_same_strict_fallback(): sh = _SETUP_SH.read_text(encoding = "utf-8") - assert '_legacy_force_vulkan=' in sh + assert "_legacy_force_vulkan=" in sh assert "1|true|yes|on) _explicit_vulkan_backend=true" in sh ps1 = _SETUP_PS1.read_text(encoding = "utf-8") @@ -149,7 +149,7 @@ def _run_ps1(value: str | None) -> str: # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( r'\$llamaBackend = "\$\(\$env:UNSLOTH_LLAMA_CPP_BACKEND\)".*?' - r'Ignoring UNSLOTH_LLAMA_CPP_BACKEND=.*?\n\s*\}', + r"Ignoring UNSLOTH_LLAMA_CPP_BACKEND=.*?\n\s*\}", re.DOTALL, ) apply_flag = _ps1_search( From 04bee773fad6bc6b6caf3e03030b2fc9c3b64154 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:48:08 +0000 Subject: [PATCH 031/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/inference.py | 132 +++++++++++------------------ 1 file changed, 50 insertions(+), 82 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6072bf7f70..6eb941445d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3981,14 +3981,41 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]: except Exception as e: logger.debug("Could not identify diffusion GGUF for training guard: %s", e) - # No readable local header: use only the DiffusionGemma family name as a - # hint. A bare "diffusion" substring is common in otherwise normal model - # names and must not route them to the single-GPU diffusion runner. - identity = " ".join( - str(getattr(config, attr, "") or "") for attr in ("identifier", "gguf_hf_repo", "gguf_file") - ).lower() - normalized_identity = _re.sub(r"[^a-z0-9]+", "", identity) - return True if "diffusiongemma" in normalized_identity else None + +def _reject_diffusion_memory_mode(config: ModelConfig, memory_mode: Optional[str]) -> None: + """Raise 400 if a DiffusionGemma GGUF was given an explicit gguf_memory_mode. + + The diffusion runner is single-GPU (DG_GPU) with no --mlock/--no-mmap plumbing. + Called by both /validate and /load before either tears down the active model, + so the endpoints agree before any unload (#7188). + gpu_ids for diffusion is handled by the runner (collapsed to a single device), so + only the host-memory mode is rejected here.""" + if not config.is_gguf: + return + if LlamaCppBackend._canonical_memory_mode(memory_mode) is None: + return + if _classify_diffusion_gguf(config) is True: + raise HTTPException( + status_code = 400, + detail = ( + "Explicit gguf_memory_mode is not supported for diffusion " + "(DiffusionGemma) GGUF models." + ), + ) + + +async def _resolve_gguf_gpu_ids_for_request( + config: ModelConfig, gpu_ids: Optional[List[int]] +) -> tuple[Optional[List[int]], bool]: + """Resolve and fully validate an explicit GGUF GPU placement pool. + + CUDA and ROCm use physical IDs. Vulkan uses ggml ordinals, so its device + existence check comes from the same ggml probe used by the loader. Both + /load and /validate call this before their training guard or any teardown. + The boolean result records whether the resolved IDs are Vulkan ordinals. + """ + if not gpu_ids: + return None, False from utils.hardware import DeviceType, get_device from utils.hardware.hardware import resolve_requested_gpu_ids @@ -5297,80 +5324,21 @@ async def validate_model( # Apply the same training coexistence policy as /load before the frontend # unloads the current model. effective_gpu_ids = request.gpu_ids if request.gpu_ids else None - # Mirror /load: GGUF supports gpu_ids, so validate the pick (a bad one is - # a clean 400) before the guard sizes the model against training VRAM. - # XPU-host picks are rejected unless the llama.cpp build is Vulkan, in - # which case the Vulkan ordinal namespace is the intended target (#7188). - if config.is_gguf and effective_gpu_ids is not None: - from utils.hardware import DeviceType, get_device - from utils.hardware.hardware import resolve_requested_gpu_ids - from core.inference.llama_cpp import _extra_args_draft_device_pin - - _loaded_llama = get_llama_cpp_backend() - _gguf_vulkan_build = await asyncio.to_thread(_loaded_llama.is_vulkan_build) - # Mirror /load: a confirmed diffusion GGUF uses the visual-server runner (CUDA - # ids via --gpu/DG_GPU), not the Vulkan llama-server, so route its gpu_ids - # through the CUDA resolver even on a Vulkan build (#7188). - _gguf_confirmed_diffusion = _classify_diffusion_gguf(config) is True - - if get_device() == DeviceType.XPU and not _gguf_vulkan_build: - raise HTTPException( - status_code = 400, - detail = ( - "GPU selection (gpu_ids) is not supported on Intel XPU. " - "Omit gpu_ids to use all devices." - ), - ) - # Mirror /load's draft-device reject for the validate-then-unload flow: a - # same-model reload inherits the running server's stored extras, and a - # stored --spec-draft-device naming a real GPU would escape the new gpu_ids - # pin. ValidateModelRequest carries no llama_extra_args, so only the - # inherited backend extras are checkable here (#7188). - if _loaded_llama.is_loaded and _loaded_llama.extra_args: - source = _loaded_llama.extra_args_source - resolved_variant = (config.gguf_variant or "").lower() - request_variant = (request.gguf_variant or "").lower() - stored_variant = (source[1] or "").lower() if source else "" - same_model = bool( - source and source[0] and source[0].lower() == model_identifier.lower() - ) - if request.gguf_variant: - variant_mismatch = request_variant != stored_variant - else: - variant_mismatch = bool(stored_variant and resolved_variant != stored_variant) - if same_model and not variant_mismatch: - _draft_dev_pin = _extra_args_draft_device_pin(_loaded_llama.extra_args) - if _draft_dev_pin is not None: - raise HTTPException( - status_code = 400, - detail = ( - f"A draft-model device override ('{_draft_dev_pin}') cannot be " - "combined with explicit gpu_ids: it would place the speculative " - "drafter outside the pinned GPUs the training guard budgeted. " - "Remove the draft-device flag to follow gpu_ids, or set it to cpu." - ), - ) - # A Vulkan build treats gpu_ids as Vulkan ordinals, so defer to the backend - # probe even on a CUDA-visible host; otherwise keep the CUDA resolver (#6414/#7188). - # A confirmed diffusion GGUF always takes the CUDA path: its runner uses CUDA ids, - # not the Vulkan llama-server (#7188). - if get_device() == DeviceType.CUDA and ( - not _gguf_vulkan_build or _gguf_confirmed_diffusion - ): - # A CPU-only llama.cpp build ignores CUDA_VISIBLE_DEVICES; the CUDA resolver - # can't see that, so reject a pin it would silently run on CPU (mirrors /load - # and the non-CUDA resolvable check). Diffusion GGUF models bypass - # llama-server, so the llama.cpp GPU-lib check is irrelevant (#7188). - if not _gguf_confirmed_diffusion and await asyncio.to_thread( - _loaded_llama._backend_lacks_gpu_lib - ): - raise HTTPException( - status_code = 400, - detail = ( - f"Requested gpu_ids {list(effective_gpu_ids)} but the llama.cpp " - "build has no GPU backend (CPU-only build); it would ignore the " - "pin and run on CPU. Omit gpu_ids to run on CPU." - ), + if config.is_gguf: + validated_gpu_ids, _ = await _resolve_gguf_gpu_ids_for_request( + config, + effective_gpu_ids, + ) + if validated_gpu_ids: + from core.inference.llama_cpp import _extra_args_draft_device_pin + loaded_llama = get_llama_cpp_backend() + if loaded_llama.is_loaded and loaded_llama.extra_args: + source = loaded_llama.extra_args_source + resolved_variant = (config.gguf_variant or "").lower() + request_variant = (request.gguf_variant or "").lower() + stored_variant = (source[1] or "").lower() if source else "" + same_model = bool( + source and source[0] and source[0].lower() == model_identifier.lower() ) try: resolve_requested_gpu_ids(effective_gpu_ids) From 8a4afdc68c50562dbda5942d962907f69797ee59 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:16:43 -0300 Subject: [PATCH 032/110] Simplify GGUF placement state and validation --- studio/backend/core/inference/llama_cpp.py | 152 ++-------- studio/backend/models/inference.py | 32 ++- studio/backend/routes/inference.py | 258 ++++------------- studio/backend/routes/training_vram.py | 33 ++- .../tests/test_chat_load_during_training.py | 19 +- studio/backend/tests/test_gpu_selection.py | 8 +- .../tests/test_llama_cpp_memory_mode.py | 269 +----------------- .../tests/test_tp_vision_regression.py | 2 +- .../src/features/chat/api/chat-adapter.ts | 3 +- studio/frontend/src/features/chat/index.ts | 2 +- .../chat/stores/chat-runtime-store.ts | 13 +- .../frontend/src/features/chat/types/api.ts | 10 +- .../components/model-config-page.tsx | 13 +- .../model-config/per-model-config.ts | 7 +- 14 files changed, 169 insertions(+), 652 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index afe8a917df..bb09590599 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1726,17 +1726,13 @@ def _extra_args_draft_offloaded_to_cpu( device flag has no env). An embedded MTP head follows the main -ngl, so these draft-only flags don't move it. Last-wins, so only each flag's final value counts.""" ngl_flags = {"--spec-draft-ngl", "-ngld", "--gpu-layers-draft", "--n-gpu-layers-draft"} - dev_flags = {"--spec-draft-device", "-devd", "--device-draft"} args = [str(a) for a in extra_args] if extra_args else [] last_ngl: Optional[str] = None - last_dev: Optional[str] = None for i, raw in enumerate(args): flag, eq, inline = raw.partition("=") value = inline if eq else (args[i + 1] if i + 1 < len(args) else "") if flag in ngl_flags: last_ngl = value - elif flag in dev_flags: - last_dev = value if last_ngl is None: last_ngl = (os.environ if env is None else env).get("LLAMA_ARG_N_GPU_LAYERS_DRAFT") if last_ngl is not None: @@ -1745,6 +1741,7 @@ def _extra_args_draft_offloaded_to_cpu( return True except (TypeError, ValueError): pass + last_dev = _extra_args_draft_device(extra_args) if last_dev is not None: devs = [d.strip().lower() for d in last_dev.split(",") if d.strip()] if devs and all(d in ("cpu", "none") for d in devs): @@ -1752,11 +1749,20 @@ def _extra_args_draft_offloaded_to_cpu( return False +def _extra_args_draft_device(extra_args: Optional[Iterable[str]]) -> Optional[str]: + """Return the last explicit draft-device value, if any.""" + dev_flags = {"--spec-draft-device", "-devd", "--device-draft"} + args = [str(a) for a in extra_args] if extra_args else [] + last_dev: Optional[str] = None + for i, raw in enumerate(args): + flag, eq, inline = raw.partition("=") + if flag in dev_flags: + last_dev = inline if eq else (args[i + 1] if i + 1 < len(args) else "") + return last_dev + + def _extra_args_draft_device_pin(extra_args: Optional[Iterable[str]]) -> Optional[str]: - """Return the drafter's explicit device pin from user extras when it names a - real GPU device (not cpu/none), else None. Parses the same draft-device flags - as _extra_args_draft_offloaded_to_cpu (--spec-draft-device / -devd / - --device-draft), last-wins, inline (=) or next-token, comma-separated. + """Return a draft-device override that names a real GPU, not cpu/none. A separate MTP drafter normally follows the main -ngl / device selection, so under an explicit gpu_ids pin it lands on the pinned cards the training guard @@ -1764,14 +1770,7 @@ def _extra_args_draft_device_pin(extra_args: Optional[Iterable[str]]) -> Optiona the drafter on a card the guard never reserved (#7188). cpu/none is a supported offload (keeps the drafter off the GPU entirely), so it does not conflict and returns None.""" - dev_flags = {"--spec-draft-device", "-devd", "--device-draft"} - args = [str(a) for a in extra_args] if extra_args else [] - last_dev: Optional[str] = None - for i, raw in enumerate(args): - flag, eq, inline = raw.partition("=") - value = inline if eq else (args[i + 1] if i + 1 < len(args) else "") - if flag in dev_flags: - last_dev = value + last_dev = _extra_args_draft_device(extra_args) if last_dev is None: return None devs = [d.strip() for d in last_dev.split(",") if d.strip()] @@ -2090,11 +2089,8 @@ class LlamaCppBackend: self._gpu_ids: Optional[List[int]] = None # Raw requested GPU pin before automatic fit narrows the active subset. self._requested_gpu_ids: Optional[List[int]] = None - # GGUF memory placement mode (None = auto; pinned/resident map to --mlock/--no-mmap). - self._memory_mode: Optional[str] = None - # Raw requested mode for the response echo. _memory_mode canonicalizes - # "auto" -> None, but the echo must keep an explicit "auto" so it round-trips - # instead of collapsing to null and letting LLAMA_ARG_MLOCK creep back (#7188). + # Raw requested host-memory mode. The canonical placement is derived + # from this so status echo and reload dedupe cannot drift apart. self._requested_memory_mode: Optional[str] = None # True when the child launched with no explicit memory_mode but inherited an # LLAMA_ARG_MLOCK/NO_MMAP/MMAP env; lets an explicit 'auto' reload force the @@ -2581,12 +2577,12 @@ class LlamaCppBackend: """GGUF memory placement mode of the active load (auto/pinned/resident). Auto is canonicalised to None for dedup; use requested_memory_mode for the original user-requested value.""" - return self._memory_mode + return self._canonical_memory_mode(self._requested_memory_mode) @property def requested_memory_mode(self) -> Optional[str]: """User's raw requested memory mode for the response echo. Distinguishes an - explicit "auto" (which _memory_mode canonicalizes to None) from omitted.""" + explicit "auto" from an omitted field.""" return self._requested_memory_mode @property @@ -3403,23 +3399,6 @@ class LlamaCppBackend: # No GPU lib: CPU-only only if a CPU/base ggml lib proves the split-lib layout (not a static build). return any(_lib_dir_has_ggml_backend(lib_dir, b) for b in ("cpu", "base")) - def has_gpu_backend(self) -> bool: - """True if a GPU probe finds any device: nvidia-smi/amd-smi (CUDA/ROCm) or a - Vulkan build's ggml enumeration. Lets the route reject gpu_ids on a GPU-less - host before teardown without falsely rejecting a torch-less Vulkan host (which - get_device() reports as CPU, the AMD/Vulkan case #7164 targets). On a probe - failure return True so the caller defers to the load path (#7188).""" - try: - if self._get_gpu_memory(self._find_llama_server_binary()): - return True - # A telemetry-down GPU still exposes a parent-visible mask, which the resolver - # validates against; treating an empty probe as "no backend" here would 400 a - # selection the resolver would have accepted (#7188). - from utils.hardware import get_parent_visible_gpu_ids - return bool(get_parent_visible_gpu_ids()) - except Exception: - return True - def is_vulkan_build(self) -> bool: """True if the resolved llama-server binary is a Vulkan build. The route uses this to defer gpu_ids to the backend (which treats them as Vulkan ordinals) instead of @@ -3429,90 +3408,6 @@ class LlamaCppBackend: except Exception: return False - def assert_requested_gpu_ids_resolvable(self, gpu_ids: Optional[list[int]]) -> None: - """Raise ValueError if the requested gpu_ids can't be honored by the llama.cpp - backend. Self-contained (finds the binary + probes) so the route can reject a - bad selection BEFORE it unloads the active model; otherwise on non-CUDA hosts a - typo like gpu_ids=[99] tore the model down and 400'd only in load_model (#7188).""" - if not gpu_ids: - return - binary = self._find_llama_server_binary() - self._assert_gpu_ids_resolvable( - gpu_ids, - self._get_gpu_memory(binary), - self._is_vulkan_backend(binary), - self._backend_lacks_gpu_lib(binary), - ) - - @staticmethod - def _assert_gpu_ids_resolvable( - gpu_ids: Optional[list[int]], - gpu_mem: list[tuple[int, int, int]], - is_vulkan_backend: bool, - backend_lacks_gpu_lib: bool = False, - ) -> None: - """Raise ValueError if the requested gpu_ids cannot be honored on this host. - - Side-effect-free mirror of load_model's flag-builder checks, so Phase 0 can - reject a bad selection (out-of-range/duplicate id, or a GPU-less host) BEFORE - Phase 1 kills the running server (#7188). Returns without raising for the valid - 'real GPU, telemetry down' fall-through. The flag builder stays the backstop.""" - if not gpu_ids: - return - # A CPU-only build (no cuda/hip/vulkan ggml lib) ignores CUDA_VISIBLE_DEVICES, so - # it can't honor a pin: reject before teardown rather than run silently on CPU (#7188). - if backend_lacks_gpu_lib: - raise ValueError( - f"Requested gpu_ids {list(gpu_ids)} but the llama.cpp build has no GPU " - "backend (CPU-only build); it would ignore the pin and run on CPU. Omit " - "gpu_ids to run on CPU." - ) - # Duplicate/negative ids are invalid on every backend; enforce it here for the - # deferred GGUF path too, else a non-Vulkan probe collapses [0, 0] into {0} and - # pins one GPU while recording the duplicate (#7188). - _requested = list(gpu_ids) - if len(set(_requested)) != len(_requested) or any(g < 0 for g in _requested): - raise ValueError(f"Invalid gpu_ids {_requested}: IDs must be unique and non-negative.") - allowed = set(gpu_ids) - # Vulkan ordinals match the probe directly; never remap through the CUDA/HIP mask - # (Vulkan enumerates independently of CUDA_VISIBLE_DEVICES) (#7188). - matched = [g for g in gpu_mem if g[0] in allowed] - if gpu_mem: - # Every requested id must match: a partial hit like [0, 99] against [0] must be - # rejected, not narrowed to [0] (which places on fewer GPUs than asked) (#7188). - if len(matched) != len(allowed): - raise ValueError(f"Requested gpu_ids {list(gpu_ids)} do not match any visible GPUs") - elif is_vulkan_backend: - # Vulkan build with an empty probe: the ordinals can't be resolved. - raise ValueError(f"Requested gpu_ids {list(gpu_ids)} do not match any visible GPUs") - else: - from utils.hardware import DeviceType, get_device, get_parent_visible_gpu_ids - - # The CUDA/HIP mask only governs placement on a CUDA/ROCm build. A Metal/SYCL/CPU - # backend ignores CUDA_VISIBLE_DEVICES, so an empty probe on a non-CUDA host means - # the pin can't be honored: reject rather than drop onto the default device - # (#7188). ROCm reports CUDA here, so HIP hosts keep the mask path. - if get_device() != DeviceType.CUDA: - raise ValueError( - f"Requested gpu_ids {list(gpu_ids)} but this backend does not support " - "explicit GPU selection (no GPU probe on a non-CUDA device); omit " - "gpu_ids to run on the default device." - ) - parent_visible = get_parent_visible_gpu_ids() - if not parent_visible: - raise ValueError( - f"Requested gpu_ids {list(gpu_ids)} but no GPU backend is available " - "(no GPU telemetry and no visible GPU mask); omit gpu_ids to run on " - "CPU." - ) - _outside_mask = [g for g in gpu_ids if g not in parent_visible] - if _outside_mask: - raise ValueError( - f"Requested gpu_ids {list(gpu_ids)} do not match any visible GPUs " - f"{parent_visible}" - ) - # Valid (incl. the real-GPU / telemetry-down fall-through). - @staticmethod def _strip_device_extra_args(extra_args): """Drop a user --device/-dev from extra_args so an explicit gpu_ids pin owns @@ -6840,7 +6735,6 @@ class LlamaCppBackend: # This path skips the command builder that records host-memory placement, # so clear any mode carried from a prior non-diffusion load (the diffusion # runner has no --mlock/--no-mmap plumbing) (#7188). - self._memory_mode = None self._requested_memory_mode = None self._launched_with_inherited_mem_env = False with self._lock: @@ -8953,7 +8847,6 @@ class LlamaCppBackend: ) self._extra_args_source = (model_identifier, hf_variant) self._requested_n_ctx = int(n_ctx) - self._memory_mode = LlamaCppBackend._canonical_memory_mode(memory_mode) # Raw requested mode for the response echo, so an explicit "auto" # round-trips instead of collapsing to null (#7188). self._requested_memory_mode = (memory_mode or "").strip().lower() or None @@ -9461,9 +9354,7 @@ class LlamaCppBackend: return False # GGUF host-memory placement mode is first-class; a change must reload (#7164). - if LlamaCppBackend._canonical_memory_mode( - self._memory_mode - ) != LlamaCppBackend._canonical_memory_mode(memory_mode): + if self.memory_mode != LlamaCppBackend._canonical_memory_mode(memory_mode): return False # An explicit memory_mode (incl. 'auto') over a child carrying inherited LLAMA_ARG_* # flags must reload so the scrub runs; the canonical check above treats 'auto' as @@ -9674,9 +9565,6 @@ class LlamaCppBackend: self._gpu_layers = -1 self._n_cpu_moe = 0 self._tensor_split = None - self._gpu_ids = None - self._requested_gpu_ids = None - self._memory_mode = None self._requested_memory_mode = None self._launched_with_inherited_mem_env = False self._layer_preserves_tensor_intent = False diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index a4c36c1db9..49423d9472 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -20,6 +20,8 @@ from pydantic import ( from picker.schemas import MAX_CHAT_TEMPLATE_BYTES +GgufMemoryMode = Literal["auto", "pinned", "resident"] + class LoadRequest(BaseModel): """Request to load a model for inference""" @@ -77,7 +79,17 @@ class LoadRequest(BaseModel): ) gpu_ids: Optional[List[int]] = Field( None, - description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries. For GGUF models the picked devices are pinned via CUDA/HIP_VISIBLE_DEVICES. On a Vulkan llama.cpp build the indices are ggml's compact Vulkan ordinals (as enumerated by the Vulkan probe and pinned via --device Vulkan), not CUDA physical indices; Vulkan enumerates independently of CUDA_VISIBLE_DEVICES.", + description = ( + "GPU placement pool, for example [0, 1]. Omit or pass [] to use " + "automatic selection. CUDA/ROCm values are physical GPU indices; " + "Vulkan values are ggml device ordinals. Explicit selection is not " + "supported on XPU, and physical IDs are unsupported when the parent " + "visibility mask uses " + "non-numeric or subdevice entries, including CUDA_VISIBLE_DEVICES " + "with UUID/MIG entries and ZE_AFFINITY_MASK with subdevice tokens " + "(for example '0.0,0.1') or FLAT-hierarchy tile handles. For GGUF " + "models the fitter may pin the smallest subset of this pool that fits." + ), ) speculative_type: Optional[str] = Field( None, @@ -173,17 +185,17 @@ class LoadRequest(BaseModel): raise ValueError("tensor_split must have a positive total") return value - gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + gguf_memory_mode: Optional[GgufMemoryMode] = Field( None, description = ( "GGUF host-memory placement mode (llama.cpp --mlock/--no-mmap). These " "control system RAM residency and file mapping on the host, NOT GPU VRAM " "placement, so they do not by themselves keep offloaded weights pinned in " - "VRAM. 'auto' (default) uses llama.cpp's normal memory-mapped loading. " - "'pinned' locks memory-mapped host pages so the OS cannot page them out. " - "'resident' avoids file-backed mapping and loads the model into RAM. On " - "llama.cpp builds with unified load modes it cannot also be locked, so the " - "OS may still swap it. Ignored for non-GGUF models." + "VRAM. Omit the field to preserve inherited llama.cpp settings. 'auto' " + "explicitly restores normal memory-mapped loading. 'pinned' locks mapped " + "host pages so the OS cannot page them out. 'resident' loads a RAM copy " + "instead of mapping the file; on newer llama.cpp builds that copy cannot " + "also be locked and may still be swapped. Ignored for non-GGUF models." ), ) @@ -264,7 +276,7 @@ class ValidateModelRequest(BaseModel): "delegate fitting to llama.cpp, while explicit layers are user-owned." ), ) - gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + gguf_memory_mode: Optional[GgufMemoryMode] = Field( None, description = "Intended GGUF memory placement mode; mirrors /load so validate's sizing agrees with the follow-up load.", ) @@ -538,7 +550,7 @@ class LoadResponse(BaseModel): "None for automatic selection." ), ) - gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + gguf_memory_mode: Optional[GgufMemoryMode] = Field( None, description = "Active GGUF memory placement mode. Only meaningful when is_gguf is True.", ) @@ -717,7 +729,7 @@ class InferenceStatusResponse(BaseModel): "None for automatic selection." ), ) - gguf_memory_mode: Optional[Literal["auto", "pinned", "resident"]] = Field( + gguf_memory_mode: Optional[GgufMemoryMode] = Field( None, description = "Active GGUF memory placement mode. Only meaningful when is_gguf is True.", ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6eb941445d..ef2e0fea5f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1003,6 +1003,7 @@ try: _DEFAULT_MAX_TOKENS_FLOOR, _DEFAULT_STREAM_STALL_TIMEOUT_S, _canonicalize_spec_mode, + _extra_args_draft_device_pin, _extra_args_set_spec_type, _hf_offline_if_dns_dead, detect_reasoning_flags, @@ -1041,6 +1042,7 @@ except ImportError: _DEFAULT_MAX_TOKENS_FLOOR, _DEFAULT_STREAM_STALL_TIMEOUT_S, _canonicalize_spec_mode, + _extra_args_draft_device_pin, _extra_args_set_spec_type, _hf_offline_if_dns_dead, detect_reasoning_flags, @@ -3270,7 +3272,7 @@ def _request_matches_loaded_settings( # GGUF host-memory placement mode is first-class; any change must reload (#7164). if LlamaCppBackend._canonical_memory_mode( request.gguf_memory_mode - ) != LlamaCppBackend._canonical_memory_mode(llama_backend.memory_mode): + ) != llama_backend.memory_mode: return False # An explicit memory_mode (incl. 'auto') over a child with inherited LLAMA_ARG_* flags # must reload so the scrub runs; the canonical check above treats 'auto' as omitted and @@ -4004,6 +4006,25 @@ def _reject_diffusion_memory_mode(config: ModelConfig, memory_mode: Optional[str ) +def _reject_draft_device_with_gpu_ids( + gpu_ids: Optional[List[int]], extra_args: Optional[list[str]] +) -> None: + """Reject a drafter pin that can escape the main model's GPU pool.""" + if not gpu_ids: + return + draft_device = _extra_args_draft_device_pin(extra_args) + if draft_device is not None: + raise HTTPException( + status_code = 400, + detail = ( + f"A draft-model device override ('{draft_device}') cannot be combined " + "with explicit gpu_ids: it would place the speculative drafter outside " + "the pinned GPUs the training guard budgeted. Remove the draft-device " + "flag to follow gpu_ids, or set it to cpu." + ), + ) + + async def _resolve_gguf_gpu_ids_for_request( config: ModelConfig, gpu_ids: Optional[List[int]] ) -> tuple[Optional[List[int]], bool]: @@ -4099,6 +4120,7 @@ def _guard_chat_load_against_training( llama_extra_args: Optional[list[str]] = None, n_parallel: int = 1, gpu_memory_mode: Literal["auto", "manual"] = "auto", + gpu_ids_are_vulkan_ordinals: bool = False, ) -> None: """Protect active training from automatically placed chat-model loads. @@ -4126,20 +4148,6 @@ def _guard_chat_load_against_training( if is_gguf and gpu_memory_mode == "manual" and diffusion_kind is False: return - # Vulkan ordinals enumerate independently of the CUDA index space the guard budgets in; - # resolving them as CUDA physical IDs would size free VRAM on the wrong card. Flag it so - # the guard budgets conservatively, matching /load's deferral to the probe (#7188). - # Computed before diffusion_gpu: only a CONFIRMED diffusion GGUF (diffusion_kind is True) - # uses the runner's CUDA single-device mask; an unclassified (None) GGUF on a Vulkan build - # must be budgeted as Vulkan ordinals, not resolved through the CUDA index space -- else an - # uncached remote normal GGUF would size the wrong physical card and could OOM training. - gpu_ids_are_vulkan_ordinals = False - if is_gguf and requested_gpu_ids and diffusion_kind is not True: - try: - gpu_ids_are_vulkan_ordinals = get_llama_cpp_backend().is_vulkan_build() - except Exception: - gpu_ids_are_vulkan_ordinals = False - diffusion_gpu = None if is_gguf and diffusion_kind is not False and not gpu_ids_are_vulkan_ordinals: # Use the same token selection as the runner: an explicit pick wins, @@ -4215,14 +4223,15 @@ def _resolve_inherited_extra_args( if not getattr(config, "is_gguf", False): return extra_llama_args llama_backend = get_llama_cpp_backend() - if not llama_backend.extra_args: + stored_args = getattr(llama_backend, "extra_args", None) + if not stored_args: return extra_llama_args # Inherit the previous load's extras (the chat-settings Apply path doesn't # round-trip them; an explicit [] still clears). Gated on (model_identifier, # hf_variant) to refuse cross-model pickup, and shadowing flags are # stripped so an inherited override can't win the last-wins CLI # parse against a freshly-supplied first-class field. - source = llama_backend.extra_args_source + source = getattr(llama_backend, "extra_args_source", None) # Compare against the resolved variant, not the request field: callers # commonly omit gguf_variant for local ``.gguf`` paths and HF auto-pick # flows. ``config.gguf_variant`` is the variant load_model was actually @@ -4254,7 +4263,7 @@ def _resolve_inherited_extra_args( # (appended last) shadows the bundled template while Studio reports its caps. fields_set = getattr(request, "model_fields_set", set()) stripped = strip_shadowing_flags( - llama_backend.extra_args, + stored_args, strip_context = "max_seq_length" in fields_set, strip_cache = "cache_type_kv" in fields_set, strip_spec = ("speculative_type" in fields_set or "spec_draft_n_max" in fields_set), @@ -4262,7 +4271,7 @@ def _resolve_inherited_extra_args( "chat_template_override" in fields_set or effective_chat_template_override is not None ), - strip_split_mode = _should_strip_split_mode(request, llama_backend.extra_args), + strip_split_mode = _should_strip_split_mode(request, stored_args), # A real gguf_memory_mode owns --mlock/--mmap/--no-mmap, so strip an # inherited one; a request that omits the field keeps the pass-through flag # (opt-in, mirrors the fast-path _strip_mem). null == "no opinion" (#7188). @@ -4597,133 +4606,30 @@ async def _load_model_impl( # (shared with /validate); its runner has no --mlock/--no-mmap plumbing. _reject_diffusion_memory_mode(config, request.gguf_memory_mode) + # Apply a same-model request's inherited extras once, before every + # preflight that depends on the effective llama.cpp command. + extra_llama_args = _resolve_inherited_extra_args( + request, + config, + model_identifier, + extra_llama_args, + effective_chat_template_override, + ) + # Normalize gpu_ids: empty list means auto-selection, same as None effective_gpu_ids = request.gpu_ids if request.gpu_ids else None - # Provenance for the backend's post-download DiffusionGemma guard. - # None keeps direct-call safety; the route sets the exact namespace when - # it validates an explicit pin below. - _gpu_ids_are_vulkan_ordinals: Optional[bool] = None + gpu_ids_are_vulkan_ordinals = False - # GGUF supports gpu_ids: validate the pick up front (before the training - # guard) so a bad pick is a clean 400, not masked by a VRAM 409. Rejects - # negative / out-of-range / duplicate ids and UUID/MIG parents. XPU hosts - # are rejected outright unless the llama.cpp build is Vulkan, in which case - # the Vulkan ordinal namespace is the intended target and the picker's - # torch-xpu ordinals don't apply; allow it through so the later Vulkan - # branch can validate/pin via --device Vulkan (#7188). - if config.is_gguf and effective_gpu_ids is not None: - from utils.hardware import DeviceType, get_device - from utils.hardware.hardware import resolve_requested_gpu_ids - from core.inference.llama_cpp import _extra_args_draft_device_pin - - _llama_backend = get_llama_cpp_backend() - _gguf_vulkan_build = await asyncio.to_thread(_llama_backend.is_vulkan_build) - # A confirmed diffusion GGUF is served by the visual-server runner, which takes a - # CUDA physical id via --gpu/DG_GPU (it remasks CUDA_VISIBLE_DEVICES), never a - # Vulkan ordinal. Route its gpu_ids through the CUDA resolver even on a Vulkan - # build, matching the training guard's diffusion_gpu handling (#7188). - _gguf_confirmed_diffusion = _classify_diffusion_gguf(config) is True - - if get_device() == DeviceType.XPU and not _gguf_vulkan_build: - raise HTTPException( - status_code = 400, - detail = ( - "GPU selection (gpu_ids) is not supported on Intel XPU. " - "Omit gpu_ids to use all devices." - ), - ) - # A draft-device override in extras (--spec-draft-device / -devd / --device-draft) - # naming a real GPU would place the MTP drafter outside the budgeted gpu_ids and - # OOM training on an unreserved card. Reject before teardown; drop the flag to - # follow the pin, or set it to cpu to offload (cpu/none allowed). llama_extra_args - # None means "inherit the running server's extras" on a same-model reload, and - # draft-device flags survive that inherit, so check the effective extras: the - # request's when provided, else the loaded same-model backend's (#7188). - _draft_extra = request.llama_extra_args - if _draft_extra is None: - _loaded_llama = get_llama_cpp_backend() - if _loaded_llama.is_loaded and _loaded_llama.extra_args: - source = _loaded_llama.extra_args_source - resolved_variant = (config.gguf_variant or "").lower() - request_variant = (request.gguf_variant or "").lower() - stored_variant = (source[1] or "").lower() if source else "" - same_model = bool( - source and source[0] and source[0].lower() == model_identifier.lower() - ) - if request.gguf_variant: - variant_mismatch = request_variant != stored_variant - else: - variant_mismatch = bool( - stored_variant and resolved_variant != stored_variant - ) - if same_model and not variant_mismatch: - _draft_extra = _loaded_llama.extra_args - _draft_dev_pin = _extra_args_draft_device_pin(_draft_extra) - if _draft_dev_pin is not None: - raise HTTPException( - status_code = 400, - detail = ( - f"A draft-model device override ('{_draft_dev_pin}') cannot be " - "combined with explicit gpu_ids: it would place the speculative " - "drafter outside the pinned GPUs the training guard budgeted. " - "Remove the draft-device flag to follow gpu_ids, or set it to cpu." - ), - ) - # A Vulkan build treats gpu_ids as Vulkan ordinals (never remapped through - # CUDA_VISIBLE_DEVICES), so defer to the backend probe even on a CUDA-visible - # host, else the CUDA resolver would 400 a valid Vulkan id under a CUDA/HIP - # mask (#7188). Otherwise keep the CUDA physical-ID resolver (#6414). A confirmed - # diffusion GGUF always takes the CUDA path: its runner uses CUDA ids, not the - # Vulkan llama-server, so a Vulkan build must not send it to the ordinal branch. - if get_device() == DeviceType.CUDA and ( - not _gguf_vulkan_build or _gguf_confirmed_diffusion - ): - _gpu_ids_are_vulkan_ordinals = False - # The CUDA resolver only validates the physical-ID mask; it can't tell the - # llama.cpp build is CPU-only. A CPU-only build ignores CUDA_VISIBLE_DEVICES, - # so the pin would silently run on CPU while /load reports gpu_ids active -- - # reject before teardown here too (mirrors the non-CUDA resolvable check) (#7188). - # Diffusion GGUF models bypass llama-server (the diffusion runner - # handles gpu_ids via --gpu/DG_GPU), so the llama.cpp GPU-lib - # check is irrelevant for them (#7188). - if not _gguf_confirmed_diffusion and await asyncio.to_thread( - _llama_backend._backend_lacks_gpu_lib - ): - raise HTTPException( - status_code = 400, - detail = ( - f"Requested gpu_ids {list(effective_gpu_ids)} but the llama.cpp " - "build has no GPU backend (CPU-only build); it would ignore the " - "pin and run on CPU. Omit gpu_ids to run on CPU." - ), - ) - try: - resolve_requested_gpu_ids(effective_gpu_ids) - except ValueError as exc: - raise HTTPException(status_code = 400, detail = str(exc)) from exc - else: - _gpu_ids_are_vulkan_ordinals = bool( - _gguf_vulkan_build and not _gguf_confirmed_diffusion - ) - # Non-CUDA GGUF host or a Vulkan build on any host: the CUDA resolver can't - # enumerate llama.cpp's devices, so gate on the backend probe and reject - # before teardown (#7188). A torch-less Vulkan host reports CPU but its - # probe is non-empty, so it falls through. - if not await asyncio.to_thread(_llama_backend.has_gpu_backend): - raise HTTPException( - status_code = 400, - detail = ( - "gpu_ids selection is not supported: no GPU backend " - "detected on this host." - ), - ) - try: - await asyncio.to_thread( - _llama_backend.assert_requested_gpu_ids_resolvable, - effective_gpu_ids, - ) - except ValueError as exc: - raise HTTPException(status_code = 400, detail = str(exc)) from exc + # Validate the full GGUF placement pool before the training guard so an + # invalid physical ID or Vulkan ordinal is a clean 400, not a masked VRAM + # 409. The same helper is used by /validate. + gguf_gpu_ids: Optional[List[int]] = None + if config.is_gguf: + ( + gguf_gpu_ids, + gpu_ids_are_vulkan_ordinals, + ) = await _resolve_gguf_gpu_ids_for_request(config, effective_gpu_ids) + _reject_draft_device_with_gpu_ids(gguf_gpu_ids, extra_llama_args) if not config.is_gguf and _mlx_distributed_launch_detected(): raise HTTPException( status_code = 400, @@ -4753,18 +4659,6 @@ async def _load_model_impl( "architectures)" ) - # Inherit the previous same-model load's pass-through extras when this - # request omits the field (a settings-Apply reload doesn't round-trip - # them); shadow-stripped so an inherited flag can't override a - # first-class field the caller did set (#5401). - extra_llama_args = _resolve_inherited_extra_args( - request, - config, - model_identifier, - extra_llama_args, - effective_chat_template_override, - ) - # Apply the training coexistence policy before the unload step below # frees the resident model. Off-loop: the default-mode guard does sync work. await asyncio.to_thread( @@ -4778,6 +4672,7 @@ async def _load_model_impl( llama_extra_args = extra_llama_args, n_parallel = getattr(fastapi_request.app.state, "llama_parallel_slots", 1), gpu_memory_mode = request.gpu_memory_mode, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, ) # ── GGUF path: load via llama-server ────────────────────── @@ -4847,8 +4742,7 @@ async def _load_model_impl( gpu_layers = request.gpu_layers, n_cpu_moe = request.n_cpu_moe, tensor_split = request.tensor_split, - gpu_ids = effective_gpu_ids, - gpu_ids_are_vulkan_ordinals = _gpu_ids_are_vulkan_ordinals, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, memory_mode = request.gguf_memory_mode, n_parallel = _n_parallel, # Issue #7164: explicit GPU pin resolved to physical ids above. @@ -5321,47 +5215,23 @@ async def validate_model( # Reject DiffusionGemma host-memory placement here too, matching /load (#7188). _reject_diffusion_memory_mode(config, request.gguf_memory_mode) + effective_extra_args = _resolve_inherited_extra_args( + request, config, model_identifier, None + ) + # Apply the same training coexistence policy as /load before the frontend # unloads the current model. effective_gpu_ids = request.gpu_ids if request.gpu_ids else None + gpu_ids_are_vulkan_ordinals = False if config.is_gguf: - validated_gpu_ids, _ = await _resolve_gguf_gpu_ids_for_request( + ( + validated_gpu_ids, + gpu_ids_are_vulkan_ordinals, + ) = await _resolve_gguf_gpu_ids_for_request( config, effective_gpu_ids, ) - if validated_gpu_ids: - from core.inference.llama_cpp import _extra_args_draft_device_pin - loaded_llama = get_llama_cpp_backend() - if loaded_llama.is_loaded and loaded_llama.extra_args: - source = loaded_llama.extra_args_source - resolved_variant = (config.gguf_variant or "").lower() - request_variant = (request.gguf_variant or "").lower() - stored_variant = (source[1] or "").lower() if source else "" - same_model = bool( - source and source[0] and source[0].lower() == model_identifier.lower() - ) - try: - resolve_requested_gpu_ids(effective_gpu_ids) - except ValueError as exc: - raise HTTPException(status_code = 400, detail = str(exc)) from exc - else: - # Non-CUDA GGUF host or a Vulkan build: gate on the llama.cpp probe so an - # unsupported selection is rejected before the unload (#7188). - if not await asyncio.to_thread(_loaded_llama.has_gpu_backend): - raise HTTPException( - status_code = 400, - detail = ( - "gpu_ids selection is not supported: no GPU backend " - "detected on this host." - ), - ) - try: - await asyncio.to_thread( - _loaded_llama.assert_requested_gpu_ids_resolvable, - effective_gpu_ids, - ) - except ValueError as exc: - raise HTTPException(status_code = 400, detail = str(exc)) from exc + _reject_draft_device_with_gpu_ids(validated_gpu_ids, effective_extra_args) effective_load_in_4bit = _effective_load_in_4bit(config, request.load_in_4bit) # Both checks cover the [adapter, base] set (matching the scan route and workers): @@ -5429,11 +5299,6 @@ async def validate_model( # training guard must not refuse it. Real loads omit include_context_length / # include_chat_template, and /load applies the guard again. if not (request.include_context_length or request.include_chat_template): - # Match /load's inherited llama.cpp extras and parallel slot count so - # validation cannot pass a smaller estimate than the subsequent load. - effective_extra_args = _resolve_inherited_extra_args( - request, config, model_identifier, None - ) # Off-loop: guard does sync nvidia-smi / HF work. await asyncio.to_thread( _guard_chat_load_against_training, @@ -5450,6 +5315,7 @@ async def validate_model( else 1 ), gpu_memory_mode = request.gpu_memory_mode, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, ) # A selected GGUF loads via llama.cpp: auto_map Python and root pickle weights in a diff --git a/studio/backend/routes/training_vram.py b/studio/backend/routes/training_vram.py index 2fa8e2c23e..d49e75f06c 100644 --- a/studio/backend/routes/training_vram.py +++ b/studio/backend/routes/training_vram.py @@ -308,16 +308,11 @@ def can_load_chat_during_training( return False, {"mode": mode, "reason": "estimate_unavailable"} free_by_index = _free_vram_by_index(get_visible_gpu_utilization().get("devices", [])) - if requested_gpu_ids and vulkan_gguf: - # Vulkan ordinals cannot be mapped to CUDA physical indices. Budget - # the least-free N visible cards for an N-device request. If that - # conservative subset fits, any physical mapping of the ordinals - # fits, without collapsing a multi-GPU request to one card. - visible_free = list(free_by_index.values()) - if not visible_free: - return False, {"mode": "gguf_vulkan", "reason": "no_visible_gpus"} - n_pins = min(len(requested_gpu_ids), len(visible_free)) - free_vals = sorted(visible_free)[:n_pins] + if requested_gpu_ids and gpu_ids_are_vulkan_ordinals: + # Vulkan ordinals cannot be mapped to CUDA indices. The least-free + # N-device subset is the safe bound for an unknown mapping. + visible_free = sorted(free_by_index.values()) + free_vals = visible_free[: min(len(requested_gpu_ids), len(visible_free))] elif single_device_gpu is not None: token = str(single_device_gpu).strip() if not token: @@ -392,20 +387,28 @@ def can_load_chat_during_training( needed_gb = required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB aggregate_fits = usable_gb >= needed_gb - # device_map="balanced" shards across GPUs: an even-share floor stops one - # near-full GPU hiding behind aggregate capacity. GGUF self-places, no floor. + # Explicit HF and Vulkan placement shard across a known number of GPUs. + # An even-share floor stops one near-full GPU hiding behind aggregate capacity. min_free_gb = min(free_vals) per_gpu_fits = True - if mode == "explicit" and len(free_vals) > 1: - per_gpu_fits = min_free_gb >= needed_gb / len(free_vals) + per_gpu_needed_gb = None + if mode == "gguf_vulkan": + per_gpu_needed_gb = needed_gb / len(requested_gpu_ids) + elif mode == "explicit" and len(free_vals) > 1: + per_gpu_needed_gb = needed_gb / len(free_vals) + if per_gpu_needed_gb is not None: + per_gpu_fits = min_free_gb >= per_gpu_needed_gb - return aggregate_fits and per_gpu_fits, { + info = { "mode": mode, "required_gb": round(required_gb, 3), "usable_gb": round(usable_gb, 3), "needed_gb": round(needed_gb, 3), "min_free_gb": round(min_free_gb, 3), } + if per_gpu_needed_gb is not None: + info["per_gpu_needed_gb"] = round(per_gpu_needed_gb, 3) + return aggregate_fits and per_gpu_fits, info except Exception as e: # Never let a sizing failure load a chat model into a training OOM. logger.warning("Chat-load coexistence probe failed; will refuse: %s", e) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 57db2ad874..808e02c562 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -540,6 +540,7 @@ class TestChatLoadGuardRoute(unittest.TestCase): decision, gpu_memory_mode = "auto", requested_gpu_ids = None, + gpu_ids_are_vulkan_ordinals = False, ): config = config or SimpleNamespace(is_gguf = False, is_lora = False, path = None) with _stub_guard_deps( @@ -553,6 +554,7 @@ class TestChatLoadGuardRoute(unittest.TestCase): max_seq_length = 0, requested_gpu_ids = requested_gpu_ids, gpu_memory_mode = gpu_memory_mode, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, ) def test_noop_when_training_inactive(self): @@ -701,11 +703,6 @@ class TestChatLoadGuardRoute(unittest.TestCase): with ( patch.object(self.route, "_classify_diffusion_gguf", return_value = None), patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), - patch.object( - self.route, - "get_llama_cpp_backend", - return_value = SimpleNamespace(is_vulkan_build = lambda: True), - ), ): self._guard( config = config, @@ -713,6 +710,7 @@ class TestChatLoadGuardRoute(unittest.TestCase): training_active = True, decision = (True, {"mode": "gguf_vulkan"}), requested_gpu_ids = [0, 1], + gpu_ids_are_vulkan_ordinals = True, ) self.assertEqual(len(captured), 1) self.assertTrue(captured[0]["gpu_ids_are_vulkan_ordinals"]) @@ -726,11 +724,6 @@ class TestChatLoadGuardRoute(unittest.TestCase): with ( patch.object(self.route, "_classify_diffusion_gguf", return_value = None), patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), - patch.object( - self.route, - "get_llama_cpp_backend", - return_value = SimpleNamespace(is_vulkan_build = lambda: False), - ), patch.object(self.route.LlamaCppBackend, "_diffusion_gpu_arg", return_value = "0"), ): self._guard( @@ -1328,7 +1321,11 @@ class TestLoadModelGuardIntegration(unittest.TestCase): captured = [] with ( patch("utils.transformers_version.latest_tier_active_for", return_value = False), - patch.object(self.route, "validate_extra_args", return_value = None), + patch.object( + self.route, + "validate_extra_args", + side_effect = lambda args: list(args) if args else None, + ), patch.object( self.route, "_resolve_model_identifier_for_request", diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 81df2df04a..a4c732b974 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1245,8 +1245,6 @@ class TestRouteErrors(unittest.TestCase): # validate gpu_ids through the CUDA physical-id resolver (the diffusion runner takes a # CUDA --gpu/DG_GPU id), NOT the Vulkan-ordinal branch, which would size the wrong # device namespace and reject a valid physical pick (#7188). - from unittest.mock import MagicMock - import utils.hardware as hardware_pkg inference_route = _load_route_module( @@ -1268,14 +1266,11 @@ class TestRouteErrors(unittest.TestCase): audio_type = None, has_audio_input = False, ) - assert_resolvable = MagicMock() # the Vulkan-ordinal validator; must NOT be called fake_backend = SimpleNamespace( is_loaded = False, model_identifier = None, is_vulkan_build = lambda: True, _backend_lacks_gpu_lib = lambda *a, **k: False, - has_gpu_backend = lambda *a, **k: True, - assert_requested_gpu_ids_resolvable = assert_resolvable, ) with ( patch.object( @@ -1305,9 +1300,8 @@ class TestRouteErrors(unittest.TestCase): current_subject = "test-user", ) ) - # Took the CUDA physical-id resolver, never the Vulkan-ordinal validator. + # Took the CUDA physical-id resolver rather than the Vulkan ordinal path. self.assertIn("CUDA_PATH_SENTINEL", exc_info.exception.detail) - assert_resolvable.assert_not_called() def test_inherited_extras_preserve_memory_flags_when_mode_omitted(self): # A same-model Apply that omits gguf_memory_mode must NOT drop an inherited diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 3e1c9bf044..ac8ca4986b 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -138,7 +138,7 @@ def _loaded_backend(**overrides): backend._extra_args_source = None backend._gguf_path = None backend._gpu_ids = None - backend._memory_mode = None + backend._requested_memory_mode = None for key, value in overrides.items(): setattr(backend, key, value) if "_gpu_ids" in overrides and "_requested_gpu_ids" not in overrides: @@ -198,13 +198,13 @@ def _base_target_state_kwargs(backend): def test_already_in_target_state_matches_same_memory_mode(): - backend = _loaded_backend(_memory_mode = "resident") + backend = _loaded_backend(_requested_memory_mode = "resident") kwargs = _base_target_state_kwargs(backend) assert backend._already_in_target_state(**kwargs) is True def test_already_in_target_state_rejects_different_memory_mode(): - backend = _loaded_backend(_memory_mode = "resident") + backend = _loaded_backend(_requested_memory_mode = "resident") kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "pinned" assert backend._already_in_target_state(**kwargs) is False @@ -230,11 +230,8 @@ def test_already_in_target_state_strips_device_extras_under_gpu_ids(): assert backend._already_in_target_state(**kwargs) is True -# ── GPU selection: reject-before behaviour (host-residency / Vulkan hardening) ─ -# The pure gpu_ids filter/order/normalize picker tests from #7188 are dropped here: -# #6414 already covers the physical-ID picker on main. What remains is the -# resolvability preflight the route runs (assert_requested_gpu_ids_resolvable) to -# reject a bad selection BEFORE the active model is torn down (#7188). +# GPU selection launch behavior. Route-level validation lives in +# test_gpu_selection.py; these tests cover the backend's emitted placement. def _fit_fallback_backend( @@ -307,19 +304,6 @@ def test_empty_probe_preserves_explicit_gpu_ids(tmp_path): assert "--fit" in cmd and cmd[cmd.index("--fit") + 1] == "on" -def test_empty_probe_still_rejects_vulkan_gpu_ids(tmp_path): - """A Vulkan build cannot map physical ids to ggml ordinals without a probe, so an - empty Vulkan probe must keep rejecting the selection (tracked as #7201) rather than - silently spreading the load across every device. The route runs this preflight via - assert_requested_gpu_ids_resolvable BEFORE any teardown (the merged impl moved the - check out of load_model into the route).""" - backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = [], vulkan = True) - - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): - with pytest.raises(ValueError, match = "do not match any visible GPUs"): - backend.assert_requested_gpu_ids_resolvable([0]) - - def test_torchless_vulkan_populated_probe_uses_identity_ordinals(tmp_path): """A torch-less Vulkan host has an empty parent-visible mask, so gpu_ids ARE the Vulkan ordinals: with a populated probe the selection loads (pinned via --device @@ -404,51 +388,6 @@ def test_vulkan_fit_keeps_discrete_device_selected(tmp_path): assert "--device" in cmd and cmd[cmd.index("--device") + 1] == "Vulkan1" -def test_vulkan_rejects_duplicate_gpu_ids(tmp_path): - """The CUDA resolver's duplicate check is skipped for the Vulkan path, so the branch - must reject duplicates itself rather than let set() silently collapse them (#7188).""" - backend, _gguf = _fit_fallback_backend( - tmp_path, gpu_memory = [(0, 10000, 16000), (1, 8000, 16000)], vulkan = True - ) - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): - with pytest.raises(ValueError, match = "unique and non-negative"): - backend.assert_requested_gpu_ids_resolvable([0, 0]) - - -def test_empty_probe_rejects_gpu_ids_without_gpu_backend(tmp_path): - """A non-Vulkan build with an empty probe AND empty parent-visible mask has no GPU - backend, so the backend must reject gpu_ids rather than pin a non-existent device - and silently run on CPU (#7188).""" - from utils.hardware import DeviceType - - backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = []) # empty probe - - with ( - # CUDA host (the mask governs placement) but no mask -> genuinely GPU-less. - patch("utils.hardware.get_device", return_value = DeviceType.CUDA), - patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []), - ): - with pytest.raises(ValueError, match = "no GPU backend"): - backend.assert_requested_gpu_ids_resolvable([0]) - - -def test_empty_probe_rejects_gpu_ids_outside_parent_mask(tmp_path): - """Empty non-Vulkan probe but a set parent-visible mask (real GPU, no telemetry): - a request outside that mask is a genuine 'no such GPU', so it must still raise - rather than pin an id the host can't offer (#7188).""" - from utils.hardware import DeviceType - - backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = []) # empty probe - - with ( - # CUDA host with telemetry down: the mask [0, 1] is real, but 9 is outside it. - patch("utils.hardware.get_device", return_value = DeviceType.CUDA), - patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0, 1]), - ): - with pytest.raises(ValueError, match = "do not match any visible GPUs"): - backend.assert_requested_gpu_ids_resolvable([9]) - - def test_vulkan_gpu_ids_strips_conflicting_user_device(tmp_path): """On Vulkan --device is the only pin. With explicit gpu_ids, a user --device in extras must be stripped so it can't override Unsloth's pin (#7188). Unsloth's --device @@ -496,51 +435,6 @@ def test_vulkan_gpu_ids_strips_conflicting_user_device(tmp_path): assert "--top-k" in cmd and cmd[cmd.index("--top-k") + 1] == "5" -def test_populated_probe_nonmatching_gpu_ids_still_raises(tmp_path): - """When the probe DID enumerate GPUs but none match the request, the id is genuinely - absent and the load must still raise (the empty-probe relaxation must not swallow - a real 'no such GPU' error).""" - backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)]) - - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0]): - with pytest.raises(ValueError, match = "do not match any visible GPUs"): - backend.assert_requested_gpu_ids_resolvable([9]) - - -@pytest.mark.parametrize( - "bad_ids, match", - [ - ([99], "do not match any visible GPUs"), # out-of-range on a Vulkan host - ([0, 0], "unique and non-negative"), # duplicate ids - ], -) -def test_invalid_gpu_ids_rejected_before_teardown(tmp_path, bad_ids, match): - """A bad gpu_ids (a typo like [99], or a duplicate) must be rejected by the route's - resolvability preflight BEFORE the active model is torn down. The merged impl does - the check in the route (assert_requested_gpu_ids_resolvable) rather than a Phase-0 - block inside load_model, so exercise that helper directly; a running server is never - reached, hence never killed (#7188).""" - backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)], vulkan = True) - killed = [] - backend._kill_process = lambda: killed.append(True) - - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): - with pytest.raises(ValueError, match = match): - backend.assert_requested_gpu_ids_resolvable(bad_ids) - - assert killed == [], f"active model was torn down before rejecting gpu_ids={bad_ids}" - - -def test_valid_gpu_ids_pass_resolvable_preflight(tmp_path): - """The resolvability preflight must NOT over-reject a VALID selection: a good gpu_ids - passes without raising so the route proceeds to load.""" - backend, _gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)], vulkan = True) - - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): - # Does not raise. - backend.assert_requested_gpu_ids_resolvable([0]) - - def test_gpu_ids_preserved_on_fit_fallback(tmp_path): """When _select_gpus falls back to --fit on, still pin CUDA_VISIBLE_DEVICES.""" gguf = tmp_path / "model.gguf" @@ -653,74 +547,6 @@ def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_id assert ("LLAMA_ARG_DEVICE" not in captured_envs[-1]) == scrubbed -def test_memory_mode_clears_inherited_mmap_env_vars(tmp_path): - """An explicit memory_mode must clear stale LLAMA_ARG_* env vars.""" - gguf = tmp_path / "model.gguf" - _write_minimal_gguf(gguf) - - backend = LlamaCppBackend() - backend._get_gpu_memory = lambda _binary = None: [(0, 10000, 16000)] - backend._read_gguf_metadata = lambda _p: None - backend._can_estimate_kv = lambda: False - backend._get_gguf_size_bytes = lambda _p: 1024 - backend._mmproj_vram_bytes = lambda _p: 0 - backend._resolve_launch_mmproj_path = lambda **k: None - backend._apu_ram_shortfall_message = lambda *a, **k: None - backend._amd_apu_wants_unified_memory = lambda *a, **k: False - backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" - backend._select_gpus = lambda *a, **k: ([0], False) - backend._wait_for_health = lambda timeout: True - backend._detect_audio_type_strict = lambda: None - backend._apply_detected_audio = lambda _d: True - - base_env = dict(os.environ) - base_env.update( - { - "LLAMA_ARG_LOAD_MODE": "dio", - "LLAMA_ARG_MLOCK": "1", - "LLAMA_ARG_MMAP": "1", - "LLAMA_ARG_NO_MMAP": "1", - "LLAMA_ARG_DIO": "1", - } - ) - backend._llama_server_env_for_binary = lambda _binary: dict(base_env) - - captured_envs = [] - - def _make_fake_popen(cmd, **kwargs): - if not cmd or str(cmd[0]) != "/fake/llama-server": - return _REAL_POPEN(cmd, **kwargs) - - class _FakePopen: - pid = 12345 - - def __init__(self, cmd, **kwargs): - captured_envs.append(kwargs.get("env") or dict(os.environ)) - - def poll(self): - return None - - return _FakePopen(cmd, **kwargs) - - with patch.object(subprocess, "Popen", side_effect = _make_fake_popen): - backend.load_model( - gguf_path = str(gguf), - model_identifier = "test", - memory_mode = "auto", - ) - - assert captured_envs, "llama-server was not spawned" - env = captured_envs[-1] - for var in ( - "LLAMA_ARG_LOAD_MODE", - "LLAMA_ARG_MLOCK", - "LLAMA_ARG_MMAP", - "LLAMA_ARG_NO_MMAP", - "LLAMA_ARG_DIO", - ): - assert var not in env - - @pytest.mark.parametrize( "mode,user_flag,winning", [ @@ -846,55 +672,6 @@ def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): assert cmd[cmd.index("--device") + 1] == "Vulkan1" -def test_has_gpu_backend_accepts_parent_mask_when_probe_empty(): - """A real GPU with telemetry down (empty probe) but a numeric parent-visible mask - must count as a backend, so /load does not 400 a selection assert_requested_gpu_ids_ - resolvable would accept via the mask fallback. No probe AND no mask is GPU-less (#7188).""" - backend = LlamaCppBackend() - backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" - backend._get_gpu_memory = lambda _binary = None: [] # telemetry unavailable - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0, 1]): - assert backend.has_gpu_backend() is True - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = []): - assert backend.has_gpu_backend() is False - - -def test_partial_gpu_ids_match_is_rejected(): - """A partial match such as [0, 99] against a probe of [0] must be rejected, not - silently narrowed to [0] (which would place the model on fewer GPUs than the caller - asked for). Every requested id must be present, not just one (#7188).""" - probe = [(0, 10000, 16000)] - for is_vulkan in (True, False): - with pytest.raises(ValueError, match = "do not match any visible GPUs"): - LlamaCppBackend._assert_gpu_ids_resolvable([0, 99], probe, is_vulkan) - # A full match still passes. - LlamaCppBackend._assert_gpu_ids_resolvable([0], probe, is_vulkan) - - -def test_duplicate_or_negative_gpu_ids_rejected_on_all_backends(): - """Duplicate/negative ids are invalid on every backend, not just Vulkan: a non-Vulkan - populated probe must not collapse [0, 0] into {0} and pin one GPU while recording the - duplicate (a torch-less CUDA host defers here, skipping the CUDA resolver) (#7188).""" - probe = [(0, 10000, 16000), (1, 8000, 16000)] - for is_vulkan in (True, False): - for bad in ([0, 0], [-1], [0, -1]): - with pytest.raises(ValueError, match = "unique and non-negative"): - LlamaCppBackend._assert_gpu_ids_resolvable(bad, probe, is_vulkan) - # A valid unique selection still passes. - LlamaCppBackend._assert_gpu_ids_resolvable([0, 1], probe, is_vulkan) - - -def test_cpu_only_build_rejects_gpu_ids(): - """A CPU-only llama.cpp build ignores CUDA_VISIBLE_DEVICES, so an explicit pin can't be - honored: reject it before teardown even with a populated nvidia-smi probe on a CUDA host, - instead of reporting a GPU-pinned load that silently runs on CPU (#7188).""" - probe = [(0, 10000, 16000)] - with pytest.raises(ValueError, match = "CPU-only build"): - LlamaCppBackend._assert_gpu_ids_resolvable([0], probe, False, backend_lacks_gpu_lib = True) - # With a GPU backend lib present the same pin is accepted. - LlamaCppBackend._assert_gpu_ids_resolvable([0], probe, False, backend_lacks_gpu_lib = False) - - def test_backend_lacks_gpu_lib_detection(tmp_path): """_backend_lacks_gpu_lib is True ONLY for a clear CPU-only split-lib layout (a ggml-cpu/base lib with no gpu sibling); a gpu lib, or an unrecognized/static layout @@ -986,25 +763,6 @@ def test_is_vulkan_backend_matches_versioned_soname(tmp_path): assert LlamaCppBackend._is_vulkan_backend(binary) is False -def test_empty_non_cuda_probe_rejects_gpu_ids_even_with_stray_mask(): - """On a Metal/SYCL/CPU (non-CUDA) backend the launcher's CUDA_VISIBLE_DEVICES is - ignored (SYCL keys off ONEAPI_DEVICE_SELECTOR, Metal off none), so an empty probe - with a stray parent-visible mask must NOT accept gpu_ids -- the pin would be silently - dropped onto the default device. A CUDA host with the same empty probe + mask is the - real telemetry-down case and still falls through (#7188).""" - from utils.hardware import DeviceType - with patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0]): - # A stray CUDA mask on a non-CUDA host must not be trusted to honor the pin. - for dev in (DeviceType.MLX, DeviceType.XPU, DeviceType.CPU): - with patch("utils.hardware.get_device", return_value = dev): - with pytest.raises(ValueError, match = "does not support explicit GPU selection"): - LlamaCppBackend._assert_gpu_ids_resolvable([0], [], False) - # CUDA host (ROCm reports CUDA too): empty probe + valid mask is telemetry-down, - # so the selection is accepted for the loader to pin via CUDA_VISIBLE_DEVICES. - with patch("utils.hardware.get_device", return_value = DeviceType.CUDA): - LlamaCppBackend._assert_gpu_ids_resolvable([0], [], False) - - def test_explicit_gpu_ids_strips_stored_device_extra_args(tmp_path): """With explicit gpu_ids a user --device is dropped from BOTH the command and the PERSISTED extras, so a later same-model reload that inherits these (after gpu_ids is @@ -1045,7 +803,7 @@ def test_explicit_gpu_ids_strips_stored_device_extra_args(tmp_path): def test_memory_mode_auto_matches_none_in_target_state(): """An explicit 'auto' request should not reload a load that omitted the field.""" - backend = _loaded_backend(_memory_mode = None) + backend = _loaded_backend() kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "auto" assert backend._already_in_target_state(**kwargs) is True @@ -1055,7 +813,7 @@ def test_explicit_auto_reloads_when_child_inherited_mem_env(): """When the live child was launched with no mode but inherited operator LLAMA_ARG_* placement flags, an explicit 'auto' request must reload so the scrub runs -- otherwise it dedups to already-loaded and stays mlocked (#7164).""" - backend = _loaded_backend(_memory_mode = None, _launched_with_inherited_mem_env = True) + backend = _loaded_backend(_launched_with_inherited_mem_env = True) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "auto" assert backend._already_in_target_state(**kwargs) is False @@ -1065,7 +823,7 @@ def test_omitted_mode_does_not_reload_child_with_inherited_mem_env(): """A request that also omits the mode (memory_mode=None) does NOT reload the inherited-env child: omitting keeps the operator env, so there's nothing to scrub and no spurious reload (which would be rejected during training).""" - backend = _loaded_backend(_memory_mode = None, _launched_with_inherited_mem_env = True) + backend = _loaded_backend(_launched_with_inherited_mem_env = True) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = None assert backend._already_in_target_state(**kwargs) is True @@ -1074,14 +832,14 @@ def test_omitted_mode_does_not_reload_child_with_inherited_mem_env(): def test_explicit_auto_matches_scrubbed_child(): """Once the child was launched clean (no inherited env), an explicit 'auto' re-Apply dedups to already-loaded -- no needless reload.""" - backend = _loaded_backend(_memory_mode = None, _launched_with_inherited_mem_env = False) + backend = _loaded_backend(_launched_with_inherited_mem_env = False) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "auto" assert backend._already_in_target_state(**kwargs) is True def test_memory_mode_pinned_does_not_match_none(): - backend = _loaded_backend(_memory_mode = None) + backend = _loaded_backend() kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "pinned" assert backend._already_in_target_state(**kwargs) is False @@ -1128,7 +886,7 @@ def test_requested_memory_mode_preserves_explicit_auto( """The response echoes requested_memory_mode, not the canonical placement. An explicit "auto" must survive as "auto" (not collapse to null) so the UI can restore it and a later reload re-runs the inherited-env scrub instead of letting LLAMA_ARG_MLOCK creep - back; canonical _memory_mode still maps "auto" -> None for placement (#7188).""" + back; the canonical memory_mode property still maps "auto" to None (#7188).""" gguf = tmp_path / "model.gguf" _write_minimal_gguf(gguf) backend = _mem_env_backend(gguf) @@ -1297,7 +1055,7 @@ def test_remote_diffusion_rejects_explicit_memory_mode_after_download(tmp_path): @pytest.mark.parametrize("mode", [None, "auto", "AUTO", ""]) def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): """auto/blank/None is the allowed no-op default for diffusion. A successful load - must clear any _memory_mode left by a prior llama-server load so reload-dedup + must clear any requested memory mode left by a prior llama-server load so reload-dedup doesn't force a needless kill+restart of the diffusion server. (The single-device gpu_ids collapse is recorded inside _start_diffusion_server, exercised by #6414's picker tests; this stub replaces the runner, so only the memory-mode clear, which @@ -1312,7 +1070,6 @@ def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): backend._start_diffusion_server = lambda **kw: True # Simulate leftover placement state from a previous llama-server GGUF load. - backend._memory_mode = "resident" backend._requested_memory_mode = "resident" backend._launched_with_inherited_mem_env = True @@ -1324,7 +1081,7 @@ def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): ) is True ) - assert backend._memory_mode is None + assert backend.memory_mode is None assert backend._requested_memory_mode is None assert backend._launched_with_inherited_mem_env is False diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py index 333a9cdad2..9898462c05 100644 --- a/studio/backend/tests/test_tp_vision_regression.py +++ b/studio/backend/tests/test_tp_vision_regression.py @@ -837,7 +837,7 @@ def _mem_loaded_backend( b._chat_template_override = None b._gguf_path = None b._gpu_ids = None - b._memory_mode = memory_mode + b._requested_memory_mode = memory_mode b._launched_with_inherited_mem_env = launched_with_inherited_mem_env return b diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 440b807ade..dd3b0cbcf4 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -63,6 +63,7 @@ import { import type { ModelType } from "../types"; import { isMultimodalResponse } from "../types/api"; import type { + GgufMemoryMode, GgufVariantDetail, OpenAIChatCompletionsRequest, OpenAIChatMessage, @@ -1501,7 +1502,7 @@ async function autoLoadSmallestModel(): Promise<{ // The safetensors fallback omits both fields and uses HF auto-placement. gpu_ids?: number[]; gpu_memory_mode?: "auto" | "manual"; - gguf_memory_mode?: "auto" | "pinned" | "resident" | null; + gguf_memory_mode?: GgufMemoryMode | null; }): Promise { const validation = await validateModel({ ...payload, diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index 785212a2c4..623d5bc385 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -28,7 +28,7 @@ export { type LocalModelInfo, type ScanFolderInfo, } from "./api/chat-api"; -export type { GgufVariantDetail } from "./types/api"; +export type { GgufMemoryMode, GgufVariantDetail } from "./types/api"; export { ChatSettingsPanel, ParamSlider, diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index cf8bb49bcd..8d8988cd84 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -23,6 +23,7 @@ import { DEFAULT_INFERENCE_PARAMS, type InferenceParams, } from "../types/runtime"; +import type { GgufMemoryMode } from "../types/api"; import { loadChatSettingsWithLegacyImport, savePersistedChatSettingsPatch, @@ -621,7 +622,7 @@ export function loadedGpuMemoryFields(resp: { n_moe_layers?: number; gpu_ids?: number[] | null; requested_gpu_ids?: number[] | null; - gguf_memory_mode?: "auto" | "pinned" | "resident" | null; + gguf_memory_mode?: GgufMemoryMode | null; }) { // GPU-memory state is meaningful only for a GGUF chat load. A non-GGUF response // still carries gpu_memory_mode (its default "auto" is serialized), so gate on @@ -988,18 +989,14 @@ type ChatRuntimeStore = { ggufLayerCount: number | null; /** MoE expert-layer count: the nCpuMoe slider max; 0/null hides the slider. */ moeLayerCount: number | null; - /** Picked physical GPU indices (null = use all / automatic). */ + /** Picked IDs in the backend-declared GPU namespace (null = automatic). */ selectedGpuIds: number[] | null; loadedGpuIds: number[] | null; /** Requested GGUF host-memory loading policy. null = backend/default behavior. */ - ggufMemoryMode: "auto" | "pinned" | "resident" | null; + ggufMemoryMode: GgufMemoryMode | null; /** Backend-reported GGUF host-memory loading mode, used as the loaded * baseline for status hydration and rollback. */ - activeMemoryMode: "auto" | "pinned" | "resident" | null; - /** Persisted: when false, picking a local model stages it as - * `pendingSelection` (and opens settings) instead of loading immediately, - * so load settings can be set before the single load. */ - loadOnSelection: boolean; + activeMemoryMode: GgufMemoryMode | null; /** Persisted: expand every On Device GGUF repo's quantizations by default * instead of waiting for a click. */ expandQuantizations: boolean; diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index e28297638e..7f96539c34 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -3,6 +3,8 @@ import type { TransformersUpgradeInfo } from "@/features/transformers-upgrade"; +export type GgufMemoryMode = "auto" | "pinned" | "resident"; + export interface BackendModelDetails { id: string; name?: string | null; @@ -75,10 +77,10 @@ export interface LoadModelRequest { n_cpu_moe?: number; /** Manual mode: relative model share per GPU (--tensor-split), in GPU order. */ tensor_split?: number[] | null; - /** Picked physical GPU indices (omit/empty = automatic). */ + /** Picked CUDA/ROCm physical IDs or Vulkan ordinals (omit/empty = automatic). */ gpu_ids?: number[]; /** GGUF host-only loading policy. It does not control GPU VRAM residency. */ - gguf_memory_mode?: "auto" | "pinned" | "resident" | null; + gguf_memory_mode?: GgufMemoryMode | null; } export interface ValidateModelResponse { @@ -195,7 +197,7 @@ export interface LoadModelResponse { /** User-requested GPU set before fit-time narrowing. */ requested_gpu_ids?: number[] | null; /** GGUF host-memory placement mode the load was invoked with. */ - gguf_memory_mode?: "auto" | "pinned" | "resident" | null; + gguf_memory_mode?: GgufMemoryMode | null; } export interface UnloadModelRequest { @@ -252,7 +254,7 @@ export interface InferenceStatusResponse { /** User-requested GPU set before fit-time narrowing. */ requested_gpu_ids?: number[] | null; /** Active GGUF host-memory placement mode (from /status). */ - gguf_memory_mode?: "auto" | "pinned" | "resident" | null; + gguf_memory_mode?: GgufMemoryMode | null; n_layers?: number | null; /** Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not MoE. */ n_moe_layers?: number; diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 1b41e670d1..653caa0d35 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -17,6 +17,7 @@ import { GPU_LAYERS_AUTO, fetchGgufStagedMetadata, readPersistedSpeculativeType, + type GgufMemoryMode, useChatRuntimeStore, } from "@/features/chat"; import { useGpuDevices } from "@/hooks/use-gpu-info"; @@ -259,8 +260,7 @@ function GpuMemorySettings({ : null; const singleGpuInUse = (selectedGpuIds ?? gpuDevices.map((device) => device.index)).length <= 1; - // Multi-GPU only, and only with physical indices (relative ordinals from a - // CUDA_VISIBLE_DEVICES mask can't be mapped back to pin a device). null = all (auto). + // Multi-GPU only, with one backend-declared index namespace. null = all. const showGpuPicker = gpuDevices.length > 1 && gpuIndexKind !== null && @@ -372,10 +372,9 @@ function GpuMemorySettings({
GPUs - Which GPUs this model may use. Unchecked GPUs are hidden from - llama.cpp (CUDA_VISIBLE_DEVICES, or HIP_VISIBLE_DEVICES on ROCm). - Leave all checked to use every GPU. At least one GPU must stay - selected. + Which GPUs this model may use. Unsloth applies the selection with + the active CUDA, ROCm, or Vulkan backend. Leave all checked to use + every GPU. At least one GPU must stay selected.
@@ -419,7 +418,7 @@ function GpuMemorySettings({ ggufMemoryMode: value === "default" ? undefined - : (value as "auto" | "pinned" | "resident"), + : (value as GgufMemoryMode), }) } > diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts index 1b92857520..51fcfd37eb 100644 --- a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts @@ -9,6 +9,7 @@ import { normalizeModelIdentity, } from "./model-identity"; import type { GpuIndexKind } from "@/hooks/use-gpu-info"; +import type { GgufMemoryMode } from "@/features/chat"; export interface PerModelConfig { customContextLength: number | null; @@ -27,7 +28,7 @@ export interface PerModelConfig { nCpuMoe?: number; selectedGpuIds?: number[] | null; selectedGpuIndexKind?: GpuIndexKind | null; - ggufMemoryMode?: "auto" | "pinned" | "resident"; + ggufMemoryMode?: GgufMemoryMode; } export const DEFAULT_PER_MODEL_CONFIG: PerModelConfig = { @@ -111,7 +112,7 @@ function normalizeGpuFields(partial: RawConfig): { nCpuMoe?: number; selectedGpuIds?: number[] | null; selectedGpuIndexKind?: GpuIndexKind | null; - ggufMemoryMode?: "auto" | "pinned" | "resident"; + ggufMemoryMode?: GgufMemoryMode; } { const out: { gpuMemoryMode?: "auto" | "manual"; @@ -119,7 +120,7 @@ function normalizeGpuFields(partial: RawConfig): { nCpuMoe?: number; selectedGpuIds?: number[] | null; selectedGpuIndexKind?: GpuIndexKind | null; - ggufMemoryMode?: "auto" | "pinned" | "resident"; + ggufMemoryMode?: GgufMemoryMode; } = {}; // Only "manual" is a real override; persisting "auto" would pin the model and // stop it following later changes to the global GPU Memory preference. From fc03e37a939f2b8db59691eb62d9e28792ee02a7 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:53:20 -0300 Subject: [PATCH 033/110] Trim GGUF placement comments --- .../backend/core/inference/_vulkan_probe.py | 20 +-- studio/backend/core/inference/llama_cpp.py | 163 ++++-------------- studio/backend/routes/inference.py | 74 ++------ studio/backend/routes/training_vram.py | 58 +------ .../tests/test_chat_load_during_training.py | 96 +++-------- studio/backend/tests/test_gpu_selection.py | 37 +--- .../tests/test_llama_cpp_memory_mode.py | 124 +++---------- studio/backend/tests/test_mtp_vram_budget.py | 5 +- .../src/features/chat/api/chat-api.ts | 3 +- .../chat/hooks/use-chat-model-runtime.ts | 3 - .../lib/apply-inference-status-to-store.ts | 3 +- .../chat/stores/chat-runtime-store.ts | 9 +- studio/frontend/src/hooks/use-gpu-info.ts | 36 +--- studio/setup.sh | 5 +- 14 files changed, 115 insertions(+), 521 deletions(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index e4513889e0..f33e31f5f9 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -26,13 +26,7 @@ _GGML_BACKEND_DEVICE_TYPE_IGPU = 2 def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: - """Per-device integrated-GPU flags and names via ggml's registry. - - The Vulkan reg enumerates devices in the same order as - ``ggml_backend_vk_get_device_memory`` (each context uses ``ctx->device = - i``), so reg index == device ordinal. Missing metadata degrades to an - all-False flag list and stable ``VulkanN`` names. - """ + """Read iGPU flags and names in Vulkan ordinal order.""" flags = [False] * count names = [f"Vulkan{i}" for i in range(count)] try: @@ -57,8 +51,7 @@ def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: dev = base.ggml_backend_reg_dev_get(reg, i) if dev: flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU - # name is the selector token (usually VulkanN); description is - # the hardware label users need in the picker. + # Prefer the user-facing hardware description. raw_name = base.ggml_backend_dev_description(dev) or base.ggml_backend_dev_name(dev) if raw_name: name = raw_name.decode("utf-8", errors = "replace") @@ -88,14 +81,7 @@ def main() -> int: base_name, vk_name = "libggml-base.so", "libggml-vulkan.so" def _find_lib(directory, stem): - """Find ``stem`` or a versioned ``stem.N`` in *directory*. - - Prefers the unversioned name; falls back to the first versioned match. - Split-lib installs often ship only the versioned runtime soname - (e.g. ``libggml-vulkan.so.0``) without the dev-only unversioned symlink, - so a hard-coded unversioned-only load would miss a real Vulkan backend - that the detector already classified correctly (#7188). - """ + """Find an unversioned library or a versioned runtime soname.""" path = os.path.join(directory, stem) if os.path.isfile(path): return path diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index bb09590599..5a813c0eab 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1762,14 +1762,7 @@ def _extra_args_draft_device(extra_args: Optional[Iterable[str]]) -> Optional[st def _extra_args_draft_device_pin(extra_args: Optional[Iterable[str]]) -> Optional[str]: - """Return a draft-device override that names a real GPU, not cpu/none. - - A separate MTP drafter normally follows the main -ngl / device selection, so - under an explicit gpu_ids pin it lands on the pinned cards the training guard - budgeted. A draft-device naming a different GPU escapes that pin and can place - the drafter on a card the guard never reserved (#7188). cpu/none is a - supported offload (keeps the drafter off the GPU entirely), so it does not - conflict and returns None.""" + """Return a GPU draft-device override; cpu/none do not conflict with a pin.""" last_dev = _extra_args_draft_device(extra_args) if last_dev is None: return None @@ -1966,11 +1959,7 @@ def _llama_lib_dir(binary: str) -> Path: def _lib_dir_has_ggml_backend(lib_dir: Path, backend: str) -> bool: - """True if ``lib_dir`` holds a ggml backend lib for ``backend`` (vulkan/cuda/hip/ - cpu/base), matching the exact soname AND versioned variants (e.g. - ``libggml-vulkan.so.0``) shipped by distro/split-lib llama.cpp builds. Vulkan - detection and the CPU-only-build check share this so they agree on what counts as - a present backend (#7188).""" + """Match an exact or versioned ggml backend library soname.""" stem = f"ggml-{backend}.dll" if sys.platform == "win32" else f"libggml-{backend}.so" try: return any( @@ -2089,12 +2078,9 @@ class LlamaCppBackend: self._gpu_ids: Optional[List[int]] = None # Raw requested GPU pin before automatic fit narrows the active subset. self._requested_gpu_ids: Optional[List[int]] = None - # Raw requested host-memory mode. The canonical placement is derived - # from this so status echo and reload dedupe cannot drift apart. + # Raw mode preserves explicit "auto"; the property provides its canonical form. self._requested_memory_mode: Optional[str] = None - # True when the child launched with no explicit memory_mode but inherited an - # LLAMA_ARG_MLOCK/NO_MMAP/MMAP env; lets an explicit 'auto' reload force the - # scrub the dedup would otherwise skip. + # An explicit mode must reload a child that inherited placement env vars. self._launched_with_inherited_mem_env: bool = False # Layer load kept multi-GPU only to honor a downgraded tensor request, so a # later explicit tensor-off reloads instead of deduping to it (#6659). @@ -2574,21 +2560,17 @@ class LlamaCppBackend: @property def memory_mode(self) -> Optional[str]: - """GGUF memory placement mode of the active load (auto/pinned/resident). - Auto is canonicalised to None for dedup; use requested_memory_mode for the - original user-requested value.""" + """Canonical active GGUF memory mode; auto is None.""" return self._canonical_memory_mode(self._requested_memory_mode) @property def requested_memory_mode(self) -> Optional[str]: - """User's raw requested memory mode for the response echo. Distinguishes an - explicit "auto" from an omitted field.""" + """Raw mode for status responses, preserving explicit auto.""" return self._requested_memory_mode @property def launched_with_inherited_mem_env(self) -> bool: - """True when the live child inherited operator LLAMA_ARG_MLOCK/NO_MMAP/MMAP env - because no memory_mode was applied; an explicit mode reload clears it.""" + """Whether the child inherited llama.cpp memory-placement env vars.""" return self._launched_with_inherited_mem_env @property @@ -3067,9 +3049,6 @@ class LlamaCppBackend: if not binary: return False lib_dir = _llama_lib_dir(binary) - # Match versioned sonames too (libggml-vulkan.so.0), as distro/split-lib installs - # ship the runtime lib without the dev-only unversioned symlink; else a real Vulkan - # build is misread as CUDA and the --device Vulkan pin is never emitted (#7188). if not _lib_dir_has_ggml_backend(lib_dir, "vulkan"): return False for _backend in ("cuda", "hip"): @@ -3381,13 +3360,10 @@ class LlamaCppBackend: @staticmethod def _backend_lacks_gpu_lib(binary: Optional[str] = None) -> bool: - """True only when the llama.cpp build clearly ships CPU-only ggml libs (a - ggml-cpu/base lib present but no cuda/hip/vulkan sibling), so an explicit gpu_ids - pin can't be honored: the child is steered only by CUDA_VISIBLE_DEVICES and a - CPU-only llama-server would ignore it and run on CPU while /load reports a - GPU-pinned success. Conservative -- an unrecognized layout (no lib dir, or no - ggml libs at all, e.g. a statically linked build) returns False so a valid custom - GPU build is never falsely rejected (#7188).""" + """Detect a split-library build proven to be CPU-only. + + Unknown and static layouts return False to avoid rejecting custom GPU builds. + """ binary = binary or LlamaCppBackend._find_llama_server_binary() if not binary: return False @@ -3395,14 +3371,11 @@ class LlamaCppBackend: if not lib_dir or not lib_dir.is_dir(): return False if any(_lib_dir_has_ggml_backend(lib_dir, b) for b in ("vulkan", "cuda", "hip")): - return False # a GPU ggml backend is present -> the pin can be honored - # No GPU lib: CPU-only only if a CPU/base ggml lib proves the split-lib layout (not a static build). + return False return any(_lib_dir_has_ggml_backend(lib_dir, b) for b in ("cpu", "base")) def is_vulkan_build(self) -> bool: - """True if the resolved llama-server binary is a Vulkan build. The route uses this - to defer gpu_ids to the backend (which treats them as Vulkan ordinals) instead of - the CUDA physical-ID resolver even on a CUDA-visible host (#7188).""" + """Whether the resolved llama-server uses Vulkan ordinals.""" try: return self._is_vulkan_backend(self._find_llama_server_binary()) except Exception: @@ -3410,9 +3383,7 @@ class LlamaCppBackend: @staticmethod def _strip_device_extra_args(extra_args): - """Drop a user --device/-dev from extra_args so an explicit gpu_ids pin owns - placement. Used for the launched command AND the persisted extras, so a dropped - --device can't be resurrected on a later inheriting reload (#7188).""" + """Drop --device/-dev so gpu_ids remains authoritative.""" return strip_shadowing_flags( extra_args, strip_context = False, @@ -3568,32 +3539,16 @@ class LlamaCppBackend: def _probe_vulkan_devices( binary: Optional[str] = None, ) -> list[tuple[int, int, bool, int, str]]: - """Return raw Vulkan device rows from a short-lived ggml probe. - - Each row is ``(ordinal, free_bytes, is_igpu, total_bytes, name)``. - The ordinal is ggml's compact ``VulkanN`` index after any inherited - ``GGML_VK_VISIBLE_DEVICES`` filter. - """ + """Return ``(ordinal, free, is_igpu, total, name)`` from a ggml probe.""" binary = binary or LlamaCppBackend._find_llama_server_binary() if not binary: return [] binary_dir = _llama_lib_dir(binary) - # Match versioned sonames too (libggml-vulkan.so.0), mirroring - # _is_vulkan_backend: split-library installs ship only the versioned - # runtime lib, so an unversioned-symlink-only guard would return [] here - # for a real Vulkan build the detector already classified as Vulkan, - # rejecting every explicit gpu_ids request before launch (#7188). if not _lib_dir_has_ggml_backend(binary_dir, "vulkan"): return [] env = child_env_without_native_path_secret() - # Pass any inherited GGML_VK_VISIBLE_DEVICES through to ggml unchanged so - # the probe enumerates the same device list the launch will, named - # Vulkan0..N in the compact order reported here and pinned by that name - # via --device -- probe, mask, and pin stay in one index space. Do NOT - # filter the mask in Python: ggml parses the env var in raw - # vkEnumeratePhysicalDevices space while this probe reports the compact - # post-filter ordinal, so a Python filter would compare mismatched spaces. + # Let ggml apply GGML_VK_VISIBLE_DEVICES; Python sees compact ordinals. if sys.platform != "win32": # Let the loader resolve sibling ggml libs next to the binary. existing_ld = env.get("LD_LIBRARY_PATH", "") @@ -6402,11 +6357,7 @@ class LlamaCppBackend: @staticmethod def _canonical_memory_mode(memory_mode: Optional[str]) -> Optional[str]: - """Normalize a memory_mode value so 'auto'/blank/None are equivalent. - - Returns None for the default (memory-mapped) placement, or the lowercase - canonical name 'pinned' / 'resident' for explicit modes. - """ + """Normalize auto, blank, and None to the default mode.""" mode = (memory_mode or "").strip().lower() if mode in ("", "auto"): return None @@ -6416,15 +6367,7 @@ class LlamaCppBackend: def _memory_mode_flags( memory_mode: Optional[str], *, supports_load_mode: bool = False ) -> List[str]: - """Return the llama-server flags for a GGUF memory placement mode. - - New llama.cpp builds use the unified --load-mode flag. Older builds - retain the deprecated mmap/mlock flags. - - - "pinned" -> load-mode mlock, or legacy --mlock. - - "resident" -> load-mode none, or legacy --no-mmap --mlock. - - otherwise -> [] (llama.cpp default, memory-mapped file). - """ + """Translate a memory mode to unified or legacy llama.cpp flags.""" mode = LlamaCppBackend._canonical_memory_mode(memory_mode) if mode == "pinned": if supports_load_mode: @@ -6432,8 +6375,7 @@ class LlamaCppBackend: return ["--mlock"] if mode == "resident": if supports_load_mode: - # Unified load modes cannot combine non-mmap loading with - # mlock. "none" preserves the non-mmap part of this mode. + # Unified load modes cannot combine a RAM copy with mlock. return ["--load-mode", "none"] return ["--no-mmap", "--mlock"] return [] @@ -6485,11 +6427,7 @@ class LlamaCppBackend: Returns True if the server started and the health check passed. """ - # When a mode is set, Unsloth's --mlock/--no-mmap (or their absence for auto) wins, - # so strip conflicting --mmap/--no-mmap/--mlock from extra_args; else last-wins - # parsing could memory-map the child while Unsloth stored 'resident'. The route - # strips this too; repeating it here covers direct load_model calls (idempotent). - # No mode = no opinion, so user flags are left untouched. + # An explicit mode owns all memory-placement flags, including auto. if memory_mode is not None and extra_args: extra_args = strip_shadowing_flags( extra_args, @@ -6589,14 +6527,7 @@ class LlamaCppBackend: binary = self._find_llama_server_binary() is_vulkan_backend = self._is_vulkan_backend(binary) - # ── Vulkan-ordinal preflight (BEFORE the Phase 1 kill) ──────── - # An explicit Vulkan pin the ggml probe never enumerated cannot be honored. - # Validate it ABOVE the kill so an invalid selection leaves the live model - # untouched: CUDA ids are range-checked at the route, but Vulkan ordinals are - # not, so a stale gpu_ids=[99] used to kill the server then 400, leaving - # nothing running (#7239). _get_gpu_memory needs only the binary (safe pre- - # download) and reuses the later fit's issubset logic. Guarded on a found - # Vulkan build + a pin so a deferred not-found stays deferred for diffusion. + # Reject stale Vulkan ordinals before replacing the live model. if is_vulkan_backend and gpu_ids and binary: _pf_wanted = {int(x) for x in gpu_ids} _pf_probed = {g[0] for g in self._get_gpu_memory(binary)} @@ -6606,12 +6537,7 @@ class LlamaCppBackend: f"present. Available Vulkan devices: {sorted(_pf_probed)}." ) - # A remote uncached GGUF may only reveal that it needs the - # single-device diffusion runner after download. On Vulkan, an - # explicit gpu_ids request cannot be mapped from ggml ordinals to - # that runner's CUDA physical index. Download and classify the main - # file before killing the healthy server so this late rejection is - # non-destructive. The Phase 2 call below reuses this cached path. + # Classify remote Vulkan loads before teardown because diffusion uses CUDA IDs. _preflight_model_path = None if is_vulkan_backend and gpu_ids and hf_repo: _resolved_repo = _resolve_repo_id_casing(hf_repo) @@ -6717,10 +6643,7 @@ class LlamaCppBackend: "GGUF host-memory modes are not supported for " "DiffusionGemma models. Use Auto." ) - # The diffusion runner pins its child by CUDA visibility mask, so a - # ggml Vulkan ordinal cannot be honored. The route rejects models it - # can classify before teardown; this covers a remote uncached GGUF - # whose architecture is first known after download (#7239). + # The diffusion runner cannot translate Vulkan ordinals to CUDA IDs. if is_vulkan_backend and gpu_ids and gpu_ids_are_vulkan_ordinals is not False: raise ValueError( "GPU selection (gpu_ids) is not supported for a DiffusionGemma " @@ -6732,9 +6655,7 @@ class LlamaCppBackend: # Not a tensor/layer GGUF: clear any preserved-fallback flag from a # prior load (this path skips the command builder that clears it). self._layer_preserves_tensor_intent = False - # This path skips the command builder that records host-memory placement, - # so clear any mode carried from a prior non-diffusion load (the diffusion - # runner has no --mlock/--no-mmap plumbing) (#7188). + # The diffusion runner has no host-memory placement modes. self._requested_memory_mode = None self._launched_with_inherited_mem_env = False with self._lock: @@ -7838,12 +7759,7 @@ class LlamaCppBackend: # model can't spill onto an unpicked GPU. if gpu_ids and gpu_indices is None: gpu_indices = sorted(gpu_ids) - # Auto placement must not broaden a Vulkan launch from the GPUs - # considered by the planner to every Vulkan device just because - # the requested context needs --fit CPU offload. Prefer all - # discrete devices; use integrated devices only when no discrete - # Vulkan device exists. Manual mode without a picker deliberately - # leaves device selection to llama.cpp. + # Auto Vulkan fit prefers discrete GPUs and keeps that pool pinned. elif ( is_vulkan_backend and gpu_memory_mode != "manual" @@ -7922,10 +7838,7 @@ class LlamaCppBackend: server_caps = self.probe_server_capabilities(binary) - # Host loading policy only. These flags control file mapping and - # system-RAM paging, not whether a GPU driver keeps offloaded - # tensors in VRAM. Emit them on the command line so they bypass - # _clear_manual_placement_env in manual mode. + # Emit host-memory policy directly so manual env cleanup cannot remove it. cmd.extend( self._memory_mode_flags( memory_mode, @@ -8280,10 +8193,7 @@ class LlamaCppBackend: if extra_args: _emit_extra_args = list(extra_args) if gpu_ids is not None: - # gpu_ids is authoritative: drop a user --device/-dev so it can't - # override the pin and offload to a guard-unaccounted GPU (#7188). - # On Vulkan --device is the only pin; on CUDA/ROCm it keeps - # backend.gpu_ids honest. Other extras pass through. + # gpu_ids owns placement, so remove a competing --device. _emit_extra_args = self._strip_device_extra_args(extra_args) if _emit_extra_args != list(extra_args): logger.info( @@ -8307,14 +8217,7 @@ class LlamaCppBackend: if "--threads" not in cmd: env.pop("LLAMA_ARG_THREADS", None) - # Unsloth's --mlock/--no-mmap (or their absence for auto) must win. llama-server - # honors LLAMA_ARG_MLOCK/NO_MMAP/MMAP only where Unsloth emits no flag, so an - # inherited env could mlock an explicit 'auto' or turn 'pinned' into 'resident'. - # Scrub only when a mode is set, so operator env vars keep the pre-PR - # inheritance otherwise (backwards compatible). Record whether the child - # launched WITH inherited placement flags so a later explicit 'auto' reloads - # to clear them (the dedup treats 'auto' and None alike and would otherwise - # leave it mlocked). Mirrors the threads / split / cache scrubs. + # Explicit modes scrub inherited placement env; omission preserves it. _mm_vars = ( "LLAMA_ARG_LOAD_MODE", "LLAMA_ARG_MLOCK", @@ -8354,10 +8257,7 @@ class LlamaCppBackend: if _ct_raw and _ct_raw not in self._TENSOR_PARALLEL_KV_TYPES: env.pop(_ct_var, None) - # Under an explicit gpu_ids pin, scrub inherited LLAMA_ARG_DEVICE (env form - # of --device): on the CUDA/ROCm path Unsloth emits no --device flag, so it - # would otherwise override the pin and steer offload off the pinned cards - # (or to 'none' -> CPU). Without a pin, inheritance is left intact. + # A gpu_ids pin also owns the inherited --device equivalent. if gpu_ids is not None: env.pop("LLAMA_ARG_DEVICE", None) @@ -9416,10 +9316,7 @@ class LlamaCppBackend: if extra_args is not None: current = list(self._extra_args) if self._extra_args is not None else [] candidate = list(extra_args) - # Under a gpu_ids pin, load_model stored the device-stripped extras - # (the pin wins over a user --device), so strip the request the same - # way before comparing -- else a raced duplicate /load carrying - # --device needlessly reloads (mirrors the route matcher). + # Compare the same device-stripped extras that load_model persisted. if gpu_ids is not None: candidate = self._strip_device_extra_args(candidate) if candidate != current: diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index ef2e0fea5f..ea7289694d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3218,9 +3218,7 @@ def _request_matches_loaded_settings( # stored extras stripped the way the reload strips them, so an extras-driven # tensor load isn't seen as a mismatch that needlessly reloads the server. backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else [] - # Strip inherited --mlock/--no-mmap/--mmap only for a real memory mode. Pydantic marks - # the field "set" even for an explicit null, but null == "no opinion" (placement left - # alone), so stripping then would needlessly reload. Gate on the value (#7188). + # Explicit null means no opinion, so only a real value owns memory flags. _strip_mem = request.gguf_memory_mode is not None effective_extra = ( request.llama_extra_args @@ -3269,14 +3267,11 @@ def _request_matches_loaded_settings( _loaded_gpu_ids = llama_backend.requested_gpu_ids if _req_gpu_ids != _loaded_gpu_ids: return False - # GGUF host-memory placement mode is first-class; any change must reload (#7164). if LlamaCppBackend._canonical_memory_mode( request.gguf_memory_mode ) != llama_backend.memory_mode: return False - # An explicit memory_mode (incl. 'auto') over a child with inherited LLAMA_ARG_* flags - # must reload so the scrub runs; the canonical check above treats 'auto' as omitted and - # would leave the child mlocked/no-mmap (#7164). + # Explicit auto must reload a child that inherited placement env vars. if request.gguf_memory_mode is not None and llama_backend.launched_with_inherited_mem_env: return False # Preserved tensor->layer fallback (both report tensor=off, so the check above @@ -3338,16 +3333,8 @@ def _request_matches_loaded_settings( ): return False else: - # Strip memory-placement flags request-side only for a real mode (mirrors the - # reload's strip of explicit extras), so a request repeating a --mlock/--mmap/--no-mmap - # the loaded server already dropped still hits this fast path instead of the guard. - # The backend side is NOT stripped: a server loaded with no memory_mode keeps a - # pass-through --mlock/--no-mmap and is still mlocked; keeping it forces a reload so - # the auto scrub runs (#7164). Explicit gpu_ids drop a user --device from the launched - # and stored extras, so strip it request-side too or an identical reload would miss - # this fast path and force a needless reload / training 409 (#7188). Gate on an - # EFFECTIVE pin: the load path normalizes gpu_ids=[] to None and keeps its --device, - # so an empty list must NOT strip here or the two sides diverge. + # Compare against the managed flags that load_model actually persisted. + # Keep backend memory flags so explicit auto can detect inherited placement. _strip_dev = bool(request.gpu_ids) _request_extra = ( strip_shadowing_flags( @@ -3948,21 +3935,13 @@ def _estimate_gguf_required_gb( def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]: - """Classify a GGUF as diffusion, normal, or unknown before it is loaded. + """Classify a GGUF as diffusion, normal, or unknown before loading.""" + identity = " ".join( + str(getattr(config, attr, "") or "") for attr in ("identifier", "gguf_hf_repo", "gguf_file") + ).lower() + # Only use the specific DiffusionGemma family name as a header fallback. + name_says_diffusion = "diffusiongemma" in _re.sub(r"[^a-z0-9]+", "", identity) - ``None`` is important here: a remote GGUF whose header is not cached can - still be routed to the single-GPU diffusion runner after download. Treating - that case as normal would let Manual mode skip the training guard even - though the runner ignores Manual's llama-server placement controls. - - The local header is authoritative and checked first: the loader routes on - the decoded architecture (LlamaCppBackend._is_diffusion), never the path, so - a normal llama-server GGUF whose directory/repo name merely contains - "diffusion" must classify as normal (else its memory-placement feature is - rejected on a false name match). The name is only a fallback when no local - header is readable -- an uncached remote GGUF -- where it conservatively - flags a likely-diffusion download (#7188). - """ try: main = getattr(config, "gguf_file", None) if not (main and Path(main).is_file()): @@ -3976,22 +3955,15 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]: probe._read_gguf_metadata(str(main)) if probe.is_diffusion: return True - # A successfully decoded architecture proves that this is a normal - # llama-server GGUF, even if the path/name contains "diffusion". if getattr(probe, "_architecture", None): return False except Exception as e: logger.debug("Could not identify diffusion GGUF for training guard: %s", e) + return True if name_says_diffusion else None def _reject_diffusion_memory_mode(config: ModelConfig, memory_mode: Optional[str]) -> None: - """Raise 400 if a DiffusionGemma GGUF was given an explicit gguf_memory_mode. - - The diffusion runner is single-GPU (DG_GPU) with no --mlock/--no-mmap plumbing. - Called by both /validate and /load before either tears down the active model, - so the endpoints agree before any unload (#7188). - gpu_ids for diffusion is handled by the runner (collapsed to a single device), so - only the host-memory mode is rejected here.""" + """Reject host-memory modes unsupported by the diffusion runner.""" if not config.is_gguf: return if LlamaCppBackend._canonical_memory_mode(memory_mode) is None: @@ -4028,13 +4000,7 @@ def _reject_draft_device_with_gpu_ids( async def _resolve_gguf_gpu_ids_for_request( config: ModelConfig, gpu_ids: Optional[List[int]] ) -> tuple[Optional[List[int]], bool]: - """Resolve and fully validate an explicit GGUF GPU placement pool. - - CUDA and ROCm use physical IDs. Vulkan uses ggml ordinals, so its device - existence check comes from the same ggml probe used by the loader. Both - /load and /validate call this before their training guard or any teardown. - The boolean result records whether the resolved IDs are Vulkan ordinals. - """ + """Validate GGUF GPU IDs and report whether they are Vulkan ordinals.""" if not gpu_ids: return None, False @@ -4272,9 +4238,7 @@ def _resolve_inherited_extra_args( or effective_chat_template_override is not None ), strip_split_mode = _should_strip_split_mode(request, stored_args), - # A real gguf_memory_mode owns --mlock/--mmap/--no-mmap, so strip an - # inherited one; a request that omits the field keeps the pass-through flag - # (opt-in, mirrors the fast-path _strip_mem). null == "no opinion" (#7188). + # A non-null mode owns inherited memory flags. strip_memory_mode = getattr(request, "gguf_memory_mode", None) is not None, # manual + per-GPU ratio emits its own --tensor-split; drop # an inherited one (appended last would override it) while @@ -4602,12 +4566,9 @@ async def _load_model_impl( detail = f"Invalid model identifier: {model_log_label}", ) - # DiffusionGemma rejects an explicit host-memory mode before any teardown - # (shared with /validate); its runner has no --mlock/--no-mmap plumbing. _reject_diffusion_memory_mode(config, request.gguf_memory_mode) - # Apply a same-model request's inherited extras once, before every - # preflight that depends on the effective llama.cpp command. + # Resolve inherited extras once before command-dependent preflights. extra_llama_args = _resolve_inherited_extra_args( request, config, @@ -4620,9 +4581,7 @@ async def _load_model_impl( effective_gpu_ids = request.gpu_ids if request.gpu_ids else None gpu_ids_are_vulkan_ordinals = False - # Validate the full GGUF placement pool before the training guard so an - # invalid physical ID or Vulkan ordinal is a clean 400, not a masked VRAM - # 409. The same helper is used by /validate. + # Invalid GPU IDs must fail before the training coexistence guard. gguf_gpu_ids: Optional[List[int]] = None if config.is_gguf: ( @@ -5212,7 +5171,6 @@ async def validate_model( detail = f"Invalid model identifier: {model_log_label}", ) - # Reject DiffusionGemma host-memory placement here too, matching /load (#7188). _reject_diffusion_memory_mode(config, request.gguf_memory_mode) effective_extra_args = _resolve_inherited_extra_args( diff --git a/studio/backend/routes/training_vram.py b/studio/backend/routes/training_vram.py index d49e75f06c..492d5fe725 100644 --- a/studio/backend/routes/training_vram.py +++ b/studio/backend/routes/training_vram.py @@ -234,15 +234,11 @@ def can_load_chat_during_training( chat model against the free VRAM that remains). Sizes/places it the same way the loader will: HF auto reuses auto_select_gpu_ids; HF explicit requires an even-share per-GPU floor for device_map="balanced"; GGUF sizes from - required_override_gb over the visible pool. A Vulkan GGUF selection picks by ggml - Vulkan ordinal (separate index space from CUDA ids), so its requested_gpu_ids is - NOT resolved against the CUDA set (which would raise -> invalid_gpu_ids -> bypass - the OOM check); conservatively size an N-device request against the least-free - N visible GPUs instead. - ``single_device_gpu`` is the exact physical device token selected by a - single-device runner. `load_in_4bit` must be effective (LoRA can flip 4-bit - -> 16-bit). CPU/MLX allows the load; default-deny on any CUDA/XPU case it - can't size, so a load never OOMs training.""" + required_override_gb over the visible pool. ``single_device_gpu`` is the + exact physical device token selected by a single-device runner. + `load_in_4bit` must be effective (LoRA can flip 4-bit -> 16-bit). CPU/MLX + allows the load; default-deny on any CUDA/XPU case it can't size, so a load + never OOMs training.""" try: from utils.hardware import ( DeviceType, @@ -263,11 +259,6 @@ def can_load_chat_during_training( max_seq_length = max_seq_length or 2048, ) - # A Vulkan GGUF selection uses ggml Vulkan ordinals, not CUDA physical ids; - # size it against the full visible pool (GGUF self-placement) rather than - # resolving ordinals against the CUDA parent-visible set. - vulkan_gguf = is_gguf and is_vulkan - # HF auto: reuse the loader's selector; fits iff its pick clears the margin. if not requested_gpu_ids and not is_gguf: _selected, meta = auto_select_gpu_ids(model_name, **est_kwargs) @@ -293,7 +284,7 @@ def can_load_chat_during_training( } # Explicit GPUs, or GGUF: size directly and check live free VRAM. - if requested_gpu_ids and vulkan_gguf: + if requested_gpu_ids and gpu_ids_are_vulkan_ordinals: mode = "gguf_vulkan" elif single_device_gpu is not None: mode = "single_device" @@ -309,8 +300,7 @@ def can_load_chat_during_training( free_by_index = _free_vram_by_index(get_visible_gpu_utilization().get("devices", [])) if requested_gpu_ids and gpu_ids_are_vulkan_ordinals: - # Vulkan ordinals cannot be mapped to CUDA indices. The least-free - # N-device subset is the safe bound for an unknown mapping. + # Use the safest N-device bound because Vulkan and CUDA IDs do not map. visible_free = sorted(free_by_index.values()) free_vals = visible_free[: min(len(requested_gpu_ids), len(visible_free))] elif single_device_gpu is not None: @@ -333,40 +323,6 @@ def can_load_chat_during_training( free_vals = [min(free_by_index.values())] if free_by_index else [] else: free_vals = [free_by_index.get(selected_gpu, 0.0)] - elif requested_gpu_ids and gpu_ids_are_vulkan_ordinals: - # Vulkan ordinals enumerate independently of the CUDA index space free_by_index - # uses (the loader skips resolve_requested_gpu_ids for them), so the target - # physical card is unknown. Require a fit on the least-free visible GPU so no - # ordinal->physical mapping can approve a load that then OOMs training (#7188). - free_vals = list(free_by_index.values()) - if not free_vals: - return False, {"mode": "gguf_vulkan", "reason": "no_visible_gpus"} - needed_gb = required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB - # A multi-GPU pin shards the model (~needed/N per device), so require each - # visible GPU to hold one shard, not the whole model. With the mapping unknown, - # min_free is the safe bound: if the least-free card holds a shard, any mapping - # does. Also apply the aggregate multi-GPU overhead so a sharded load does not - # start with too little protected headroom and OOM the training run (#7188). - per_gpu_needed_gb = needed_gb / len(requested_gpu_ids) - min_free_gb = min(free_vals) - # The pin lands on some N-of-visible subset (mapping unknown), so budget the - # aggregate over the least-free N cards, not all visible: the N smallest minimize - # usable_gb = max + overhead*rest, so if they fit, any N-subset does. Using all - # visible would over-count headroom the pinned cards may not have (e.g. four 50 GB - # cards make a two-card 100 GB pin look like it fits when no pair holds it). - n_pins = min(len(requested_gpu_ids), len(free_vals)) - ranked = sorted(sorted(free_vals)[:n_pins], reverse = True) - usable_gb = ranked[0] + sum(f * _MULTI_GPU_OVERHEAD for f in ranked[1:]) - aggregate_fits = usable_gb >= needed_gb - per_gpu_fits = min_free_gb >= per_gpu_needed_gb - return per_gpu_fits and aggregate_fits, { - "mode": "gguf_vulkan", - "required_gb": round(required_gb, 3), - "needed_gb": round(needed_gb, 3), - "usable_gb": round(usable_gb, 3), - "per_gpu_needed_gb": round(per_gpu_needed_gb, 3), - "min_free_gb": round(min_free_gb, 3), - } elif requested_gpu_ids: # Invalid ids -> load_model 400s first, so don't block; missing id = 0. try: diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 808e02c562..129023ffcd 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -128,8 +128,7 @@ class TestCanLoadExplicitHF(_GpuCacheResetMixin, unittest.TestCase): auto_mock.assert_not_called() # explicit never calls the auto selector def test_per_gpu_floor_blocks_uneven_split(self): - # free [45, 10]; aggregate 45 + 10*0.85 = 53.5 >= needed 27, but the 10 GB - # GPU is below the even-share floor 27/2 = 13.5 -> refuse (would OOM it). + # Aggregate capacity passes, but the 10 GB shard fails its 13.5 GB floor. ok, info, _ = self._run( required = 20.0, devices = _devices((0, 80, 35), (1, 80, 70)), gpu_ids = [0, 1] ) @@ -205,10 +204,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertTrue(ok) def test_no_per_gpu_floor_for_gguf_with_explicit_gpu_ids(self): - # gpu_ids narrows llama.cpp's candidate pool but does not turn its - # self-placement into HF device_map="balanced". The uneven selected - # pair therefore keeps the aggregate GGUF check without an even-share - # floor on the nearly-full card. + # llama.cpp self-placement uses aggregate capacity within the pinned pool. ok, info, _ = self._run( devices = _devices((0, 80, 35), (1, 80, 70), (2, 80, 0)), required_override = 20.0, @@ -237,9 +233,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(blocked_info["usable_gb"], 10.0) def test_vulkan_pin_takes_precedence_over_unknown_diffusion_fallback(self): - # An uncached GGUF can carry a speculative single-device fallback while - # its explicit pin is actually a ggml Vulkan ordinal. Never interpret - # that ordinal as the same-numbered CUDA physical device. + # Do not reinterpret a Vulkan ordinal as a speculative CUDA device. ok, info, _ = self._run( devices = _devices((0, 80, 0), (1, 80, 78)), required_override = 20.0, @@ -252,9 +246,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["usable_gb"], 2.0) def test_vulkan_multi_gpu_guard_counts_requested_devices(self): - # The ordinal mapping is unknown, so use the least-free two visible - # cards for a two-device request. Their aggregate capacity is still - # available instead of collapsing the request to one card. + # An unknown mapping uses the least-free two-card subset. ok, info, _ = self._run( devices = _devices((0, 80, 70), (1, 80, 70), (2, 80, 0)), required_override = 10.0, @@ -266,10 +258,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["usable_gb"], 18.5) def test_single_device_unresolved_token_sizes_against_worst_device(self): - # A non-numeric device token (a CUDA UUID / MIG handle) can't map to a - # free-VRAM index. The runner still drives ONE device, so size against the - # worst-case visible device (min free), not the aggregate pool: one GPU - # with 80 GB free vs a 20 GB model -> allow. + # Unknown single-device tokens use the least-free visible GPU. ok, info, _ = self._run( devices = _devices((0, 80, 0)), required_override = 20.0, @@ -291,10 +280,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertNotEqual(info.get("reason"), "unresolved_gpu_id") def test_single_device_unresolved_token_uses_min_free_not_aggregate(self): - # The single-device runner uses ONE device but we can't tell which from a - # UUID token. Sizing against the aggregate pool would let a 20 GB model - # "fit" 160 GB of pooled free VRAM while landing on a 2 GB card and OOMing - # training. Min-free (2 GB) is the safe worst case -> refuse. + # Aggregate capacity cannot justify an unresolved single-device load. ok, info, _ = self._run( devices = _devices((0, 80, 78), (1, 80, 0), (2, 80, 0)), required_override = 20.0, @@ -304,9 +290,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["mode"], "single_device") def test_single_device_cpu_token_allows(self): - # An empty device token = a CPU-only single-device runner (CPU diffusion - # GGUF): it uses no GPU VRAM, so it never threatens training -> allow - # regardless of how full the GPUs are. + # An empty device token is CPU-only and consumes no VRAM. ok, info, _ = self._run( devices = _devices((0, 80, 78)), required_override = 20.0, @@ -321,14 +305,10 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertFalse(ok) self.assertEqual(info["reason"], "estimate_unavailable") - # ── Vulkan-ordinal least-free / per-shard budgeting (#7188) ────────────── - # gpu_ids on a Vulkan build are ordinals that don't map to the CUDA index - # space free VRAM is reported in, so the guard budgets the least-free visible - # GPU per shard instead of resolving ids to physical cards. + # ── Vulkan ordinal budgeting ───────────────────────────────────────────── def test_vulkan_build_budgets_least_free_gpu(self): - # Vulkan ordinals can't map to the CUDA index space free VRAM is reported in. - # free [70, 60], needed 9.75: fits even the least-free card, so allow. + # The model fits even on the least-free card. ok, info, _ = self._run( devices = _devices((0, 80, 10), (1, 80, 20)), required_override = 5.0, @@ -339,8 +319,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["mode"], "gguf_vulkan") def test_vulkan_build_rejected_when_worst_card_too_full(self): - # free [70, 3], needed 15.5. A Vulkan pin could land on the near-full card and - # OOM training, so the least-free check rejects even though the aggregate fits. + # The unknown mapping could select the near-full card. ok, info, _ = self._run( devices = _devices((0, 80, 10), (1, 80, 77)), required_override = 10.0, @@ -351,8 +330,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["mode"], "gguf_vulkan") def test_cuda_indexed_build_allows_same_layout(self): - # Same VRAM layout, CUDA-indexed build: resolves ids and budgets the aggregate - # (usable 72.55 >= 15.5) -> allow. Confirms the Vulkan branch tightens the case. + # CUDA IDs resolve physically, so the same aggregate can pass. ok, info, _ = self._run( devices = _devices((0, 80, 10), (1, 80, 77)), required_override = 10.0, @@ -374,9 +352,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["reason"], "no_visible_gpus") def test_vulkan_multi_gpu_shards_budget_per_device(self): - # free [70, 4], needed 6.3. A single-GPU Vulkan pin needs the whole 6.3 on the - # least-free card -> reject; a 2-GPU pin shards (~3.15/card) and fits the least- - # free card -> allow. Confirms the guard budgets per-shard, not whole-model (#7188). + # Two Vulkan devices split a model that cannot fit the least-free card alone. ok_single, info_single, _ = self._run( devices = _devices((0, 80, 10), (1, 80, 76)), required_override = 2.0, @@ -395,11 +371,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info_multi["per_gpu_needed_gb"], round(6.3 / 2, 3)) def test_vulkan_aggregate_budgets_requested_subset_not_all_visible(self): - # More visible GPUs than pinned ordinals: a 2-ordinal Vulkan pin lands on some - # 2-of-4 cards (mapping unknown), so the aggregate must budget the least-free 2, - # not all four. free [50,50,50,50], needed 96 (80*1.15+4): any 2-card pool is - # 50 + 50*0.85 = 92.5 < 96, so reject -- even though summing all four (177.5) - # would pass. Guards against approving a pin that then OOMs training (#7188). + # Budget only the requested two-card subset, not all visible GPUs. ok, info, _ = self._run( devices = _devices((0, 80, 30), (1, 80, 30), (2, 80, 30), (3, 80, 30)), required_override = 80.0, @@ -630,10 +602,7 @@ class TestChatLoadGuardRoute(unittest.TestCase): self.assertFalse(self.route._classify_diffusion_gguf(config)) def test_local_normal_gguf_in_diffusion_named_path_is_normal(self): - # A normal llama-server GGUF whose local path/repo merely contains "diffusion" - # must classify as normal: the loader routes on the decoded header, not the name, - # so the readable header wins over the name and the memory-placement feature is not - # rejected on a false name match. Header-before-name fallback (#7188). + # A readable GGUF header overrides a diffusion-like path. import tempfile class _Probe: is_diffusion = False @@ -694,10 +663,7 @@ class TestChatLoadGuardRoute(unittest.TestCase): self.assertEqual(captured[0]["requested_gpu_ids"], [3, 1]) def test_unclassified_gguf_on_vulkan_build_budgets_as_ordinals(self): - # An uncached (unknown) GGUF pinned with explicit gpu_ids on a Vulkan build must be - # budgeted as Vulkan ordinals, NOT via the CUDA single-device path: the launch treats - # the ids as ordinals, so single-device CUDA sizing could pick the wrong physical card - # and OOM training. The Vulkan flag must fire and suppress diffusion_gpu (#7188). + # Unknown GGUFs still use the Vulkan ordinal namespace selected by the build. captured = [] config = SimpleNamespace(is_gguf = True) with ( @@ -908,10 +874,7 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): self.assertEqual(captured.get("gpu_memory_mode"), "manual") def test_validate_forwards_inherited_extras_and_parallel_to_guard(self): - # Regression: /load resolves inherited same-model extras and passes the - # real slot count to the guard; validate must do the same, else it sizes - # a smaller estimate (no inherited -c/--model-draft, n_parallel=1) and - # /load then 409s after the frontend has already unloaded. + # Validate and load must size the same inherited command. from models.inference import ValidateModelRequest request = ValidateModelRequest(model_path = "unsloth/Qwen3-1.7B", max_seq_length = 4096) @@ -945,9 +908,7 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): self.assertIn("n_parallel", captured) def test_metadata_probe_skips_training_guard(self): - # A header-only probe (include_context_length) allocates no VRAM, so the - # training guard must not run -- else the staging GPU-layers / MoE sliders - # it feeds are hidden exactly when a during-training user needs them. + # Header-only probes allocate no VRAM. from models.inference import ValidateModelRequest request = ValidateModelRequest( @@ -983,9 +944,7 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): self.assertEqual(guard_called, []) def test_gguf_validate_rejects_inherited_draft_device_before_unload(self): - # Validate-then-unload flow: a same-model reload inherits the running - # server's stored --spec-draft-device, which would escape a new gpu_ids - # pin. /validate must 400 BEFORE the client unloads, mirroring /load. + # Validate inherited draft placement before the client unloads. from models.inference import ValidateModelRequest request = ValidateModelRequest(model_path = "x.gguf", gpu_ids = [0]) @@ -998,7 +957,6 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): path = None, base_model = None, ) - # Same model already loaded, carrying a draft-device pin in its extras. loaded = SimpleNamespace( is_loaded = True, model_identifier = "x.gguf", @@ -1023,7 +981,6 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): asyncio.run(self.route.validate_model(request, current_subject = "u")) self.assertEqual(ctx.exception.status_code, 400) self.assertIn("draft-model device", ctx.exception.detail) - # Rejected before the coexistence guard (and thus before any unload). self.assertEqual(captured, []) @@ -1217,10 +1174,7 @@ class TestLoadModelGuardIntegration(unittest.TestCase): llama.unload_model.assert_not_called() def test_gguf_draft_device_gpu_rejected_under_gpu_ids_before_unload(self): - # A draft-device override naming a real GPU (--spec-draft-device CUDA1) would place - # the drafter outside the gpu_ids pin, so /load must 400 before the unload frees the - # model, not after teardown (#7188). cpu/none offload is covered by the - # _extra_args_draft_device_pin unit test. + # Reject escaping draft placement before teardown. import contextlib from unittest.mock import MagicMock from models.inference import LoadRequest @@ -1264,13 +1218,11 @@ class TestLoadModelGuardIntegration(unittest.TestCase): return_value = ("x.gguf", "x.gguf", False), ), patch.object(self.route, "resolve_effective_chat_template_override", return_value = None), - # The merged impl renamed the diffusion guard to _reject_diffusion_memory_mode. patch.object(self.route, "_reject_diffusion_memory_mode", return_value = None), patch.object(self.route, "get_inference_backend", return_value = inf), patch.object(self.route, "get_llama_cpp_backend", return_value = llama), patch.object(self.route, "_hf_offline_if_dns_dead", lambda: contextlib.nullcontext()), patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), - # Guard would PASS, so a 400 is attributable to the draft-device check. _stub_guard_deps(training_active = True, decision = (True, {}), captured = captured), ): with self.assertRaises(HTTPException) as exc: @@ -1280,16 +1232,13 @@ class TestLoadModelGuardIntegration(unittest.TestCase): self.assertEqual(exc.exception.status_code, 400) self.assertIn("draft-model device", exc.exception.detail) - # Rejected before the guard and the unload step, so nothing is torn down. self.assertEqual(captured, []) inf.unload_model.assert_not_called() inf._shutdown_subprocess.assert_not_called() llama.unload_model.assert_not_called() def test_gguf_inherited_draft_device_rejected_under_gpu_ids(self): - # A same-model reload omitting llama_extra_args inherits the stored extras, and a - # stored --spec-draft-device survives. Adding gpu_ids must still 400: the check runs - # against the effective (inherited) extras, not just the raw request (#7188). + # Check effective inherited extras, not only the raw request. import contextlib from unittest.mock import MagicMock from models.inference import LoadRequest @@ -1297,7 +1246,6 @@ class TestLoadModelGuardIntegration(unittest.TestCase): inf = SimpleNamespace(active_model_name = None) inf.unload_model = MagicMock() inf._shutdown_subprocess = MagicMock() - # Same model already loaded, with a draft-device pin in its stored extras. llama = SimpleNamespace( is_loaded = True, model_identifier = "x.gguf", @@ -1316,7 +1264,6 @@ class TestLoadModelGuardIntegration(unittest.TestCase): identifier = "x.gguf", display_name = "x", ) - # Reload the same checkpoint, omitting extras (inherit) but adding a pin. request = LoadRequest(model_path = "x.gguf", gpu_ids = [0], max_seq_length = 4096) captured = [] with ( @@ -1333,8 +1280,6 @@ class TestLoadModelGuardIntegration(unittest.TestCase): ), patch.object(self.route, "resolve_effective_chat_template_override", return_value = None), patch.object(self.route, "_reject_diffusion_memory_mode", return_value = None), - # Different gpu_ids -> not a settings match, so the already_loaded early - # return is skipped and the reload path (with my check) runs. patch.object(self.route, "_request_matches_loaded_settings", return_value = False), patch.object(self.route, "get_inference_backend", return_value = inf), patch.object(self.route, "get_llama_cpp_backend", return_value = llama), @@ -1349,7 +1294,6 @@ class TestLoadModelGuardIntegration(unittest.TestCase): self.assertEqual(exc.exception.status_code, 400) self.assertIn("draft-model device", exc.exception.detail) - # Rejected before the guard and the unload step. self.assertEqual(captured, []) inf.unload_model.assert_not_called() llama.unload_model.assert_not_called() diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index a4c732b974..2c437ca170 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -875,10 +875,7 @@ class TestRouteErrors(unittest.TestCase): self.assertIn("only supported on CUDA and Intel XPU", str(exc_info.exception)) def test_inference_route_resolves_gguf_gpu_ids(self): - # GGUF gpu_ids are now supported: /load routes them through the same - # resolution as non-GGUF loads (rejecting only genuinely invalid ids with - # the resolver's actionable message) rather than a blanket "not supported" - # reject, so /validate can stay consistent with /load (#7239). + # GGUF IDs use the normal resolver instead of a blanket rejection. import utils.hardware.hardware as hardware_mod inference_route = _load_route_module( @@ -910,8 +907,6 @@ class TestRouteErrors(unittest.TestCase): "ModelConfig", SimpleNamespace(from_identifier = lambda **_kwargs: model_config), ), - # Patch both the package re-export and the defining module so the stub - # fires no matter which import path the route uses. patch("utils.hardware.resolve_requested_gpu_ids", _fake_resolve), patch.object(hardware_mod, "resolve_requested_gpu_ids", _fake_resolve), patch.object( @@ -935,7 +930,6 @@ class TestRouteErrors(unittest.TestCase): ) ) - # The selection was routed through resolution (not the old blanket reject). self.assertEqual(exc_info.exception.status_code, 400) self.assertIn("SENTINEL", exc_info.exception.detail) self.assertNotIn("not supported for GGUF", exc_info.exception.detail) @@ -1040,10 +1034,6 @@ class TestRouteErrors(unittest.TestCase): self.assertEqual(resolved, [0, 1]) def test_inference_route_validates_gpu_ids_for_gguf(self): - # gpu_ids is now SUPPORTED for GGUF (the GPU picker), but still - # validated: a rejected pick surfaces as a clean 400, not the old - # "not supported for GGUF" rejection. Patch the validator so the test - # is deterministic regardless of the host's (or a prior test's) GPU env. import utils.hardware.hardware as hardware_mod import utils.hardware as hardware_pkg @@ -1073,8 +1063,6 @@ class TestRouteErrors(unittest.TestCase): "ModelConfig", SimpleNamespace(from_identifier = lambda **_kwargs: model_config), ), - # Patch both the package re-export and the defining module so the stub - # fires no matter which import path the route uses. patch( "utils.hardware.resolve_requested_gpu_ids", side_effect = ValueError("Invalid gpu_ids [0, 1]: rejected by test"), @@ -1115,15 +1103,11 @@ class TestRouteErrors(unittest.TestCase): ) ) - # The validator's ValueError (or backend capability rejection) becomes a clean 400. self.assertEqual(exc_info.exception.status_code, 400) self.assertIn("gpu_ids", exc_info.exception.detail.lower()) def test_inference_route_rejects_gpu_ids_on_cpu_only_llama_build(self): - # A CUDA-visible host with a CPU-only llama.cpp build: the CUDA resolver validates - # the mask but can't see that the build ignores CUDA_VISIBLE_DEVICES, so the pin - # would silently run on CPU while /load reports it active. The route must reject - # before teardown (mirrors the non-CUDA resolvable check) (#7188). + # A CPU-only llama.cpp build cannot honor a CUDA visibility pin. import utils.hardware as hardware_pkg inference_route = _load_route_module( @@ -1177,10 +1161,7 @@ class TestRouteErrors(unittest.TestCase): self.assertIn("cpu-only build", exc_info.exception.detail.lower()) def test_cpu_only_llama_build_skips_reject_for_diffusion_gguf(self): - # A diffusion GGUF is served by the separate visual-server runner (DG_GPU/--gpu), - # not llama-server, so a CPU-only llama.cpp build must NOT reject its gpu_ids. The - # flow skips the cpu-only reject and reaches the id resolver just after it, where a - # sentinel stops the load deterministically (#7188). + # Diffusion uses its own runner, independent of llama.cpp GPU libraries. import utils.hardware as hardware_pkg inference_route = _load_route_module( @@ -1236,15 +1217,11 @@ class TestRouteErrors(unittest.TestCase): current_subject = "test-user", ) ) - # Reached the id resolver past the skipped cpu-only reject, not the reject itself. self.assertNotIn("cpu-only build", exc_info.exception.detail.lower()) self.assertIn("SENTINEL_AFTER_GATE", exc_info.exception.detail) def test_diffusion_gguf_on_vulkan_build_uses_cuda_path(self): - # A confirmed diffusion GGUF on a CUDA host whose llama-server is a Vulkan build must - # validate gpu_ids through the CUDA physical-id resolver (the diffusion runner takes a - # CUDA --gpu/DG_GPU id), NOT the Vulkan-ordinal branch, which would size the wrong - # device namespace and reject a valid physical pick (#7188). + # The diffusion runner uses CUDA physical IDs even beside Vulkan llama.cpp. import utils.hardware as hardware_pkg inference_route = _load_route_module( @@ -1300,13 +1277,10 @@ class TestRouteErrors(unittest.TestCase): current_subject = "test-user", ) ) - # Took the CUDA physical-id resolver rather than the Vulkan ordinal path. self.assertIn("CUDA_PATH_SENTINEL", exc_info.exception.detail) def test_inherited_extras_preserve_memory_flags_when_mode_omitted(self): - # A same-model Apply that omits gguf_memory_mode must NOT drop an inherited - # --mlock/--no-mmap pass-through (opt-in memory stripping), while a first-class - # field the caller set (max_seq_length) still strips its shadow flag (#7188). + # Omission preserves memory flags while explicit context still owns -c. inference_route = _load_route_module( "inference_route_module_for_inherit_memflags_keep_test", "routes/inference.py", @@ -1325,7 +1299,6 @@ class TestRouteErrors(unittest.TestCase): self.assertNotIn("-c", out) # first-class max_seq_length still strips the inherited -c def test_inherited_extras_strip_memory_flags_when_mode_requested(self): - # A real gguf_memory_mode owns the memory flags, so an inherited one is stripped (#7188). inference_route = _load_route_module( "inference_route_module_for_inherit_memflags_strip_test", "routes/inference.py", diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index ac8ca4986b..d6834afbc6 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -21,9 +21,7 @@ if _BACKEND_DIR not in sys.path: def _install_stub_if_absent(name: str, build): - """Install a stub for ``name`` only when the real module isn't importable, so a - stub never shadows a real module and breaks unrelated tests in a combined pytest - run. The stub stays a pure fallback for minimal environments.""" + """Install a fallback stub without shadowing an importable module.""" if name in sys.modules: return try: @@ -263,8 +261,7 @@ def _fit_fallback_backend( def test_empty_probe_preserves_explicit_gpu_ids(tmp_path): - """A CUDA/ROCm build with an empty probe must still honor a route-validated explicit - gpu_ids, pinning via CUDA_VISIBLE_DEVICES + --fit on instead of raising (#7164).""" + """Keep a route-validated CUDA/ROCm pin when telemetry is unavailable.""" from utils.hardware import DeviceType backend, gguf = _fit_fallback_backend(tmp_path, gpu_memory = []) # empty probe @@ -289,7 +286,6 @@ def test_empty_probe_preserves_explicit_gpu_ids(tmp_path): with ( patch.object(subprocess, "Popen", side_effect = _make_fake_popen), - # CUDA host with telemetry down: the mask fallback only applies on CUDA/ROCm. patch("utils.hardware.get_device", return_value = DeviceType.CUDA), patch("utils.hardware.get_parent_visible_gpu_ids", return_value = [0, 1]), ): @@ -305,9 +301,7 @@ def test_empty_probe_preserves_explicit_gpu_ids(tmp_path): def test_torchless_vulkan_populated_probe_uses_identity_ordinals(tmp_path): - """A torch-less Vulkan host has an empty parent-visible mask, so gpu_ids ARE the - Vulkan ordinals: with a populated probe the selection loads (pinned via --device - Vulkan) instead of raising (#7188).""" + """Use Vulkan ordinals directly when torch has no visible GPUs.""" backend, gguf = _fit_fallback_backend( tmp_path, gpu_memory = [(0, 10000, 16000), (1, 8000, 16000)], vulkan = True ) @@ -346,15 +340,9 @@ def test_torchless_vulkan_populated_probe_uses_identity_ordinals(tmp_path): def test_vulkan_fit_keeps_discrete_device_selected(tmp_path): - """Crossing into --fit must not make an integrated GPU eligible again. - - A mixed iGPU/discrete host can fit a small context on the discrete card but - require CPU offload at a larger context. The latter still needs an explicit - Vulkan device pin even when the user did not use the GPU picker. - """ + """Keep the discrete Vulkan device pinned when fit adds CPU offload.""" backend, gguf = _fit_fallback_backend( tmp_path, - # total=0 is the Vulkan probe's integrated-GPU marker. gpu_memory = [(0, 30000, 0), (1, 14000, 16000)], vulkan = True, ) @@ -389,9 +377,7 @@ def test_vulkan_fit_keeps_discrete_device_selected(tmp_path): def test_vulkan_gpu_ids_strips_conflicting_user_device(tmp_path): - """On Vulkan --device is the only pin. With explicit gpu_ids, a user --device in - extras must be stripped so it can't override Unsloth's pin (#7188). Unsloth's --device - survives; unrelated extras pass through.""" + """Keep only Unsloth's Vulkan device pin while preserving unrelated extras.""" backend, gguf = _fit_fallback_backend( tmp_path, gpu_memory = [(0, 10000, 16000), (1, 8000, 16000)], vulkan = True ) @@ -426,12 +412,10 @@ def test_vulkan_gpu_ids_strips_conflicting_user_device(tmp_path): ) cmd = captured["cmd"] - # Unsloth pinned Vulkan0 (gpu_indices=[0]); the user's Vulkan1 override is gone. assert "Vulkan1" not in cmd device_idxs = [i for i, tok in enumerate(cmd) if tok == "--device"] assert len(device_idxs) == 1 assert cmd[device_idxs[0] + 1] == "Vulkan0" - # Unrelated user extras still pass through. assert "--top-k" in cmd and cmd[cmd.index("--top-k") + 1] == "5" @@ -454,7 +438,6 @@ def test_gpu_ids_preserved_on_fit_fallback(tmp_path): backend._apu_ram_shortfall_message = lambda *a, **k: None backend._amd_apu_wants_unified_memory = lambda *a, **k: False backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" - # Force a --fit on fallback. backend._select_gpus = lambda *a, **k: (None, True) backend._wait_for_health = lambda timeout: True backend._detect_audio_type_strict = lambda: None @@ -490,11 +473,7 @@ def test_gpu_ids_preserved_on_fit_fallback(tmp_path): @pytest.mark.parametrize("gpu_ids,scrubbed", [([1, 2], True), (None, False)]) def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_ids, scrubbed): - """An explicit gpu_ids pin owns device placement, so an inherited LLAMA_ARG_DEVICE (the - env form of llama.cpp --device) is scrubbed from the child env -- otherwise it would - steer offload off the pinned cards (or to 'none' -> CPU) while /load reports a GPU-pinned - success. Without gpu_ids the operator's LLAMA_ARG_DEVICE inheritance is left intact, - mirroring the memory/split/tensor env scrubs (backwards compatible) (#7188).""" + """Scrub inherited LLAMA_ARG_DEVICE only when gpu_ids owns placement.""" monkeypatch.setenv("LLAMA_ARG_DEVICE", "CUDA3") gguf = tmp_path / "model.gguf" @@ -556,10 +535,7 @@ def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_id ], ) def test_memory_mode_strips_conflicting_extra_args(tmp_path, mode, user_flag, winning): - """When a placement mode is applied, a conflicting --mmap/--no-mmap/--mlock left - in extra_args must be stripped so llama.cpp's last-wins parsing can't run a - placement that disagrees with the stored memory_mode (#7164). auto emits no - memory flag, so the user's --mlock is dropped rather than pinning the child.""" + """Strip user memory flags when a first-class mode owns placement.""" gguf = tmp_path / "model.gguf" _write_minimal_gguf(gguf) backend = _mem_env_backend(gguf) @@ -591,10 +567,7 @@ def test_memory_mode_strips_conflicting_extra_args(tmp_path, mode, user_flag, wi assert captured_cmds, "llama-server was not spawned" cmd = captured_cmds[-1] - # The caller's conflicting flag is gone. assert user_flag not in cmd - # Only Unsloth's own memory flags (if any) remain, and the last mmap/no-mmap - # flag reflects Unsloth's mode, not the stripped user flag. mmap_flags = [a for a in cmd if a in ("--mmap", "--no-mmap")] if winning is None: assert "--mmap" not in cmd # user --mmap/--no-mmap fully stripped @@ -603,17 +576,12 @@ def test_memory_mode_strips_conflicting_extra_args(tmp_path, mode, user_flag, wi def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): - """gpu_ids are Vulkan device ordinals matched directly against the Vulkan probe, - NEVER remapped through the CUDA/HIP parent mask. Vulkan enumerates independently of - CUDA_VISIBLE_DEVICES (its order can even reverse the CUDA order), so a remap would - pin the wrong device. A CUDA mask of [2, 3] must not shift the requested ordinal - (#7188).""" + """Do not remap Vulkan ordinals through the CUDA/HIP visibility mask.""" gguf = tmp_path / "model.gguf" _write_minimal_gguf(gguf) backend = LlamaCppBackend() backend._is_vulkan_backend = lambda _binary = None: True - # Vulkan reports two devices at ordinals 0 and 1. backend._get_gpu_memory = lambda _binary = None: [(0, 10000, 16000), (1, 9000, 16000)] backend._get_gpu_free_memory = lambda _binary = None: [(0, 10000), (1, 9000)] backend._read_gguf_metadata = lambda _p: None @@ -624,7 +592,6 @@ def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): backend._apu_ram_shortfall_message = lambda *a, **k: None backend._amd_apu_wants_unified_memory = lambda *a, **k: False backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" - # Select the single candidate that survives the gpu_ids filter (its Vulkan ordinal). backend._select_gpus = lambda requested_total, gpus, **k: ([gpus[0][0]], False) backend._wait_for_health = lambda timeout: True backend._detect_audio_type_strict = lambda: None @@ -653,7 +620,6 @@ def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): "Popen", side_effect = _make_fake_popen, ), - # A CUDA/HIP mask of [2, 3] must NOT remap the requested Vulkan ordinal. patch( "utils.hardware.get_parent_visible_gpu_ids", return_value = [2, 3], @@ -668,15 +634,11 @@ def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): assert captured_cmds, "llama-server was not spawned" cmd = captured_cmds[-1] assert "--device" in cmd - # gpu_ids=[1] pins Vulkan ordinal 1 directly, not remapped to Vulkan0 via the mask. assert cmd[cmd.index("--device") + 1] == "Vulkan1" def test_backend_lacks_gpu_lib_detection(tmp_path): - """_backend_lacks_gpu_lib is True ONLY for a clear CPU-only split-lib layout (a - ggml-cpu/base lib with no gpu sibling); a gpu lib, or an unrecognized/static layout - with no ggml libs, returns False so a valid custom GPU build is never falsely rejected - (#7188).""" + """Only classify a proven CPU-only split-library layout.""" ext = "dll" if sys.platform == "win32" else "so" pre = "" if sys.platform == "win32" else "lib" @@ -690,21 +652,16 @@ def test_backend_lacks_gpu_lib_detection(tmp_path): binary = str(tmp_path / "llama-server") (tmp_path / "llama-server").write_bytes(b"x") - # CPU-only split layout -> True. with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _lib_dir_with("cpu")): assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is True - # Any GPU ggml lib present -> False (pin can be honored). for gpu in ("cuda", "hip", "vulkan"): with patch( "core.inference.llama_cpp._llama_lib_dir", return_value = _lib_dir_with("cpu", gpu) ): assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is False - # No ggml libs at all (static / unrecognized) -> False (never falsely reject). with patch("core.inference.llama_cpp._llama_lib_dir", return_value = _lib_dir_with()): assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is False - # Versioned sonames (e.g. libggml-cuda.so.0) are matched too: a versioned GPU lib - # next to an unversioned CPU lib is a real GPU build -> False (#7188). for gpu in ("cuda", "hip", "vulkan"): d = tmp_path / f"libsv_cpu_{gpu}" d.mkdir() @@ -712,7 +669,6 @@ def test_backend_lacks_gpu_lib_detection(tmp_path): (d / f"{pre}ggml-{gpu}.{ext}.0").write_bytes(b"x") with patch("core.inference.llama_cpp._llama_lib_dir", return_value = d): assert LlamaCppBackend._backend_lacks_gpu_lib(binary) is False - # A versioned CPU-only lib is still recognized as CPU-only -> True. d = tmp_path / "libsv_cpu_only" d.mkdir() (d / f"{pre}ggml-cpu.{ext}.0").write_bytes(b"x") @@ -721,10 +677,7 @@ def test_backend_lacks_gpu_lib_detection(tmp_path): def test_is_vulkan_backend_matches_versioned_soname(tmp_path): - """_is_vulkan_backend matches versioned Vulkan sonames (libggml-vulkan.so.0) too, so a - distro/split-lib Vulkan install without the dev-only unversioned symlink is still detected - as Vulkan; otherwise the route treats Vulkan ordinals as CUDA ids and never emits the - --device Vulkan pin (#7188). Shares the matcher with _backend_lacks_gpu_lib.""" + """Detect versioned Vulkan sonames and reject mixed-backend layouts.""" ext = "dll" if sys.platform == "win32" else "so" pre = "" if sys.platform == "win32" else "lib" binary = str(tmp_path / "llama-server") @@ -739,24 +692,20 @@ def test_is_vulkan_backend_matches_versioned_soname(tmp_path): (d / f).write_bytes(b"x") return d - # Versioned-only Vulkan lib (no unversioned symlink) -> detected as Vulkan. with patch( "core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}.0") ): assert LlamaCppBackend._is_vulkan_backend(binary) is True - # Unversioned Vulkan lib -> still detected (regression). with patch( "core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}") ): assert LlamaCppBackend._is_vulkan_backend(binary) is True - # A CUDA/HIP sibling (versioned or not) means a multi-backend build -> defer to that backend. for sib in (f"{pre}ggml-cuda.{ext}", f"{pre}ggml-hip.{ext}.0"): with patch( "core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-vulkan.{ext}.0", sib), ): assert LlamaCppBackend._is_vulkan_backend(binary) is False - # No Vulkan lib at all -> not a Vulkan build. with patch( "core.inference.llama_cpp._llama_lib_dir", return_value = _dir(f"{pre}ggml-cpu.{ext}") ): @@ -764,9 +713,7 @@ def test_is_vulkan_backend_matches_versioned_soname(tmp_path): def test_explicit_gpu_ids_strips_stored_device_extra_args(tmp_path): - """With explicit gpu_ids a user --device is dropped from BOTH the command and the - PERSISTED extras, so a later same-model reload that inherits these (after gpu_ids is - cleared) can't resurrect the dropped --device and override auto placement (#7188).""" + """Do not persist a user --device overridden by gpu_ids.""" backend, gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)], vulkan = True) backend._select_gpus = lambda requested_total, gpus, **k: ([gpus[0][0]], False) @@ -810,9 +757,7 @@ def test_memory_mode_auto_matches_none_in_target_state(): def test_explicit_auto_reloads_when_child_inherited_mem_env(): - """When the live child was launched with no mode but inherited operator - LLAMA_ARG_* placement flags, an explicit 'auto' request must reload so the - scrub runs -- otherwise it dedups to already-loaded and stays mlocked (#7164).""" + """Explicit auto reloads a child with inherited placement env vars.""" backend = _loaded_backend(_launched_with_inherited_mem_env = True) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "auto" @@ -820,9 +765,7 @@ def test_explicit_auto_reloads_when_child_inherited_mem_env(): def test_omitted_mode_does_not_reload_child_with_inherited_mem_env(): - """A request that also omits the mode (memory_mode=None) does NOT reload the - inherited-env child: omitting keeps the operator env, so there's nothing to - scrub and no spurious reload (which would be rejected during training).""" + """Omission preserves inherited placement without reloading.""" backend = _loaded_backend(_launched_with_inherited_mem_env = True) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = None @@ -830,8 +773,7 @@ def test_omitted_mode_does_not_reload_child_with_inherited_mem_env(): def test_explicit_auto_matches_scrubbed_child(): - """Once the child was launched clean (no inherited env), an explicit 'auto' - re-Apply dedups to already-loaded -- no needless reload.""" + """Explicit auto deduplicates after inherited env has been scrubbed.""" backend = _loaded_backend(_launched_with_inherited_mem_env = False) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "auto" @@ -846,8 +788,7 @@ def test_memory_mode_pinned_does_not_match_none(): def test_load_response_and_status_round_trip_placement_fields(): - """gpu_ids and gguf_memory_mode are accepted by the response schemas so - status-hydrated requests can preserve explicit placement settings.""" + """Placement fields round-trip through load and status schemas.""" from models.inference import InferenceStatusResponse, LoadResponse load_resp = LoadResponse( @@ -883,10 +824,7 @@ def test_load_response_and_status_round_trip_placement_fields(): def test_requested_memory_mode_preserves_explicit_auto( tmp_path, mode, expected_requested, expected_canonical ): - """The response echoes requested_memory_mode, not the canonical placement. An explicit - "auto" must survive as "auto" (not collapse to null) so the UI can restore it and a - later reload re-runs the inherited-env scrub instead of letting LLAMA_ARG_MLOCK creep - back; the canonical memory_mode property still maps "auto" to None (#7188).""" + """Preserve explicit auto for status while canonicalizing it to None.""" gguf = tmp_path / "model.gguf" _write_minimal_gguf(gguf) backend = _mem_env_backend(gguf) @@ -923,10 +861,7 @@ def _mem_env_backend(gguf): [("auto", True), ("pinned", True), ("resident", True), (None, False)], ) def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scrubbed): - """An explicit memory_mode strips inherited LLAMA_ARG_MLOCK/NO_MMAP/MMAP so - llama-server can't run a placement Unsloth didn't select (#7164). memory_mode=None - leaves operator env untouched (backwards compatible); the reload-dedup handles a - later explicit 'auto' (see the target-state tests below).""" + """Only an explicit memory mode scrubs inherited placement env vars.""" monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") monkeypatch.setenv("LLAMA_ARG_NO_MMAP", "1") monkeypatch.setenv("LLAMA_ARG_MMAP", "true") @@ -973,17 +908,11 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru assert (var not in env) == scrubbed -# ── diffusion GGUFs clear host-residency memory-mode state ─────────────────── -# The route rejects known DiffusionGemma Vulkan pins and explicit host-memory modes -# before teardown. load_model retains a post-download Vulkan guard for a remote -# uncached model whose architecture was not known to the route (#7239). A successful -# diffusion load also clears any host-residency memory_mode carried from a prior -# llama-server load because the diffusion runner has no --mlock/--no-mmap support. +# ── diffusion GGUF placement ───────────────────────────────────────────────── def test_remote_diffusion_load_rejects_vulkan_ordinal_after_download(tmp_path): - """A renamed remote DiffusionGemma may be unclassifiable until its downloaded - GGUF header is read. Never pass its Vulkan ordinal to the CUDA diffusion runner.""" + """Reject a Vulkan ordinal when a remote model proves to be diffusion.""" gguf = tmp_path / "renamed.gguf" _write_minimal_gguf(gguf, arch = "diffusion-gemma") @@ -1007,8 +936,7 @@ def test_remote_diffusion_load_rejects_vulkan_ordinal_after_download(tmp_path): def test_confirmed_diffusion_allows_physical_gpu_id_on_vulkan_build(tmp_path): - """The route validates known DiffusionGemma pins as CUDA physical IDs. A - Vulkan llama.cpp build does not change the diffusion runner's index space.""" + """Keep diffusion pins in CUDA physical-ID space on Vulkan builds.""" gguf = tmp_path / "diffusion.gguf" _write_minimal_gguf(gguf, arch = "diffusion-gemma") @@ -1054,12 +982,7 @@ def test_remote_diffusion_rejects_explicit_memory_mode_after_download(tmp_path): @pytest.mark.parametrize("mode", [None, "auto", "AUTO", ""]) def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): - """auto/blank/None is the allowed no-op default for diffusion. A successful load - must clear any requested memory mode left by a prior llama-server load so reload-dedup - doesn't force a needless kill+restart of the diffusion server. (The single-device - gpu_ids collapse is recorded inside _start_diffusion_server, exercised by #6414's - picker tests; this stub replaces the runner, so only the memory-mode clear, which - load_model performs itself, is asserted here.)""" + """A diffusion load clears stale llama-server memory-mode state.""" gguf = tmp_path / "diffusion.gguf" _write_minimal_gguf(gguf, arch = "diffusion-gemma") @@ -1069,7 +992,6 @@ def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): backend._is_vulkan_backend = lambda _binary = None: False backend._start_diffusion_server = lambda **kw: True - # Simulate leftover placement state from a previous llama-server GGUF load. backend._requested_memory_mode = "resident" backend._launched_with_inherited_mem_env = True @@ -1087,9 +1009,7 @@ def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): def test_local_chat_gguf_in_diffusion_path_not_prekilled(tmp_path): - """A local chat GGUF whose path contains "diffusion" is NOT a diffusion model: the - header (read via _classify_diffusion_gguf) decides, not the path string, so explicit - gpu_ids on such a local GGUF must load normally, not be rejected on the path (#7188).""" + """A diffusion-like path cannot override a normal local GGUF header.""" backend, gguf = _fit_fallback_backend(tmp_path, gpu_memory = [(0, 10000, 16000)]) backend._select_gpus = lambda *a, **k: ([0], False) diff --git a/studio/backend/tests/test_mtp_vram_budget.py b/studio/backend/tests/test_mtp_vram_budget.py index bdfc69e611..834d10de07 100644 --- a/studio/backend/tests/test_mtp_vram_budget.py +++ b/studio/backend/tests/test_mtp_vram_budget.py @@ -870,10 +870,7 @@ class TestExtraArgsMtpDetection: assert "llama_backend.hf_repo" in body def test_route_matcher_strips_memory_flags_from_explicit_extras(self): - # A request repeating a --mlock/--mmap/--no-mmap the loaded server already stripped - # must still hit the fast path, so a no-op re-Apply during training isn't reloaded - # (#7164). Only the request side is stripped: the backend keeps its flags so an - # explicit auto over a server that inherited --mlock still reloads to clear it. + # Strip only request-side memory flags so explicit auto can still trigger cleanup. routes_src = ( Path(__file__).resolve().parent.parent / "routes" / "inference.py" ).read_text() diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 26687f404f..180af96398 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -169,8 +169,7 @@ export async function validateModel( // --fit, while a pinned layer count is owned by the user. Tell validate // so it applies the same training-guard policy as /load. gpu_memory_mode: payload.gpu_memory_mode, - // GGUF host-memory loading policy. This is independent from GPU VRAM placement. - // Sent so validate's preflight matches the follow-up /load. + // Keep validate and load on the same host-memory policy. gguf_memory_mode: payload.gguf_memory_mode ?? null, }), }); diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 0e856ebd6f..63523a20f8 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -1009,8 +1009,6 @@ export function useChatModelRuntime() { loadedChatTemplateOverride: effectiveChatTemplateOverride, loadedIsMultimodal: isMultimodalResponse(loadResponse), loadedIsDiffusion: loadResponse.is_diffusion ?? false, - // activeMemoryMode is committed by loadedGpuMemoryFields (spread - // above) so every load path resets it uniformly; see its note. activeNativePathToken: nativePathToken ?? null, activeNativePathExpiresAtMs: nativePathToken ? nativePathExpiresAtMs @@ -1115,7 +1113,6 @@ export function useChatModelRuntime() { n_cpu_moe: stateBeforeUnload.loadedNCpuMoe ?? 0, tensor_split: stateBeforeUnload.loadedSplitRatio ?? undefined, gpu_ids: stateBeforeUnload.loadedGpuIds ?? undefined, - // Restore the previous model's host-memory residency too. gguf_memory_mode: stateBeforeUnload.activeMemoryMode ?? null, }); const rollbackSpeculativeType = normalizeSpeculativeType( diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index 2bc06ba95f..17f93501c6 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -336,8 +336,7 @@ export function applyActiveModelStatusToStore( hydratingExistingModel || gpuStatusChanged) && gpuStatusFields), - // Always advance the loaded baseline. gpuStatusFields above updates the - // editable value too, while preserving a same-model local edit. + // Advance the loaded baseline without overwriting same-model edits. ...(seedLoadParams && { activeMemoryMode: status.gguf_memory_mode ?? null, }), diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 8d8988cd84..a5d2f6f15b 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -693,12 +693,8 @@ export function loadedGpuMemoryFields(resp: { // effective placement, but that must not silently rewrite future requests. selectedGpuIds: gpuIds, loadedGpuIds: gpuIds, - // Commit the residency the backend actually applied. Mirroring both the - // editable value and loaded baseline keeps every load path in sync. + // Reset both editable and loaded state to the applied backend mode. ggufMemoryMode: resp.gguf_memory_mode ?? null, - // Otherwise, after a switch (compare/auto/rollback load) the store keeps - // the prior value, so an immediate same-model Apply could resend a stale - // mode. Non-GGUF and auto loads report null, resetting it. activeMemoryMode: resp.gguf_memory_mode ?? null, ...manualKnobs, }; @@ -994,8 +990,7 @@ type ChatRuntimeStore = { loadedGpuIds: number[] | null; /** Requested GGUF host-memory loading policy. null = backend/default behavior. */ ggufMemoryMode: GgufMemoryMode | null; - /** Backend-reported GGUF host-memory loading mode, used as the loaded - * baseline for status hydration and rollback. */ + /** Loaded GGUF host-memory mode used for hydration and rollback. */ activeMemoryMode: GgufMemoryMode | null; /** Persisted: expand every On Device GGUF repo's quantizations by default * instead of waiting for a click. */ diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 766e287b02..a1c196b526 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -20,11 +20,9 @@ export interface SystemGpuDevice { indexKind: GpuIndexKind | null; name: string; memoryTotalGb: number; - /** Free VRAM at fetch time. Degrades to the total when the utilization - * probe had no usage data; 0 only when the total is unknown too. */ + /** Free VRAM at fetch time, or total VRAM when usage is unavailable. */ memoryFreeGb: number; - /** True when `index` is safe to send as gpu_ids. This covers CUDA/HIP - * physical IDs and ggml Vulkan ordinals, but not unresolved relative IDs. */ + /** Whether `index` is safe to send as gpu_ids. */ pinnable: boolean; } @@ -84,10 +82,7 @@ function toGpuInfo(data: SystemInfoResponse | null): GpuInfo { } function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { - // The backend owns the index contract. CUDA/HIP expose stable physical IDs, - // while Vulkan exposes compact ggml ordinals. XPU and unresolved relative - // masks report support=false. Missing support info preserves compatibility - // with older backends. + // The backend declares whether its device indices are pinnable. const pinnableBackend = data?.gpu?.gguf_gpu_ids_supported !== false; return (data?.gpu?.devices ?? []) .filter((d) => typeof d.index === "number") @@ -144,38 +139,19 @@ export function useGpuDevices(): SystemGpuDevice[] { return devices; } -/** - * Await the shared /api/system fetch so cachedPinnableGpuIndices (and the - * store's reconcilePersistedGpuIds) can validate a persisted pick before a - * load path sends it -- on a cold cache the reconcile passes ids through - * unvalidated, and a stale cross-host pick then fails /load with the picker - * hidden. Resolves immediately once the module cache is warm; a failed fetch - * keeps the cache cold, preserving the "can't validate, backend guards" - * degradation. - */ +/** Warm the shared system cache before validating persisted GPU IDs. */ export async function ensureGpuDeviceCache(): Promise { await fetchSystemOnce(); } -/** - * Pinnable physical GPU indices from the already-fetched /api/system cache, for - * non-React code (the store) that needs to validate a persisted `gpu_ids` pick - * without triggering a fetch. Returns: - * - `null` when the cache isn't populated yet (caller can't validate, so keep - * the pick and let the backend guard reject a truly bad one); - * - `[]` when the host has no pinnable multi-GPU set (single GPU, or relative/ - * UUID-masked indices) -- the picker is hidden, so any saved pick is stale; - * - the physical indices otherwise. - */ +/** Cached pinnable IDs, null before fetch, or [] when pinning is unavailable. */ export function cachedPinnableGpuIndices(): number[] | null { if (!cachedSystem) return null; const pinnable = toGpuDevices(cachedSystem).filter((d) => d.pinnable); - // Mirrors the sheet's showGpuPicker gate: only a 2+ pinnable-GPU host can pin. return pinnable.length > 1 ? pinnable.map((d) => d.index) : []; } -/** Index namespace for persisted gpu_ids. Undefined means the cache is cold; - * null means the current host has no single pinnable namespace. */ +/** Cached index namespace, undefined before fetch and null when unavailable. */ export function cachedPinnableGpuIndexKind(): | GpuIndexKind | null diff --git a/studio/setup.sh b/studio/setup.sh index 1900098a7a..7996af7e94 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1401,10 +1401,7 @@ else # through to the CPU prebuilt instead of breaking the install. _PREBUILT_CMD+=(--has-rocm) fi - # The backend override is case-insensitive and trimmed. cpu maps to the - # persisted --force-cpu choice. vulkan is consumed directly by - # install_llama_prebuilt.py and remains scoped to llama.cpp, so it does not - # change the torch backend used for training. + # The normalized override affects llama.cpp only, not the training backend. _llama_backend="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" _legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" _explicit_vulkan_backend=false From 70eba212d553f8202febe51643722c3df7ef3a20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:08:17 +0000 Subject: [PATCH 034/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/inference.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index ea7289694d..4610150ed4 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3267,9 +3267,10 @@ def _request_matches_loaded_settings( _loaded_gpu_ids = llama_backend.requested_gpu_ids if _req_gpu_ids != _loaded_gpu_ids: return False - if LlamaCppBackend._canonical_memory_mode( - request.gguf_memory_mode - ) != llama_backend.memory_mode: + if ( + LlamaCppBackend._canonical_memory_mode(request.gguf_memory_mode) + != llama_backend.memory_mode + ): return False # Explicit auto must reload a child that inherited placement env vars. if request.gguf_memory_mode is not None and llama_backend.launched_with_inherited_mem_env: From 91f7d10639b0183511188601f40a1110909b6889 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:31:07 -0300 Subject: [PATCH 035/110] Address GGUF placement review findings --- studio/backend/core/inference/llama_cpp.py | 41 +++++++++++++++++++ studio/backend/routes/inference.py | 20 ++++++++- studio/backend/tests/test_gpu_memory_mode.py | 17 ++++++++ studio/backend/tests/test_gpu_selection.py | 26 ++++++++++++ .../tests/test_install_resolve_prebuilt.py | 28 +++++++++++++ .../tests/test_setup_llama_cpp_backend.py | 2 +- .../src/features/chat/api/chat-adapter.ts | 2 +- .../chat/hooks/use-chat-model-runtime.ts | 5 ++- .../lib/apply-inference-status-to-store.ts | 5 ++- .../src/features/chat/shared-composer.tsx | 2 +- .../chat/stores/chat-runtime-store.ts | 17 +++++++- .../model-config/apply-per-model-config.ts | 2 +- studio/install_llama_prebuilt.py | 21 ++++++---- studio/setup.sh | 2 +- tests/studio/test_model_picker_contracts.py | 18 +++++--- 15 files changed, 184 insertions(+), 24 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 5a813c0eab..00f1d834a3 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2558,6 +2558,46 @@ class LlamaCppBackend: """Raw requested GPU pin before automatic fit narrows it.""" return self._requested_gpu_ids + def matches_gpu_ids(self, gpu_ids: Optional[List[int]]) -> bool: + """Whether a requested pin is already satisfied by the active runner. + + A regular GGUF load may narrow the requested placement pool to the + smallest fitting subset. Accept both the original request and the + effective status-echoed subset so either can round-trip without a + needless reload. Diffusion drives one device and keeps its existing + lowest-device normalization. + """ + if self._is_diffusion: + requested = [sorted(int(x) for x in gpu_ids)[0]] if gpu_ids else None + return requested == (self._gpu_ids or None) + + requested = sorted(int(x) for x in gpu_ids) if gpu_ids else None + raw = self._requested_gpu_ids or None + effective = self._gpu_ids or None + return requested == raw or requested == effective + + def _record_matching_gpu_request(self, gpu_ids: Optional[List[int]]) -> None: + """Adopt the caller's explicit pool after a full already-loaded match. + + Matching an effective subset avoids a reload, but the incoming request + is still the user's latest placement intent. Record it so status and a + later reload do not restore GPUs the user just removed. + """ + if self._is_diffusion: + self._requested_gpu_ids = [sorted(int(x) for x in gpu_ids)[0]] if gpu_ids else None + else: + self._requested_gpu_ids = sorted(int(x) for x in gpu_ids) if gpu_ids else None + if self._last_load_kwargs is not None: + self._last_load_kwargs["gpu_ids"] = ( + list(self._requested_gpu_ids) if self._requested_gpu_ids else None + ) + + def _record_matching_memory_request(self, memory_mode: Optional[str]) -> None: + """Adopt the caller's raw host-memory intent after a full match.""" + self._requested_memory_mode = (memory_mode or "").strip().lower() or None + if self._last_load_kwargs is not None and "memory_mode" in self._last_load_kwargs: + self._last_load_kwargs["memory_mode"] = self._requested_memory_mode + @property def memory_mode(self) -> Optional[str]: """Canonical active GGUF memory mode; auto is None.""" @@ -9322,6 +9362,7 @@ class LlamaCppBackend: if candidate != current: return False self._record_matching_gpu_request(gpu_ids) + self._record_matching_memory_request(memory_mode) return True def _classify_gpu_offload( diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 4610150ed4..8ac7343145 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4008,8 +4008,23 @@ async def _resolve_gguf_gpu_ids_for_request( from utils.hardware import DeviceType, get_device from utils.hardware.hardware import resolve_requested_gpu_ids - is_vulkan = LlamaCppBackend._is_vulkan_backend() - if get_device() == DeviceType.XPU and not is_vulkan: + llama_backend = get_llama_cpp_backend() + is_vulkan_build = await asyncio.to_thread(llama_backend.is_vulkan_build) + confirmed_diffusion = _classify_diffusion_gguf(config) is True + ids_are_vulkan_ordinals = is_vulkan_build and not confirmed_diffusion + device = get_device() + lacks_gpu_lib = getattr(llama_backend, "_backend_lacks_gpu_lib", None) + + if confirmed_diffusion and device != DeviceType.CUDA: + raise HTTPException( + status_code = 400, + detail = ( + "GPU selection (gpu_ids) for DiffusionGemma requires CUDA. " + "Omit gpu_ids on this host." + ), + ) + + if device == DeviceType.XPU and not ids_are_vulkan_ordinals: raise HTTPException( status_code = 400, detail = ( @@ -4456,6 +4471,7 @@ async def _load_model_impl( and getattr(llama_backend, "_audio_probed", True) ): llama_backend._record_matching_gpu_request(request.gpu_ids) + llama_backend._record_matching_memory_request(request.gguf_memory_mode) logger.info( "Model already loaded (GGUF): " f"{model_log_label} variant={request.gguf_variant or llama_backend.hf_variant}, skipping reload" diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index a87df8e272..9910c0dfd5 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -605,6 +605,23 @@ def test_response_models_emit_gpu_ids(model_cls): assert obj.model_dump()["requested_gpu_ids"] == [1, 2] +def test_gguf_load_and_status_responses_include_requested_gpu_pool(): + route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8") + assert route_src.count("requested_gpu_ids = llama_backend.requested_gpu_ids") == 3 + + +def test_already_loaded_response_preserves_explicit_auto_memory_mode(): + route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8") + assert "llama_backend._record_matching_memory_request(request.gguf_memory_mode)" in route_src + + backend = LlamaCppBackend() + backend._last_load_kwargs = {"memory_mode": None} + backend._record_matching_memory_request("auto") + assert backend.requested_memory_mode == "auto" + assert backend.memory_mode is None + assert backend._last_load_kwargs["memory_mode"] == "auto" + + def test_gpu_ids_property_default_and_reset(): backend = LlamaCppBackend() assert backend.gpu_ids is None diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 2c437ca170..5a3f376279 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1279,6 +1279,32 @@ class TestRouteErrors(unittest.TestCase): ) self.assertIn("CUDA_PATH_SENTINEL", exc_info.exception.detail) + def test_diffusion_gguf_gpu_ids_require_cuda(self): + import utils.hardware as hardware_pkg + + inference_route = _load_route_module( + "inference_route_module_for_diffusion_non_cuda_test", + "routes/inference.py", + ) + config = SimpleNamespace(is_gguf = True) + fake_backend = SimpleNamespace(is_vulkan_build = lambda: True) + with ( + patch.object(inference_route, "_classify_diffusion_gguf", return_value = True), + patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend), + patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), + patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CPU), + patch.object( + _hw_module, + "resolve_requested_gpu_ids", + side_effect = AssertionError("non-CUDA diffusion must reject before resolving"), + ), + ): + with self.assertRaises(HTTPException) as exc_info: + asyncio.run(inference_route._resolve_gguf_gpu_ids_for_request(config, [0])) + + self.assertEqual(exc_info.exception.status_code, 400) + self.assertIn("requires CUDA", exc_info.exception.detail) + def test_inherited_extras_preserve_memory_flags_when_mode_omitted(self): # Omission preserves memory flags while explicit context still owns -c. inference_route = _load_route_module( diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 68ab1082b1..8684071d2e 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -449,6 +449,34 @@ def test_forced_vulkan_filters_cpu_fallback_before_validation(): assert ilp._vulkan_only_attempts([vulkan, cpu]) == [vulkan] +def test_forced_vulkan_skips_cpu_only_release_plans(): + vulkan = ilp.AssetChoice( + repo = UPSTREAM, + tag = "b9924", + name = "llama-b9924-bin-ubuntu-vulkan-x64.tar.gz", + url = "https://example/vulkan", + source_label = "upstream", + install_kind = "linux-vulkan", + ) + cpu = ilp.AssetChoice( + repo = UPSTREAM, + tag = "b9925", + name = "llama-b9925-bin-ubuntu-x64.tar.gz", + url = "https://example/cpu", + source_label = "upstream", + install_kind = "linux-cpu", + ) + plans = [ + ilp.InstallReleasePlan("latest", "b9925", "b9925", [cpu], SimpleNamespace()), + ilp.InstallReleasePlan("latest", "b9924", "b9924", [vulkan], SimpleNamespace()), + ] + + filtered = ilp._vulkan_only_release_plans(plans) + + assert [plan.release_tag for plan in filtered] == ["b9924"] + assert filtered[0].attempts == [vulkan] + + def test_route_to_vulkan_prebuilt_auto_intel_goes_upstream_and_drops_fork_pin(): # Routing fork -> upstream also drops the fork release pin, which is in a # different tag namespace and would make the upstream resolver miss. diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 26bb5a495f..ac16374e91 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -39,7 +39,7 @@ def _run(value: str | None, system: str = "Linux") -> tuple[list[str], str]: if value is not None: env["UNSLOTH_LLAMA_CPP_BACKEND"] = value harness = ( - f'_PREBUILT_CMD=()\nC_WARN=""\n_HOST_SYSTEM="{system}"\n' + f'set -u\n_PREBUILT_CMD=()\nC_WARN=""\nC_OK=""\n_HOST_SYSTEM="{system}"\n' 'step() { printf "STEP: %s\\n" "$*" >&2; }\n' f"{_backend_block()}\n" 'printf "%s\\n" "${_PREBUILT_CMD[@]}"' diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index dd3b0cbcf4..27459eba3b 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1567,7 +1567,7 @@ async function autoLoadSmallestModel(): Promise<{ config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( config.selectedGpuIds, - config.selectedGpuIndexKind ?? null, + config.selectedGpuIndexKind, ) : null; const effectiveMemoryMode = config.ggufMemoryMode ?? null; diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 63523a20f8..8a348f306c 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -699,6 +699,9 @@ export function useChatModelRuntime() { const validateGpuIds = resetsPerModelSettings ? null : loadSelectedGpuIds; + const validateMemoryMode = resetsPerModelSettings + ? null + : loadMemoryMode; // The reset below re-baselines gpuLayers to Auto; mirror it here. const validateGpuLayers = resetsPerModelSettings ? GPU_LAYERS_AUTO @@ -730,7 +733,7 @@ export function useChatModelRuntime() { gguf_variant: ggufVariant ?? null, gpu_ids: validateGpuIds ?? undefined, ...(isGguf ? { gpu_memory_mode: loadGpuMemoryMode } : {}), - gguf_memory_mode: loadMemoryMode ?? null, + gguf_memory_mode: validateMemoryMode, }); // Upgrade consent runs before the security dialogs; Accept installs and the load continues. if (validation.requires_transformers_upgrade) { diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index 17f93501c6..e54f67a58d 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -13,6 +13,7 @@ import { type ReasoningStyle, loadOptionalBool, loadedGpuMemoryFields, + requestedGpuIdsFromResponse, resolveToolsEnabledOnLoad, useChatRuntimeStore, } from "../stores/chat-runtime-store"; @@ -222,7 +223,9 @@ export function applyActiveModelStatusToStore( incomingGpuMode === "manual" ? (status.n_cpu_moe ?? null) : null; const incomingSplit = incomingGpuMode === "manual" ? (status.tensor_split ?? null) : null; - const incomingGpuIds = status.is_gguf ? (status.gpu_ids ?? null) : null; + const incomingGpuIds = status.is_gguf + ? requestedGpuIdsFromResponse(status) + : null; const incomingMemoryMode = status.is_gguf ? (status.gguf_memory_mode ?? null) : null; diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 848d8a786c..0aa44ac3f1 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -1059,7 +1059,7 @@ export function SharedComposer({ ownConfig.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( ownConfig.selectedGpuIds, - ownConfig.selectedGpuIndexKind ?? null, + ownConfig.selectedGpuIndexKind, ) : compareLoadKnobs.selectedGpuIds; const effectiveMemoryMode = ownConfig.ggufMemoryMode ?? null; diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index a5d2f6f15b..569d5f2ea8 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -596,9 +596,11 @@ export function reconcilePersistedGpuIds( if (ids == null) return ids; if (arguments.length >= 2) { const currentIndexKind = cachedPinnableGpuIndexKind(); + const expectedIndexKind = + savedIndexKind === undefined ? "physical" : savedIndexKind; if ( currentIndexKind !== undefined && - currentIndexKind !== savedIndexKind + currentIndexKind !== expectedIndexKind ) { return null; } @@ -609,6 +611,15 @@ export function reconcilePersistedGpuIds( return kept.length > 0 ? kept : null; } +export function requestedGpuIdsFromResponse(resp: { + gpu_ids?: number[] | null; + requested_gpu_ids?: number[] | null; +}): number[] | null { + return Object.prototype.hasOwnProperty.call(resp, "requested_gpu_ids") + ? (resp.requested_gpu_ids ?? null) + : (resp.gpu_ids ?? null); +} + // Store fields derived from a load/status response's GPU-memory settings. // Shared by every load path so the manual-knob round-trip can't drift. export function loadedGpuMemoryFields(resp: { @@ -651,7 +662,9 @@ export function loadedGpuMemoryFields(resp: { }; } const mode = resp.gpu_memory_mode ?? "auto"; - const gpuIds = resp.requested_gpu_ids ?? resp.gpu_ids ?? null; + // Keep the user's placement pool editable across status/load hydration. + // gpu_ids remains the effective fitted subset for diagnostics. + const gpuIds = requestedGpuIdsFromResponse(resp); // Layer/MoE/split knobs apply (and are reported) only in manual mode; in auto // the server ignores them, so don't seed the loaded baseline or the editable // knobs with values it never applied. In manual, the server reports gpu_layers diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index 5fa39cf8c9..8dcd7667c7 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -55,7 +55,7 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( config.selectedGpuIds, - config.selectedGpuIndexKind ?? null, + config.selectedGpuIndexKind, ) : null, ggufMemoryMode: config.ggufMemoryMode ?? null, diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 0a96ab0b9d..53daf32d83 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6077,11 +6077,20 @@ def _vulkan_only_host(host: HostInfo) -> HostInfo: def _vulkan_only_attempts(attempts: Iterable[AssetChoice]) -> list[AssetChoice]: """Remove generic CPU fallbacks from an explicit Vulkan install.""" - filtered = [ + return [ attempt for attempt in attempts if attempt.install_kind in ("linux-vulkan", "windows-vulkan") ] + + +def _vulkan_only_release_plans(plans: Iterable[InstallReleasePlan]) -> list[InstallReleasePlan]: + """Keep release plans that contain an explicit Vulkan bundle.""" + filtered = [] + for plan in plans: + attempts = _vulkan_only_attempts(plan.attempts) + if attempts: + filtered.append(dataclasses_replace(plan, attempts = attempts)) if not filtered: raise PrebuiltFallback("no Vulkan prebuilt bundle attempts were available") return filtered @@ -6223,13 +6232,7 @@ def install_prebuilt( if strict_vulkan: # Upstream plans append CPU as a generic fallback. An explicit # Vulkan selection must fail instead of silently installing it. - release_plans = [ - dataclasses_replace( - plan, - attempts = _vulkan_only_attempts(plan.attempts), - ) - for plan in release_plans - ] + release_plans = _vulkan_only_release_plans(release_plans) if release_plans and existing_install_matches_plan(install_dir, host, release_plans[0]): current = release_plans[0] if diffusion_visual_server_backfill_needed(install_dir, host, current.attempts[0]): @@ -6569,6 +6572,8 @@ def main() -> int: _requested, plans = resolve_simple_install_release_plans( args.resolve_prebuilt, host, repo, release_tag ) + if force_vulkan_requested() and not _cpu_mechanism and not host.is_macos: + plans = _vulkan_only_release_plans(plans) choice = plans[0].attempts[0] if plans and plans[0].attempts else None if choice is None: payload = {"prebuilt_available": False, "repo": repo} diff --git a/studio/setup.sh b/studio/setup.sh index 7996af7e94..3ee7d57959 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1418,7 +1418,7 @@ else step "llama.cpp" "Vulkan has no effect on macOS; the universal build uses Metal" "$C_WARN" >&2 else _explicit_vulkan_backend=true - step "llama.cpp" "Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" "$C_INFO" + step "llama.cpp" "Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" "$C_OK" fi ;; ""|auto) ;; diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 1a73ebab00..353accd34a 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -164,10 +164,11 @@ def test_gpu_picker_round_trips_requested_pool_not_fitted_subset(): assert types.count("requested_gpu_ids?: number[] | null") >= 2 store = _read("features/chat/stores/chat-runtime-store.ts") - assert "resp.requested_gpu_ids ?? resp.gpu_ids ?? null" in store + assert 'hasOwnProperty.call(resp, "requested_gpu_ids")' in store + assert "const gpuIds = requestedGpuIdsFromResponse(resp)" in store status = _read("features/chat/lib/apply-inference-status-to-store.ts") - assert "status.requested_gpu_ids ?? status.gpu_ids ?? null" in status + assert "requestedGpuIdsFromResponse(status)" in status def test_compare_load_uses_each_models_gpu_config(): @@ -176,7 +177,7 @@ def test_compare_load_uses_each_models_gpu_config(): assert "ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers" in src assert "ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe" in src assert "if (ownConfig.selectedGpuIds != null)" in src - assert "ownConfig.selectedGpuIndexKind ?? null" in src + assert "ownConfig.selectedGpuIndexKind," in src for field in ( "gpu_memory_mode: effectiveGpuMemoryMode", "gpu_layers: effectiveGpuLayers", @@ -452,14 +453,21 @@ def test_default_gpu_mode_clears_manual_knobs(): def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): store = _read("features/chat/stores/chat-runtime-store.ts") - assert "resp.requested_gpu_ids ?? resp.gpu_ids ?? null" in store - assert "currentIndexKind !== savedIndexKind" in store + assert "requestedGpuIdsFromResponse(resp)" in store + assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in store + assert "currentIndexKind !== expectedIndexKind" in store gpu_info = _read("hooks/use-gpu-info.ts") assert "cachedPinnableGpuIndexKind" in gpu_info config = _read("features/model-picker/model-config/per-model-config.ts") assert '"selectedGpuIndexKind"' in config +def test_model_switch_clears_host_memory_mode_before_validation(): + src = _read("features/chat/hooks/use-chat-model-runtime.ts") + assert "const validateMemoryMode = resetsPerModelSettings" in src + assert "gguf_memory_mode: validateMemoryMode" in src + + def test_legacy_migration_is_idempotent_and_non_destructive(): """The v1->v2 localStorage migration (unsloth_load_settings -> unsloth_model_configs) is invoked on every store read, so it must be From a234efe26330c040d556d961782781876a685260 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:14:11 -0300 Subject: [PATCH 036/110] Address GGUF placement review follow-ups --- studio/backend/core/inference/llama_cpp.py | 7 ++++++- studio/backend/main.py | 8 ++++++-- studio/backend/routes/inference.py | 12 ++++++++++-- studio/backend/tests/test_gpu_selection.py | 12 +++++++++--- studio/backend/tests/test_llama_cpp_memory_mode.py | 1 + .../src/features/chat/stores/chat-runtime-store.ts | 3 +++ tests/studio/test_model_picker_contracts.py | 6 ++++++ 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 00f1d834a3..10b75a7afc 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -6568,7 +6568,12 @@ class LlamaCppBackend: is_vulkan_backend = self._is_vulkan_backend(binary) # Reject stale Vulkan ordinals before replacing the live model. - if is_vulkan_backend and gpu_ids and binary: + if ( + is_vulkan_backend + and gpu_ids + and binary + and gpu_ids_are_vulkan_ordinals is not False + ): _pf_wanted = {int(x) for x in gpu_ids} _pf_probed = {g[0] for g in self._get_gpu_memory(binary)} if not _pf_wanted.issubset(_pf_probed): diff --git a/studio/backend/main.py b/studio/backend/main.py index 7409aaa7f0..571de1b65b 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1211,8 +1211,12 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: gpu_ids_supported = bool(enriched_devices) else: # XPU indices cannot yet be applied safely across Level Zero's - # FLAT and COMPOSITE hierarchy modes. - gpu_ids_supported = get_device() != DeviceType.XPU + # FLAT and COMPOSITE hierarchy modes. A proven CPU-only + # llama.cpp build cannot apply a CUDA pin either. + gpu_ids_supported = ( + get_device() != DeviceType.XPU + and not LlamaCppBackend._backend_lacks_gpu_lib() + ) except Exception as e: logger.debug(f"Could not resolve gpu_ids support: {e}") gpu_ids_supported = True diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 8ac7343145..e7daf2c6b7 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4010,7 +4010,9 @@ async def _resolve_gguf_gpu_ids_for_request( llama_backend = get_llama_cpp_backend() is_vulkan_build = await asyncio.to_thread(llama_backend.is_vulkan_build) - confirmed_diffusion = _classify_diffusion_gguf(config) is True + diffusion_kind = _classify_diffusion_gguf(config) + confirmed_diffusion = diffusion_kind is True + definitively_non_diffusion = diffusion_kind is False ids_are_vulkan_ordinals = is_vulkan_build and not confirmed_diffusion device = get_device() lacks_gpu_lib = getattr(llama_backend, "_backend_lacks_gpu_lib", None) @@ -4033,7 +4035,13 @@ async def _resolve_gguf_gpu_ids_for_request( ), ) - if is_vulkan and _classify_diffusion_gguf(config) is True: + if ( + device == DeviceType.CUDA + and not ids_are_vulkan_ordinals + and definitively_non_diffusion + and callable(lacks_gpu_lib) + and await asyncio.to_thread(lacks_gpu_lib) + ): raise HTTPException( status_code = 400, detail = ( diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 5a3f376279..39c36a8802 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1141,6 +1141,7 @@ class TestRouteErrors(unittest.TestCase): "ModelConfig", SimpleNamespace(from_identifier = lambda **_kwargs: model_config), ), + patch.object(inference_route, "_classify_diffusion_gguf", return_value = False), patch.object(inference_route, "_guard_chat_load_against_training", return_value = None), patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext), @@ -1160,8 +1161,9 @@ class TestRouteErrors(unittest.TestCase): self.assertEqual(exc_info.exception.status_code, 400) self.assertIn("cpu-only build", exc_info.exception.detail.lower()) - def test_cpu_only_llama_build_skips_reject_for_diffusion_gguf(self): - # Diffusion uses its own runner, independent of llama.cpp GPU libraries. + def test_cpu_only_llama_build_defers_reject_for_unknown_diffusion_gguf(self): + # A remote GGUF may prove to use the independent diffusion runner only + # after download, so the route must not reject it as llama.cpp yet. import utils.hardware as hardware_pkg inference_route = _load_route_module( @@ -1195,7 +1197,11 @@ class TestRouteErrors(unittest.TestCase): "ModelConfig", SimpleNamespace(from_identifier = lambda **_kwargs: model_config), ), - patch.object(inference_route, "_classify_diffusion_gguf", return_value = True), + patch.object( + inference_route, + "_classify_diffusion_gguf", + return_value = None, + ), patch.object( _hw_module, "resolve_requested_gpu_ids", diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index d6834afbc6..1d5d960a11 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -943,6 +943,7 @@ def test_confirmed_diffusion_allows_physical_gpu_id_on_vulkan_build(tmp_path): backend = LlamaCppBackend() backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" backend._is_vulkan_backend = lambda _binary = None: True + backend._get_gpu_memory = lambda _binary: [(0, 8 * 1024**3, 8 * 1024**3)] backend._read_gguf_metadata = lambda _path: setattr(backend, "_is_diffusion", True) captured = {} backend._start_diffusion_server = lambda **kwargs: captured.update(kwargs) or True diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 569d5f2ea8..c5a34a4b36 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -589,6 +589,8 @@ export function rebalanceSplit( // set), so a saved [1] on a now-1-GPU host doesn't get sent and rejected with no // way to clear it. A null pick (= automatic) passes through unchanged, and an // unpopulated device cache leaves the pick alone (the backend still guards). +// An explicit null namespace means discovery had not completed when the live +// state was captured, while an absent namespace is a legacy physical-ID pick. export function reconcilePersistedGpuIds( ids: number[] | null, savedIndexKind?: GpuIndexKind | null, @@ -600,6 +602,7 @@ export function reconcilePersistedGpuIds( savedIndexKind === undefined ? "physical" : savedIndexKind; if ( currentIndexKind !== undefined && + savedIndexKind !== null && currentIndexKind !== expectedIndexKind ) { return null; diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 353accd34a..46ee7059ab 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -455,6 +455,7 @@ def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): store = _read("features/chat/stores/chat-runtime-store.ts") assert "requestedGpuIdsFromResponse(resp)" in store assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in store + assert "savedIndexKind !== null" in store assert "currentIndexKind !== expectedIndexKind" in store gpu_info = _read("hooks/use-gpu-info.ts") assert "cachedPinnableGpuIndexKind" in gpu_info @@ -462,6 +463,11 @@ def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): assert '"selectedGpuIndexKind"' in config +def test_cpu_only_llama_build_hides_gpu_picker(): + src = (WORKDIR / "studio" / "backend" / "main.py").read_text() + assert "and not LlamaCppBackend._backend_lacks_gpu_lib()" in src + + def test_model_switch_clears_host_memory_mode_before_validation(): src = _read("features/chat/hooks/use-chat-model-runtime.ts") assert "const validateMemoryMode = resetsPerModelSettings" in src From 7faec73b1405d56baee2b9e2435bb59b7e0a6f13 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:15:08 +0000 Subject: [PATCH 037/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/studio/backend/main.py b/studio/backend/main.py index 571de1b65b..2cd85a5705 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1214,8 +1214,7 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: # FLAT and COMPOSITE hierarchy modes. A proven CPU-only # llama.cpp build cannot apply a CUDA pin either. gpu_ids_supported = ( - get_device() != DeviceType.XPU - and not LlamaCppBackend._backend_lacks_gpu_lib() + get_device() != DeviceType.XPU and not LlamaCppBackend._backend_lacks_gpu_lib() ) except Exception as e: logger.debug(f"Could not resolve gpu_ids support: {e}") From f4def43666e794122a11a01ce87f40d8f430983c Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:16:48 -0300 Subject: [PATCH 038/110] Reject invalid remote diffusion placement before teardown --- studio/backend/core/inference/llama_cpp.py | 28 +++++++++++++------ .../tests/test_llama_cpp_memory_mode.py | 11 ++++++-- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 10b75a7afc..4f3c7281fb 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -6582,9 +6582,13 @@ class LlamaCppBackend: f"present. Available Vulkan devices: {sorted(_pf_probed)}." ) - # Classify remote Vulkan loads before teardown because diffusion uses CUDA IDs. + # Classify remote loads before teardown when diffusion would change + # the meaning or validity of requested placement. _preflight_model_path = None - if is_vulkan_backend and gpu_ids and hf_repo: + _preflight_memory_mode = self._canonical_memory_mode(memory_mode) + if hf_repo and ( + (is_vulkan_backend and gpu_ids) or _preflight_memory_mode is not None + ): _resolved_repo = _resolve_repo_id_casing(hf_repo) if _resolved_repo != hf_repo: logger.info( @@ -6600,13 +6604,19 @@ class LlamaCppBackend: hf_token = hf_token, ) if self._gguf_path_is_diffusion(_preflight_model_path, model_identifier): - raise ValueError( - "GPU selection (gpu_ids) is not supported for a DiffusionGemma " - "GGUF on a Vulkan llama.cpp build: the diffusion runner selects " - "its device by CUDA physical index, which has no defined mapping " - "to ggml Vulkan device ordinals. Omit gpu_ids to use the default " - "device." - ) + if _preflight_memory_mode is not None: + raise ValueError( + "GGUF host-memory modes are not supported for " + "DiffusionGemma models. Use Auto." + ) + if is_vulkan_backend and gpu_ids: + raise ValueError( + "GPU selection (gpu_ids) is not supported for a DiffusionGemma " + "GGUF on a Vulkan llama.cpp build: the diffusion runner selects " + "its device by CUDA physical index, which has no defined mapping " + "to ggml Vulkan device ordinals. Omit gpu_ids to use the default " + "device." + ) # ── Phase 1: kill old process (under lock, fast) ────────── with self._lock: diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 1d5d960a11..14616a3761 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -958,7 +958,7 @@ def test_confirmed_diffusion_allows_physical_gpu_id_on_vulkan_build(tmp_path): assert captured["gpu_ids"] == [1] -def test_remote_diffusion_rejects_explicit_memory_mode_after_download(tmp_path): +def test_remote_diffusion_rejects_explicit_memory_mode_before_teardown(tmp_path): gguf = tmp_path / "renamed.gguf" _write_minimal_gguf(gguf, arch = "diffusion-gemma") @@ -971,7 +971,14 @@ def test_remote_diffusion_rejects_explicit_memory_mode_after_download(tmp_path): "unsupported memory mode reached the diffusion runner" ) - with pytest.raises(ValueError, match = "host-memory modes are not supported"): + with ( + patch.object( + backend, + "_kill_process", + side_effect = AssertionError("invalid placement tore down the live model"), + ), + pytest.raises(ValueError, match = "host-memory modes are not supported"), + ): backend.load_model( hf_repo = "renamed/model", hf_variant = "Q4_K_M", From 551d09fa1fe918400cf2b74524b9c702b15e2e25 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:18:02 +0000 Subject: [PATCH 039/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 4f3c7281fb..e70d056895 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -6586,9 +6586,7 @@ class LlamaCppBackend: # the meaning or validity of requested placement. _preflight_model_path = None _preflight_memory_mode = self._canonical_memory_mode(memory_mode) - if hf_repo and ( - (is_vulkan_backend and gpu_ids) or _preflight_memory_mode is not None - ): + if hf_repo and ((is_vulkan_backend and gpu_ids) or _preflight_memory_mode is not None): _resolved_repo = _resolve_repo_id_casing(hf_repo) if _resolved_repo != hf_repo: logger.info( From f49d0e090122169875ea3248c8a64ccde786b5fb Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:50:53 -0300 Subject: [PATCH 040/110] Correct Vulkan GGUF placement boundaries --- .../backend/core/inference/_vulkan_probe.py | 39 +++++++++----- studio/backend/core/inference/llama_cpp.py | 38 +++++++++----- studio/backend/main.py | 17 +++--- studio/backend/routes/inference.py | 14 ++++- studio/backend/tests/test_gpu_selection.py | 10 ++-- .../tests/test_llama_cpp_memory_mode.py | 52 +++++++++++++++++++ .../tests/test_llama_cpp_vulkan_probe.py | 37 +++++++++++++ studio/frontend/src/hooks/use-gpu-info.ts | 4 +- studio/frontend/src/hooks/use-system.ts | 3 ++ tests/studio/test_model_picker_contracts.py | 9 ++++ 10 files changed, 184 insertions(+), 39 deletions(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index f33e31f5f9..b8cdc65bc8 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -38,27 +38,42 @@ def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: base.ggml_backend_reg_dev_get.argtypes = [ctypes.c_void_p, ctypes.c_size_t] base.ggml_backend_dev_type.restype = ctypes.c_int base.ggml_backend_dev_type.argtypes = [ctypes.c_void_p] - base.ggml_backend_dev_name.restype = ctypes.c_char_p - base.ggml_backend_dev_name.argtypes = [ctypes.c_void_p] - base.ggml_backend_dev_description.restype = ctypes.c_char_p - base.ggml_backend_dev_description.argtypes = [ctypes.c_void_p] - reg = lib.ggml_backend_vk_reg() if not reg: return flags, names dev_count = base.ggml_backend_reg_dev_count(reg) - for i in range(min(count, dev_count)): + except Exception: + return flags, names + + name_functions = [] + for symbol in ("ggml_backend_dev_description", "ggml_backend_dev_name"): + try: + function = getattr(base, symbol) + function.restype = ctypes.c_char_p + function.argtypes = [ctypes.c_void_p] + name_functions.append(function) + except Exception: + pass + + for i in range(min(count, dev_count)): + try: dev = base.ggml_backend_reg_dev_get(reg, i) - if dev: + except Exception: + continue + if dev: + try: flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU - # Prefer the user-facing hardware description. - raw_name = base.ggml_backend_dev_description(dev) or base.ggml_backend_dev_name(dev) + except Exception: + pass + for function in name_functions: + try: + raw_name = function(dev) + except Exception: + continue if raw_name: name = raw_name.decode("utf-8", errors = "replace") names[i] = name.replace("\t", " ").replace("\r", " ").replace("\n", " ") - except Exception: - # Best-effort metadata must never suppress the memory readings. - pass + break return flags, names diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index e70d056895..9bcfc46cb5 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3644,10 +3644,12 @@ class LlamaCppBackend: ): free_mib = free_bytes // (1024 * 1024) capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu) - total_gb = round(total_bytes / (1024**3), 2) if total_bytes > 0 else 0 - free_gb = round(capped / 1024, 2) + total_gb = round(total_bytes / (1024**3), 2) if total_bytes > 0 and not is_igpu else 0 + free_gb = round(capped / 1024, 2) if not is_igpu else 0 used_gb = ( - round(max(0, total_bytes - free_bytes) / (1024**3), 2) if total_bytes > 0 else None + round(max(0, total_bytes - free_bytes) / (1024**3), 2) + if total_bytes > 0 and not is_igpu + else None ) devices.append( { @@ -6566,14 +6568,15 @@ class LlamaCppBackend: # GGUF uses the diffusion runner, and its arch is only known after the header. binary = self._find_llama_server_binary() is_vulkan_backend = self._is_vulkan_backend(binary) + _vulkan_ordinal_pin = ( + is_vulkan_backend and bool(gpu_ids) and gpu_ids_are_vulkan_ordinals is not False + ) + _cpu_only_pin = ( + bool(gpu_ids) and not is_vulkan_backend and self._backend_lacks_gpu_lib(binary) + ) # Reject stale Vulkan ordinals before replacing the live model. - if ( - is_vulkan_backend - and gpu_ids - and binary - and gpu_ids_are_vulkan_ordinals is not False - ): + if _vulkan_ordinal_pin and binary: _pf_wanted = {int(x) for x in gpu_ids} _pf_probed = {g[0] for g in self._get_gpu_memory(binary)} if not _pf_wanted.issubset(_pf_probed): @@ -6586,7 +6589,9 @@ class LlamaCppBackend: # the meaning or validity of requested placement. _preflight_model_path = None _preflight_memory_mode = self._canonical_memory_mode(memory_mode) - if hf_repo and ((is_vulkan_backend and gpu_ids) or _preflight_memory_mode is not None): + if hf_repo and ( + _vulkan_ordinal_pin or _cpu_only_pin or _preflight_memory_mode is not None + ): _resolved_repo = _resolve_repo_id_casing(hf_repo) if _resolved_repo != hf_repo: logger.info( @@ -6601,13 +6606,16 @@ class LlamaCppBackend: hf_variant = hf_variant, hf_token = hf_token, ) - if self._gguf_path_is_diffusion(_preflight_model_path, model_identifier): + _preflight_is_diffusion = self._gguf_path_is_diffusion( + _preflight_model_path, model_identifier + ) + if _preflight_is_diffusion: if _preflight_memory_mode is not None: raise ValueError( "GGUF host-memory modes are not supported for " "DiffusionGemma models. Use Auto." ) - if is_vulkan_backend and gpu_ids: + if _vulkan_ordinal_pin: raise ValueError( "GPU selection (gpu_ids) is not supported for a DiffusionGemma " "GGUF on a Vulkan llama.cpp build: the diffusion runner selects " @@ -6615,6 +6623,12 @@ class LlamaCppBackend: "to ggml Vulkan device ordinals. Omit gpu_ids to use the default " "device." ) + elif _cpu_only_pin: + raise ValueError( + f"Requested gpu_ids {list(gpu_ids)} but the llama.cpp build has " + "no GPU backend (CPU-only build); it would ignore the pin and run " + "on CPU. Omit gpu_ids to run on CPU." + ) # ── Phase 1: kill old process (under lock, fast) ────────── with self._lock: diff --git a/studio/backend/main.py b/studio/backend/main.py index 2cd85a5705..d03bc0541d 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1195,20 +1195,19 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: enriched_devices.append(enriched_dev) # Vulkan has its own compact ordinal space, independent of torch and - # CUDA_VISIBLE_DEVICES. Replace torch's view with the same ggml probe - # records that /load validates and pins as --device VulkanN. + # CUDA_VISIBLE_DEVICES. Keep torch's view global and expose the ggml + # records separately for the GGUF picker. + gguf_devices = enriched_devices + gguf_backend = visibility_info.get("backend") try: from core.inference.llama_cpp import LlamaCppBackend from utils.hardware import DeviceType, get_device is_vulkan = LlamaCppBackend._is_vulkan_backend() if is_vulkan: - enriched_devices = LlamaCppBackend._get_vulkan_gpu_info() - visibility_info = { - "available": bool(enriched_devices), - "backend": "vulkan", - } - gpu_ids_supported = bool(enriched_devices) + gguf_devices = LlamaCppBackend._get_vulkan_gpu_info() + gguf_backend = "vulkan" + gpu_ids_supported = bool(gguf_devices) else: # XPU indices cannot yet be applied safely across Level Zero's # FLAT and COMPOSITE hierarchy modes. A proven CPU-only @@ -1223,6 +1222,8 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: "available": visibility_info.get("available", False), "devices": enriched_devices, "backend": visibility_info.get("backend"), + "gguf_devices": gguf_devices, + "gguf_backend": gguf_backend, "gguf_gpu_ids_supported": gpu_ids_supported, } _system_gpu_cache = (time.monotonic(), gpu_info) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index e7daf2c6b7..0fad75cd60 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4013,7 +4013,6 @@ async def _resolve_gguf_gpu_ids_for_request( diffusion_kind = _classify_diffusion_gguf(config) confirmed_diffusion = diffusion_kind is True definitively_non_diffusion = diffusion_kind is False - ids_are_vulkan_ordinals = is_vulkan_build and not confirmed_diffusion device = get_device() lacks_gpu_lib = getattr(llama_backend, "_backend_lacks_gpu_lib", None) @@ -4026,6 +4025,19 @@ async def _resolve_gguf_gpu_ids_for_request( ), ) + if confirmed_diffusion and is_vulkan_build: + raise HTTPException( + status_code = 400, + detail = ( + "GPU selection (gpu_ids) is not supported for a DiffusionGemma " + "GGUF on a Vulkan llama.cpp build: the picker uses Vulkan ordinals, " + "which have no defined mapping to CUDA physical indices. Omit gpu_ids " + "to use the default device." + ), + ) + + ids_are_vulkan_ordinals = is_vulkan_build + if device == DeviceType.XPU and not ids_are_vulkan_ordinals: raise HTTPException( status_code = 400, diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 39c36a8802..cc14beb7ce 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1226,8 +1226,9 @@ class TestRouteErrors(unittest.TestCase): self.assertNotIn("cpu-only build", exc_info.exception.detail.lower()) self.assertIn("SENTINEL_AFTER_GATE", exc_info.exception.detail) - def test_diffusion_gguf_on_vulkan_build_uses_cuda_path(self): - # The diffusion runner uses CUDA physical IDs even beside Vulkan llama.cpp. + def test_diffusion_gguf_on_vulkan_build_rejects_ordinal_pin(self): + # The GGUF picker supplies Vulkan ordinals, which cannot be reinterpreted + # as the CUDA physical IDs used by the diffusion runner. import utils.hardware as hardware_pkg inference_route = _load_route_module( @@ -1265,7 +1266,7 @@ class TestRouteErrors(unittest.TestCase): patch.object( _hw_module, "resolve_requested_gpu_ids", - side_effect = ValueError("CUDA_PATH_SENTINEL"), + side_effect = AssertionError("Vulkan ordinal reached the CUDA resolver"), ), patch.object(inference_route, "_guard_chat_load_against_training", return_value = None), patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), @@ -1283,7 +1284,8 @@ class TestRouteErrors(unittest.TestCase): current_subject = "test-user", ) ) - self.assertIn("CUDA_PATH_SENTINEL", exc_info.exception.detail) + self.assertEqual(exc_info.exception.status_code, 400) + self.assertIn("no defined mapping", exc_info.exception.detail) def test_diffusion_gguf_gpu_ids_require_cuda(self): import utils.hardware as hardware_pkg diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 14616a3761..60ce87d203 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -988,6 +988,58 @@ def test_remote_diffusion_rejects_explicit_memory_mode_before_teardown(tmp_path) ) +def test_remote_normal_cpu_only_pin_rejects_before_teardown(tmp_path): + gguf = tmp_path / "renamed.gguf" + _write_minimal_gguf(gguf, arch = "llama") + + backend = LlamaCppBackend() + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._is_vulkan_backend = lambda _binary = None: False + backend._backend_lacks_gpu_lib = lambda _binary = None: True + backend._download_gguf = lambda **_kwargs: str(gguf) + + with ( + patch.object( + backend, + "_kill_process", + side_effect = AssertionError("invalid CPU-only pin tore down the live model"), + ), + pytest.raises(ValueError, match = "CPU-only build"), + ): + backend.load_model( + hf_repo = "renamed/model", + hf_variant = "Q4_K_M", + model_identifier = "renamed/model", + speculative_type = "off", + gpu_ids = [0], + gpu_ids_are_vulkan_ordinals = False, + ) + + +def test_remote_diffusion_cpu_only_pin_reaches_runner(tmp_path): + gguf = tmp_path / "renamed.gguf" + _write_minimal_gguf(gguf, arch = "diffusion-gemma") + + backend = LlamaCppBackend() + backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" + backend._is_vulkan_backend = lambda _binary = None: False + backend._backend_lacks_gpu_lib = lambda _binary = None: True + backend._download_gguf = lambda **_kwargs: str(gguf) + backend._read_gguf_metadata = lambda _path: setattr(backend, "_is_diffusion", True) + captured = {} + backend._start_diffusion_server = lambda **kwargs: captured.update(kwargs) or True + + assert backend.load_model( + hf_repo = "renamed/model", + hf_variant = "Q4_K_M", + model_identifier = "renamed/model", + speculative_type = "off", + gpu_ids = [1], + gpu_ids_are_vulkan_ordinals = False, + ) + assert captured["gpu_ids"] == [1] + + @pytest.mark.parametrize("mode", [None, "auto", "AUTO", ""]) def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): """A diffusion load clears stale llama-server memory-mode state.""" diff --git a/studio/backend/tests/test_llama_cpp_vulkan_probe.py b/studio/backend/tests/test_llama_cpp_vulkan_probe.py index 62129a78c5..f89a0e88a6 100644 --- a/studio/backend/tests/test_llama_cpp_vulkan_probe.py +++ b/studio/backend/tests/test_llama_cpp_vulkan_probe.py @@ -53,6 +53,7 @@ _maybe_stub("loggers", _build_loggers_stub) _maybe_stub("structlog", lambda: _types.ModuleType("structlog")) from core.inference import llama_cpp as _llama_mod # noqa: E402 +from core.inference._vulkan_probe import _device_metadata # noqa: E402 from core.inference.llama_cpp import ( # noqa: E402 LlamaCppBackend, _llama_lib_dir, @@ -63,6 +64,29 @@ MIB = 1024 * 1024 GIB = 1024 * MIB +class _FakeCFunction: + def __init__(self, result): + self.result = result + + def __call__(self, *_args): + return self.result + + +def test_missing_description_symbol_keeps_igpu_detection(): + base = _types.SimpleNamespace( + ggml_backend_reg_dev_count = _FakeCFunction(1), + ggml_backend_reg_dev_get = _FakeCFunction(1), + ggml_backend_dev_type = _FakeCFunction(2), + ggml_backend_dev_name = _FakeCFunction(b"Legacy Vulkan iGPU"), + ) + lib = _types.SimpleNamespace(ggml_backend_vk_reg = _FakeCFunction(1)) + + flags, names = _device_metadata(base, lib, 1) + + assert flags == [True] + assert names == ["Legacy Vulkan iGPU"] + + def _make_vulkan_install(tmp_path: Path) -> str: """A binary whose sibling dir holds the Vulkan ggml lib, so the reader's ``is_vulkan_backend`` sibling-file check passes.""" @@ -149,6 +173,19 @@ def test_vulkan_device_info_accepts_legacy_four_column_probe(tmp_path): assert devices[0]["name"] == "Vulkan2" +def test_vulkan_device_info_does_not_advertise_shared_ram_as_vram(tmp_path): + binary = _make_vulkan_install(tmp_path) + rows = [_row(0, 30 * GIB, is_igpu = 1, total_bytes = 32 * GIB, name = "Integrated GPU")] + with _mock_probe(rows): + [device] = LlamaCppBackend._get_vulkan_gpu_info(binary) + + assert device["memory_total_gb"] == 0 + assert device["vram_total_gb"] == 0 + assert device["vram_free_gb"] == 0 + assert device["vram_used_gb"] is None + assert device["vram_utilization_pct"] is None + + def test_large_discrete_gpu_is_untouched(tmp_path): binary = _make_vulkan_install(tmp_path) # A 48 GiB discrete card stays untouched regardless of size; only the diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index a1c196b526..6f2245f183 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -82,9 +82,9 @@ function toGpuInfo(data: SystemInfoResponse | null): GpuInfo { } function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { - // The backend declares whether its device indices are pinnable. + // GGUF placement may use a different namespace from the global torch view. const pinnableBackend = data?.gpu?.gguf_gpu_ids_supported !== false; - return (data?.gpu?.devices ?? []) + return (data?.gpu?.gguf_devices ?? data?.gpu?.devices ?? []) .filter((d) => typeof d.index === "number") .map((d) => ({ index: d.index as number, diff --git a/studio/frontend/src/hooks/use-system.ts b/studio/frontend/src/hooks/use-system.ts index 493684133b..6a21fca952 100644 --- a/studio/frontend/src/hooks/use-system.ts +++ b/studio/frontend/src/hooks/use-system.ts @@ -47,6 +47,9 @@ export interface SystemInfoResponse { parent_visible_gpu_ids?: number[]; index_kind?: string; devices: GpuDevice[]; + /** GGUF placement devices, using Vulkan ordinals when applicable. */ + gguf_devices?: GpuDevice[]; + gguf_backend?: string; }; ml_packages: { torch?: string; diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 46ee7059ab..eafc366124 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -468,6 +468,15 @@ def test_cpu_only_llama_build_hides_gpu_picker(): assert "and not LlamaCppBackend._backend_lacks_gpu_lib()" in src +def test_vulkan_gguf_devices_do_not_replace_global_gpu_info(): + backend = (WORKDIR / "studio" / "backend" / "main.py").read_text() + assert '"gguf_devices": gguf_devices' in backend + assert "gguf_devices = LlamaCppBackend._get_vulkan_gpu_info()" in backend + assert "enriched_devices = LlamaCppBackend._get_vulkan_gpu_info()" not in backend + frontend = _read("hooks/use-gpu-info.ts") + assert "data?.gpu?.gguf_devices ?? data?.gpu?.devices" in frontend + + def test_model_switch_clears_host_memory_mode_before_validation(): src = _read("features/chat/hooks/use-chat-model-runtime.ts") assert "const validateMemoryMode = resetsPerModelSettings" in src From ed63e3ef7d633d69fdad08c8b68a6f21e79b47ee Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:52:43 -0300 Subject: [PATCH 041/110] Simplify Vulkan metadata handling --- .../backend/core/inference/_vulkan_probe.py | 25 ++++++++++--------- studio/backend/main.py | 3 --- studio/frontend/src/hooks/use-system.ts | 1 - 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index b8cdc65bc8..8d6eeb3ae1 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -60,20 +60,21 @@ def _device_metadata(base, lib, count: int) -> tuple[list[bool], list[str]]: dev = base.ggml_backend_reg_dev_get(reg, i) except Exception: continue - if dev: + if not dev: + continue + try: + flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU + except Exception: + pass + for function in name_functions: try: - flags[i] = base.ggml_backend_dev_type(dev) == _GGML_BACKEND_DEVICE_TYPE_IGPU + raw_name = function(dev) except Exception: - pass - for function in name_functions: - try: - raw_name = function(dev) - except Exception: - continue - if raw_name: - name = raw_name.decode("utf-8", errors = "replace") - names[i] = name.replace("\t", " ").replace("\r", " ").replace("\n", " ") - break + continue + if raw_name: + name = raw_name.decode("utf-8", errors = "replace") + names[i] = name.replace("\t", " ").replace("\r", " ").replace("\n", " ") + break return flags, names diff --git a/studio/backend/main.py b/studio/backend/main.py index d03bc0541d..51590dd350 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1198,7 +1198,6 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: # CUDA_VISIBLE_DEVICES. Keep torch's view global and expose the ggml # records separately for the GGUF picker. gguf_devices = enriched_devices - gguf_backend = visibility_info.get("backend") try: from core.inference.llama_cpp import LlamaCppBackend from utils.hardware import DeviceType, get_device @@ -1206,7 +1205,6 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: is_vulkan = LlamaCppBackend._is_vulkan_backend() if is_vulkan: gguf_devices = LlamaCppBackend._get_vulkan_gpu_info() - gguf_backend = "vulkan" gpu_ids_supported = bool(gguf_devices) else: # XPU indices cannot yet be applied safely across Level Zero's @@ -1223,7 +1221,6 @@ def _get_cached_system_gpu_info(logger) -> dict[str, Any]: "devices": enriched_devices, "backend": visibility_info.get("backend"), "gguf_devices": gguf_devices, - "gguf_backend": gguf_backend, "gguf_gpu_ids_supported": gpu_ids_supported, } _system_gpu_cache = (time.monotonic(), gpu_info) diff --git a/studio/frontend/src/hooks/use-system.ts b/studio/frontend/src/hooks/use-system.ts index 6a21fca952..269fa84a92 100644 --- a/studio/frontend/src/hooks/use-system.ts +++ b/studio/frontend/src/hooks/use-system.ts @@ -49,7 +49,6 @@ export interface SystemInfoResponse { devices: GpuDevice[]; /** GGUF placement devices, using Vulkan ordinals when applicable. */ gguf_devices?: GpuDevice[]; - gguf_backend?: string; }; ml_packages: { torch?: string; From 9d5a27daecb560fb30b2a93779ac0ae927945986 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:20:49 -0300 Subject: [PATCH 042/110] Preserve deferred GPU index namespaces --- .../src/features/chat/api/chat-adapter.ts | 11 +++++++-- .../chat/hooks/use-chat-model-runtime.ts | 2 ++ .../lib/apply-inference-status-to-store.ts | 5 +++- .../chat/presets/preset-load-config.ts | 1 + .../src/features/chat/shared-composer.tsx | 5 +++- .../chat/stores/chat-runtime-store.ts | 20 ++++++++++++++-- .../hooks/use-active-model-config.ts | 8 ++++--- .../model-config/apply-per-model-config.ts | 23 +++++++++++-------- tests/studio/test_model_picker_contracts.py | 17 ++++++++++++++ 9 files changed, 73 insertions(+), 19 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 27459eba3b..986ed30322 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1935,6 +1935,13 @@ async function autoLoadSmallestModel(): Promise<{ "UD-Q4_K_XL", ); const defaultMemoryMode = defaultConfig.ggufMemoryMode ?? null; + if (rt.selectedGpuIds != null) { + await ensureGpuDeviceCache(); + } + const defaultGpuIds = reconcilePersistedGpuIds( + rt.selectedGpuIds, + rt.selectedGpuIndexKind, + ); if ( !(await canAutoLoad({ model_path: "unsloth/Qwen3.5-4B-MTP-GGUF", @@ -1943,7 +1950,7 @@ async function autoLoadSmallestModel(): Promise<{ gguf_variant: "UD-Q4_K_XL", // The same live-store GPU pick the load below sends (a fresh default // model has no remembered settings to prefer). - gpu_ids: rt.selectedGpuIds ?? undefined, + gpu_ids: defaultGpuIds ?? undefined, gpu_memory_mode: rt.gpuMemoryMode, gguf_memory_mode: defaultMemoryMode, })) @@ -1975,7 +1982,7 @@ async function autoLoadSmallestModel(): Promise<{ gpu_memory_mode: rt.gpuMemoryMode, gpu_layers: GPU_LAYERS_AUTO, n_cpu_moe: 0, - gpu_ids: rt.selectedGpuIds ?? undefined, + gpu_ids: defaultGpuIds ?? undefined, gguf_memory_mode: defaultMemoryMode, }); saveSpeculativeType(specSettings.speculativeType); diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 8a348f306c..b7306ff5a0 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -668,6 +668,7 @@ export function useChatModelRuntime() { } let loadSelectedGpuIds = reconcilePersistedGpuIds( stateBeforeUnload.selectedGpuIds, + stateBeforeUnload.selectedGpuIndexKind, ); let loadMemoryMode = stateBeforeUnload.ggufMemoryMode; let loadSpeculativeType = stateBeforeUnload.speculativeType; @@ -808,6 +809,7 @@ export function useChatModelRuntime() { // Per-model GPU knobs must not follow onto a different model // (gpuMemoryMode is a standing preference and is kept). selectedGpuIds: null, + selectedGpuIndexKind: null, ggufMemoryMode: null, gpuLayers: GPU_LAYERS_AUTO, nCpuMoe: 0, diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index e54f67a58d..0a47f2389e 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -268,7 +268,10 @@ export function applyActiveModelStatusToStore( customContextLength: prevState.customContextLength, }), ...(preserveSameModelEdits && - gpuIdsEditPending && { selectedGpuIds: prevState.selectedGpuIds }), + gpuIdsEditPending && { + selectedGpuIds: prevState.selectedGpuIds, + selectedGpuIndexKind: prevState.selectedGpuIndexKind, + }), ...(preserveSameModelEdits && memoryModeEditPending && { ggufMemoryMode: prevState.ggufMemoryMode }), }; diff --git a/studio/frontend/src/features/chat/presets/preset-load-config.ts b/studio/frontend/src/features/chat/presets/preset-load-config.ts index 1083655cf2..37c5071640 100644 --- a/studio/frontend/src/features/chat/presets/preset-load-config.ts +++ b/studio/frontend/src/features/chat/presets/preset-load-config.ts @@ -212,6 +212,7 @@ export function applyPresetLoadConfig( gpuLayers: config.gpuLayers, nCpuMoe: config.nCpuMoe, selectedGpuIds: store.selectedGpuIds, + selectedGpuIndexKind: store.selectedGpuIndexKind, }); } diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 0aa44ac3f1..47c5e21d97 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -998,7 +998,10 @@ export function SharedComposer({ // Reconcile the pick against the GPUs present now, like the model-switch // path: an early remember-restore can hold a stale cross-host pick that // /load would reject (the device cache is populated by send time). - selectedGpuIds: reconcilePersistedGpuIds(store.selectedGpuIds), + selectedGpuIds: reconcilePersistedGpuIds( + store.selectedGpuIds, + store.selectedGpuIndexKind, + ), customContextLength: store.customContextLength, }; // Set when an accepted transformers install unloaded the active model diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index c5a34a4b36..5f0305d9cf 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -650,6 +650,7 @@ export function loadedGpuMemoryFields(resp: { // baseline clears to null so Reset preserves the preference, not a stale mode. return { selectedGpuIds: null, + selectedGpuIndexKind: null, loadedGpuIds: null, loadedGpuMemoryMode: null, gpuLayers: GPU_LAYERS_AUTO, @@ -668,6 +669,8 @@ export function loadedGpuMemoryFields(resp: { // Keep the user's placement pool editable across status/load hydration. // gpu_ids remains the effective fitted subset for diagnostics. const gpuIds = requestedGpuIdsFromResponse(resp); + const gpuIndexKind = + gpuIds == null ? null : (cachedPinnableGpuIndexKind() ?? null); // Layer/MoE/split knobs apply (and are reported) only in manual mode; in auto // the server ignores them, so don't seed the loaded baseline or the editable // knobs with values it never applied. In manual, the server reports gpu_layers @@ -708,6 +711,7 @@ export function loadedGpuMemoryFields(resp: { // Keep the editable picker on the user's requested set. Auto fit can narrow // effective placement, but that must not silently rewrite future requests. selectedGpuIds: gpuIds, + selectedGpuIndexKind: gpuIndexKind, loadedGpuIds: gpuIds, // Reset both editable and loaded state to the applied backend mode. ggufMemoryMode: resp.gguf_memory_mode ?? null, @@ -1003,6 +1007,8 @@ type ChatRuntimeStore = { moeLayerCount: number | null; /** Picked IDs in the backend-declared GPU namespace (null = automatic). */ selectedGpuIds: number[] | null; + /** Namespace used by selectedGpuIds; kept with deferred persisted picks. */ + selectedGpuIndexKind: GpuIndexKind | null; loadedGpuIds: number[] | null; /** Requested GGUF host-memory loading policy. null = backend/default behavior. */ ggufMemoryMode: GgufMemoryMode | null; @@ -1143,7 +1149,10 @@ type ChatRuntimeStore = { setGpuLayers: (value: number) => void; setNCpuMoe: (value: number) => void; setSplitRatio: (value: number[] | null) => void; - setSelectedGpuIds: (ids: number[] | null) => void; + setSelectedGpuIds: ( + ids: number[] | null, + indexKind?: GpuIndexKind | null, + ) => void; setExpandQuantizations: (value: boolean) => void; setShowAllQuantizations: (value: boolean) => void; setFitOnDeviceOnly: (value: boolean) => void; @@ -1460,6 +1469,7 @@ export const useChatRuntimeStore = create((set, get) => ({ ggufLayerCount: null, moeLayerCount: null, selectedGpuIds: null, + selectedGpuIndexKind: null, loadedGpuIds: null, ggufMemoryMode: null, activeMemoryMode: null, @@ -1717,6 +1727,7 @@ export const useChatRuntimeStore = create((set, get) => ({ ggufLayerCount: null, moeLayerCount: null, selectedGpuIds: null, + selectedGpuIndexKind: null, loadedGpuIds: null, ggufMemoryMode: null, activeMemoryMode: null, @@ -2022,7 +2033,12 @@ export const useChatRuntimeStore = create((set, get) => ({ setGpuLayers: (gpuLayers) => set({ gpuLayers }), setNCpuMoe: (nCpuMoe) => set({ nCpuMoe }), setSplitRatio: (splitRatio) => set({ splitRatio }), - setSelectedGpuIds: (selectedGpuIds) => set({ selectedGpuIds }), + setSelectedGpuIds: (selectedGpuIds, selectedGpuIndexKind = null) => + set({ + selectedGpuIds, + selectedGpuIndexKind: + selectedGpuIds == null ? null : selectedGpuIndexKind, + }), setExpandQuantizations: (expandQuantizations) => { saveBool(CHAT_EXPAND_QUANTIZATIONS_KEY, expandQuantizations); set({ expandQuantizations }); diff --git a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts index 10d6f98f88..56c19ac4ac 100644 --- a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts +++ b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts @@ -4,7 +4,6 @@ import { isExternalModelId, useChatRuntimeStore } from "@/features/chat"; import { useMemo } from "react"; import type { PerModelConfig } from "../model-config/per-model-config"; -import { cachedPinnableGpuIndexKind } from "@/hooks/use-gpu-info"; export interface ActiveModelConfigState { checkpoint: string | null; @@ -29,6 +28,9 @@ export function useActiveModelConfig(): ActiveModelConfigState { const gpuLayers = useChatRuntimeStore((s) => s.gpuLayers); const nCpuMoe = useChatRuntimeStore((s) => s.nCpuMoe); const selectedGpuIds = useChatRuntimeStore((s) => s.selectedGpuIds); + const selectedGpuIndexKind = useChatRuntimeStore( + (s) => s.selectedGpuIndexKind, + ); const ggufMemoryMode = useChatRuntimeStore((s) => s.ggufMemoryMode); const isGguf = @@ -58,8 +60,7 @@ export function useActiveModelConfig(): ActiveModelConfigState { gpuLayers, nCpuMoe, selectedGpuIds, - selectedGpuIndexKind: - selectedGpuIds == null ? null : (cachedPinnableGpuIndexKind() ?? null), + selectedGpuIndexKind, ggufMemoryMode: ggufMemoryMode ?? undefined, }; }, [ @@ -76,6 +77,7 @@ export function useActiveModelConfig(): ActiveModelConfigState { gpuLayers, nCpuMoe, selectedGpuIds, + selectedGpuIndexKind, ggufMemoryMode, ]); diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index 8dcd7667c7..3edcb182e2 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -15,7 +15,6 @@ import { type PerModelConfig, normalizeMaxSeqLength, } from "./per-model-config"; -import { cachedPinnableGpuIndexKind } from "@/hooks/use-gpu-info"; function cleanTemplate(value: string | null | undefined): string | null { return value?.trim() ? value : null; @@ -33,6 +32,13 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { if (maxSeqLength !== store.params.maxSeqLength) { store.setParams({ ...store.params, maxSeqLength }); } + const selectedGpuIds = + config.selectedGpuIds !== undefined + ? reconcilePersistedGpuIds( + config.selectedGpuIds, + config.selectedGpuIndexKind, + ) + : null; useChatRuntimeStore.setState({ customContextLength: config.customContextLength ?? null, kvCacheDtype: config.kvCacheDtype ?? null, @@ -51,13 +57,11 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { gpuLayers: config.gpuLayers ?? GPU_LAYERS_AUTO, nCpuMoe: config.nCpuMoe ?? 0, splitRatio: null, - selectedGpuIds: - config.selectedGpuIds !== undefined - ? reconcilePersistedGpuIds( - config.selectedGpuIds, - config.selectedGpuIndexKind, - ) - : null, + selectedGpuIds, + selectedGpuIndexKind: + selectedGpuIds == null + ? null + : (config.selectedGpuIndexKind ?? "physical"), ggufMemoryMode: config.ggufMemoryMode ?? null, }); } @@ -91,8 +95,7 @@ export function currentRuntimePerModelConfig( gpuLayers: s.gpuLayers, nCpuMoe: s.nCpuMoe, selectedGpuIds: s.selectedGpuIds, - selectedGpuIndexKind: - s.selectedGpuIds == null ? null : (cachedPinnableGpuIndexKind() ?? null), + selectedGpuIndexKind: s.selectedGpuIndexKind, ggufMemoryMode: s.ggufMemoryMode ?? undefined, }; } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index eafc366124..cf38c8c426 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -157,6 +157,23 @@ def test_active_model_config_round_trips_gpu_fields(): assert "export function gpuFieldsSignature" in shared +def test_deferred_gpu_pick_keeps_its_index_namespace(): + """A remembered pick restored before GPU discovery must keep its namespace + until load-time reconciliation, or Vulkan IDs can be reused as physical IDs.""" + store = _read("features/chat/stores/chat-runtime-store.ts") + assert "selectedGpuIndexKind: GpuIndexKind | null;" in store + + apply = _read("features/model-picker/model-config/apply-per-model-config.ts") + assert "selectedGpuIndexKind: s.selectedGpuIndexKind" in apply + assert 'config.selectedGpuIndexKind ?? "physical"' in apply + + runtime = _read("features/chat/hooks/use-chat-model-runtime.ts") + assert "stateBeforeUnload.selectedGpuIndexKind," in runtime + + compare = _read("features/chat/shared-composer.tsx") + assert "store.selectedGpuIndexKind," in compare + + def test_gpu_picker_round_trips_requested_pool_not_fitted_subset(): """A GGUF fit may narrow [0, 1] to [0], but load/status hydration must keep [0, 1] as the editable pool so a later reload can grow back onto GPU 1.""" From d8ce6776f42d53d22ecd9b8674f812b093a66dc9 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:51:20 -0300 Subject: [PATCH 043/110] Preserve deferred GPU and memory settings --- studio/backend/core/inference/llama_cpp.py | 39 ++++++++++--------- studio/backend/routes/chat_history.py | 1 + studio/backend/routes/inference.py | 8 +--- .../tests/test_llama_cpp_memory_mode.py | 9 +++++ .../tests/test_tp_vision_regression.py | 15 +++++++ .../chat/presets/preset-load-config.ts | 12 ++++++ .../model-config/apply-per-model-config.ts | 4 +- tests/studio/test_chat_preset_load_config.py | 11 ++++++ tests/studio/test_model_picker_contracts.py | 3 +- 9 files changed, 74 insertions(+), 28 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9bcfc46cb5..9e9a033cf9 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -126,6 +126,14 @@ LLAMA_SERVER_NOT_FOUND_DETAIL = ( "then try again. (Advanced: set LLAMA_SERVER_PATH to an existing binary.)" ) +_LLAMA_MEMORY_MODE_ENV_VARS = ( + "LLAMA_ARG_LOAD_MODE", + "LLAMA_ARG_MLOCK", + "LLAMA_ARG_NO_MMAP", + "LLAMA_ARG_MMAP", + "LLAMA_ARG_DIO", +) + # llama-server can serve HTTP 200 while running a model entirely on CPU when a # GPU backend fails to init (#5807 / #5106 / #5830). Classify the startup log so @@ -2608,10 +2616,15 @@ class LlamaCppBackend: """Raw mode for status responses, preserving explicit auto.""" return self._requested_memory_mode - @property - def launched_with_inherited_mem_env(self) -> bool: - """Whether the child inherited llama.cpp memory-placement env vars.""" - return self._launched_with_inherited_mem_env + def matches_memory_mode(self, memory_mode: Optional[str]) -> bool: + """Whether the child already has the requested host-memory behavior.""" + if self.memory_mode != self._canonical_memory_mode(memory_mode): + return False + if memory_mode is not None: + return not self._launched_with_inherited_mem_env + return self._launched_with_inherited_mem_env or not any( + name in os.environ for name in _LLAMA_MEMORY_MODE_ENV_VARS + ) @property def n_layers(self) -> Optional[int]: @@ -8285,19 +8298,12 @@ class LlamaCppBackend: env.pop("LLAMA_ARG_THREADS", None) # Explicit modes scrub inherited placement env; omission preserves it. - _mm_vars = ( - "LLAMA_ARG_LOAD_MODE", - "LLAMA_ARG_MLOCK", - "LLAMA_ARG_NO_MMAP", - "LLAMA_ARG_MMAP", - "LLAMA_ARG_DIO", - ) if memory_mode is not None: - for _mm_var in _mm_vars: + for _mm_var in _LLAMA_MEMORY_MODE_ENV_VARS: env.pop(_mm_var, None) _child_inherited_mem_env = False else: - _child_inherited_mem_env = any(_v in env for _v in _mm_vars) + _child_inherited_mem_env = any(_v in env for _v in _LLAMA_MEMORY_MODE_ENV_VARS) # Reconcile the inherited LLAMA_ARG_* env with Unsloth's final # decision: stripping CLI extras on a tensor->layer downgrade @@ -9321,12 +9327,7 @@ class LlamaCppBackend: return False # GGUF host-memory placement mode is first-class; a change must reload (#7164). - if self.memory_mode != LlamaCppBackend._canonical_memory_mode(memory_mode): - return False - # An explicit memory_mode (incl. 'auto') over a child carrying inherited LLAMA_ARG_* - # flags must reload so the scrub runs; the canonical check above treats 'auto' as - # omitted and would otherwise leave the child mlocked/no-mmap (#7164). - if memory_mode is not None and self._launched_with_inherited_mem_env: + if not self.matches_memory_mode(memory_mode): return False # Compare on the canonical requested mode. With --spec-type in diff --git a/studio/backend/routes/chat_history.py b/studio/backend/routes/chat_history.py index 6a0d49b47d..9dd7260174 100644 --- a/studio/backend/routes/chat_history.py +++ b/studio/backend/routes/chat_history.py @@ -172,6 +172,7 @@ class ChatPresetLoadConfig(BaseModel): gpuMemoryMode: Optional[Literal["manual"]] = None gpuLayers: Optional[int] = None nCpuMoe: Optional[int] = Field(default = None, ge = 0) + ggufMemoryMode: Optional[Literal["auto", "pinned", "resident"]] = None class ChatPreset(BaseModel): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 0fad75cd60..14cf97ffec 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3267,13 +3267,7 @@ def _request_matches_loaded_settings( _loaded_gpu_ids = llama_backend.requested_gpu_ids if _req_gpu_ids != _loaded_gpu_ids: return False - if ( - LlamaCppBackend._canonical_memory_mode(request.gguf_memory_mode) - != llama_backend.memory_mode - ): - return False - # Explicit auto must reload a child that inherited placement env vars. - if request.gguf_memory_mode is not None and llama_backend.launched_with_inherited_mem_env: + if not llama_backend.matches_memory_mode(request.gguf_memory_mode): return False # Preserved tensor->layer fallback (both report tensor=off, so the check above # matches): if the user now explicitly drops tensor intent, reload so placement diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 60ce87d203..af0275e290 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -780,6 +780,15 @@ def test_explicit_auto_matches_scrubbed_child(): assert backend._already_in_target_state(**kwargs) is True +def test_omitted_mode_reloads_scrubbed_child_to_reinherit_parent_env(monkeypatch): + """Default must restart an explicit-Auto child when the parent has a mode.""" + monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") + backend = _loaded_backend(_launched_with_inherited_mem_env = False) + kwargs = _base_target_state_kwargs(backend) + kwargs["memory_mode"] = None + assert backend._already_in_target_state(**kwargs) is False + + def test_memory_mode_pinned_does_not_match_none(): backend = _loaded_backend() kwargs = _base_target_state_kwargs(backend) diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py index 9898462c05..15e8c92260 100644 --- a/studio/backend/tests/test_tp_vision_regression.py +++ b/studio/backend/tests/test_tp_vision_regression.py @@ -881,6 +881,21 @@ def test_explicit_null_memory_mode_dedupes_over_passthrough_mlock(): assert inference_routes._request_matches_loaded_settings(req, backend) is True +def test_default_memory_mode_reloads_to_reinherit_parent_env(monkeypatch): + """Default cannot dedupe to an Auto child that scrubbed the parent mode.""" + from models.inference import LoadRequest + + inference_routes = _load_inference_routes_module() + monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") + req = LoadRequest(model_path = "owner/repo", gguf_memory_mode = None) + backend = _mem_loaded_backend( + memory_mode = None, + extra_args = None, + launched_with_inherited_mem_env = False, + ) + assert inference_routes._request_matches_loaded_settings(req, backend) is False + + def test_explicit_pinned_dedupes_when_flags_already_applied(): """A server loaded WITH memory_mode="pinned" already had --mlock stripped from its extras. A repeat pinned Apply re-sending --mlock must still dedupe: the request-side diff --git a/studio/frontend/src/features/chat/presets/preset-load-config.ts b/studio/frontend/src/features/chat/presets/preset-load-config.ts index 37c5071640..ff21346cf9 100644 --- a/studio/frontend/src/features/chat/presets/preset-load-config.ts +++ b/studio/frontend/src/features/chat/presets/preset-load-config.ts @@ -34,6 +34,7 @@ export type PresetLoadConfig = Pick< | "gpuMemoryMode" | "gpuLayers" | "nCpuMoe" + | "ggufMemoryMode" >; const VALID_KV_CACHE_DTYPES = new Set(KV_CACHE_DTYPES); @@ -80,6 +81,12 @@ export function normalizePresetLoadConfig( : null; const gpuMemoryMode = partial.gpuMemoryMode === "manual" ? ("manual" as const) : undefined; + const ggufMemoryMode = + partial.ggufMemoryMode === "auto" || + partial.ggufMemoryMode === "pinned" || + partial.ggufMemoryMode === "resident" + ? partial.ggufMemoryMode + : undefined; let gpuLayers: number | undefined; if (typeof partial.gpuLayers === "number" && Number.isFinite(partial.gpuLayers)) { gpuLayers = partial.gpuLayers < 0 ? GPU_LAYERS_AUTO : Math.floor(partial.gpuLayers); @@ -114,6 +121,7 @@ export function normalizePresetLoadConfig( ...(gpuMemoryMode ? { gpuMemoryMode } : {}), ...(gpuLayers !== undefined ? { gpuLayers } : {}), ...(nCpuMoe !== undefined ? { nCpuMoe } : {}), + ...(ggufMemoryMode ? { ggufMemoryMode } : {}), }; return hasPresetLoadConfig(normalized) ? normalized : undefined; @@ -163,6 +171,9 @@ export function capturePresetLoadConfig(): PresetLoadConfig | undefined { ...(snapshot.nCpuMoe != null && snapshot.nCpuMoe > 0 ? { nCpuMoe: snapshot.nCpuMoe } : {}), + ...(snapshot.ggufMemoryMode + ? { ggufMemoryMode: snapshot.ggufMemoryMode } + : {}), }; return hasPresetLoadConfig(coalesceDefaultLoadKnobs(captured)) ? coalesceDefaultLoadKnobs(captured) @@ -213,6 +224,7 @@ export function applyPresetLoadConfig( nCpuMoe: config.nCpuMoe, selectedGpuIds: store.selectedGpuIds, selectedGpuIndexKind: store.selectedGpuIndexKind, + ggufMemoryMode: config.ggufMemoryMode, }); } diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index 3edcb182e2..489a97fa95 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -61,7 +61,9 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { selectedGpuIndexKind: selectedGpuIds == null ? null - : (config.selectedGpuIndexKind ?? "physical"), + : config.selectedGpuIndexKind === undefined + ? "physical" + : config.selectedGpuIndexKind, ggufMemoryMode: config.ggufMemoryMode ?? null, }); } diff --git a/tests/studio/test_chat_preset_load_config.py b/tests/studio/test_chat_preset_load_config.py index 1588c7d96d..513eb0c62d 100644 --- a/tests/studio/test_chat_preset_load_config.py +++ b/tests/studio/test_chat_preset_load_config.py @@ -53,6 +53,17 @@ def test_apply_skips_missing_load_config(): assert "if (p.loadConfig)" in sheet +def test_host_memory_mode_round_trips_through_presets(): + source = _read("studio/frontend/src/features/chat/presets/preset-load-config.ts") + assert '| "ggufMemoryMode"' in source + assert "partial.ggufMemoryMode ===" in source + assert "snapshot.ggufMemoryMode" in source + assert "ggufMemoryMode: config.ggufMemoryMode" in source + + routes = _read("studio/backend/routes/chat_history.py") + assert 'ggufMemoryMode: Optional[Literal["auto", "pinned", "resident"]]' in routes + + def test_hydration_does_not_replay_preset_load_config(): store = _read("studio/frontend/src/features/chat/stores/chat-runtime-store.ts") assert "applyPresetLoadConfig(activeDefinition.loadConfig)" not in store diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index cf38c8c426..2fd385fcf0 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -165,7 +165,8 @@ def test_deferred_gpu_pick_keeps_its_index_namespace(): apply = _read("features/model-picker/model-config/apply-per-model-config.ts") assert "selectedGpuIndexKind: s.selectedGpuIndexKind" in apply - assert 'config.selectedGpuIndexKind ?? "physical"' in apply + assert "config.selectedGpuIndexKind === undefined" in apply + assert 'config.selectedGpuIndexKind ?? "physical"' not in apply runtime = _read("features/chat/hooks/use-chat-model-runtime.ts") assert "stateBeforeUnload.selectedGpuIndexKind," in runtime From c4726bf585b5c64df1e8659fd0a9e37b8cbc201a Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:07:05 -0300 Subject: [PATCH 044/110] Preserve host memory in compare loads --- studio/frontend/src/features/chat/shared-composer.tsx | 5 +++-- tests/studio/test_model_picker_contracts.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 47c5e21d97..9bb4c04f8a 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -995,6 +995,7 @@ export function SharedComposer({ gpuLayers: store.gpuLayers, nCpuMoe: store.nCpuMoe, splitRatio: store.splitRatio, + ggufMemoryMode: store.ggufMemoryMode, // Reconcile the pick against the GPUs present now, like the model-switch // path: an early remember-restore can hold a stale cross-host pick that // /load would reject (the device cache is populated by send time). @@ -1002,7 +1003,6 @@ export function SharedComposer({ store.selectedGpuIds, store.selectedGpuIndexKind, ), - customContextLength: store.customContextLength, }; // Set when an accepted transformers install unloaded the active model // server-side; a later failure must then clear the stale checkpoint. @@ -1065,7 +1065,8 @@ export function SharedComposer({ ownConfig.selectedGpuIndexKind, ) : compareLoadKnobs.selectedGpuIds; - const effectiveMemoryMode = ownConfig.ggufMemoryMode ?? null; + const effectiveMemoryMode = + ownConfig.ggufMemoryMode ?? compareLoadKnobs.ggufMemoryMode; // A pane's context comes from its own config only: a saved pin, or null // (Auto/native). It must not inherit the active model's shared snapshot -- // resolveFitMaxSeqLength would treat that as a pin and load this pane at diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 2fd385fcf0..d420718d51 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -194,6 +194,7 @@ def test_compare_load_uses_each_models_gpu_config(): assert "ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode" in src assert "ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers" in src assert "ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe" in src + assert "ownConfig.ggufMemoryMode ?? compareLoadKnobs.ggufMemoryMode" in src assert "if (ownConfig.selectedGpuIds != null)" in src assert "ownConfig.selectedGpuIndexKind," in src for field in ( From 0e9330216b4b0aebc43c47076cccbab23da187b6 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:32:52 -0300 Subject: [PATCH 045/110] Handle DiffusionGemma memory settings --- studio/backend/core/inference/llama_cpp.py | 4 - studio/backend/models/inference.py | 3 + studio/backend/routes/inference.py | 1 + .../tests/test_chat_load_during_training.py | 52 ++++++++++++ studio/backend/tests/test_gpu_memory_mode.py | 11 +-- .../tests/test_llama_cpp_memory_mode.py | 1 + .../src/features/chat/api/chat-api.ts | 9 +- .../frontend/src/features/chat/chat-page.tsx | 4 + .../frontend/src/features/chat/types/api.ts | 1 + .../hub/catalog/sampling-settings-dialog.tsx | 4 + .../components/model-config-page.tsx | 83 +++++++++++++++---- .../components/sidebar-model-config.tsx | 3 + tests/studio/test_model_picker_contracts.py | 19 ++++- 13 files changed, 165 insertions(+), 30 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 9e9a033cf9..25059ea588 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2875,7 +2875,6 @@ class LlamaCppBackend: "spec_draft_n_max_flag": None, "supports_kv_unified": False, "supports_fit_ctx": False, - "supports_fit_target": False, "supports_load_mode": False, "supports_cache_ram": False, "supports_ctx_checkpoints": False, @@ -2897,7 +2896,6 @@ class LlamaCppBackend: spec_draft_n_max_flag: Optional[str] = None supports_kv_unified = False supports_fit_ctx = False - supports_fit_target = False supports_load_mode = False supports_cache_ram = False supports_ctx_checkpoints = False @@ -3003,7 +3001,6 @@ class LlamaCppBackend: supports_kv_unified = _is_real("--kv-unified") supports_fit_ctx = _is_real("--fit-ctx") - supports_fit_target = _is_real("--fit-target") supports_load_mode = _is_real("--load-mode") supports_cache_ram = _is_real("--cache-ram") supports_ctx_checkpoints = _is_real("--ctx-checkpoints") @@ -3040,7 +3037,6 @@ class LlamaCppBackend: "spec_draft_n_max_flag": spec_draft_n_max_flag, "supports_kv_unified": supports_kv_unified, "supports_fit_ctx": supports_fit_ctx, - "supports_fit_target": supports_fit_target, "supports_load_mode": supports_load_mode, "supports_cache_ram": supports_cache_ram, "supports_ctx_checkpoints": supports_ctx_checkpoints, diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 49423d9472..05847d11fc 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -336,6 +336,9 @@ class ValidateModelResponse(BaseModel): identifier: Optional[str] = Field(None, description = "Resolved model identifier") display_name: Optional[str] = Field(None, description = "Display name derived from identifier") is_gguf: bool = Field(False, description = "Whether this is a GGUF model (llama.cpp)") + is_diffusion: bool = Field( + False, description = "Whether this is a block-diffusion model (DiffusionGemma)" + ) is_lora: bool = Field(False, description = "Whether this is a LoRA adapter") is_vision: bool = Field(False, description = "Whether this is a vision-capable model") requires_trust_remote_code: bool = Field( diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 14cf97ffec..542d0cf29d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -5378,6 +5378,7 @@ async def validate_model( if native_grant_backed else getattr(config, "display_name", config.identifier), is_gguf = is_gguf, + is_diffusion = is_gguf and _classify_diffusion_gguf(config) is True, is_lora = getattr(config, "is_lora", False), is_vision = getattr(config, "is_vision", False), requires_trust_remote_code = requires_trust_remote_code, diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 129023ffcd..82c4b8b650 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -943,6 +943,43 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): asyncio.run(self.route.validate_model(request, current_subject = "u")) self.assertEqual(guard_called, []) + def test_metadata_probe_reports_diffusion(self): + from models.inference import ValidateModelRequest + + request = ValidateModelRequest( + model_path = "unsloth/DiffusionGemma-GGUF", + include_context_length = True, + ) + cfg = SimpleNamespace( + identifier = "unsloth/DiffusionGemma-GGUF", + display_name = "DiffusionGemma-GGUF", + is_gguf = True, + is_lora = False, + is_vision = False, + gguf_file = None, + gguf_hf_repo = "unsloth/DiffusionGemma-GGUF", + gguf_variant = None, + path = None, + base_model = None, + ) + with ( + patch.object( + self.route, + "_resolve_model_identifier_for_request", + return_value = ( + "unsloth/DiffusionGemma-GGUF", + "unsloth/DiffusionGemma-GGUF", + False, + ), + ), + patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), + patch.object(self.route, "load_inference_config", return_value = {}), + ): + response = asyncio.run( + self.route.validate_model(request, current_subject = "u") + ) + self.assertTrue(response.is_diffusion) + def test_gguf_validate_rejects_inherited_draft_device_before_unload(self): # Validate inherited draft placement before the client unloads. from models.inference import ValidateModelRequest @@ -975,6 +1012,11 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): patch.object(self.route, "load_inference_config", return_value = {}), patch.object(self.route, "_requires_trust_remote_code_for_model", return_value = False), patch.object(self.route, "get_llama_cpp_backend", return_value = loaded), + patch.object( + self.route, + "_resolve_gguf_gpu_ids_for_request", + return_value = ([0], False), + ), _stub_guard_deps(training_active = True, decision = (True, {}), captured = captured), ): with self.assertRaises(HTTPException) as ctx: @@ -1219,6 +1261,11 @@ class TestLoadModelGuardIntegration(unittest.TestCase): ), patch.object(self.route, "resolve_effective_chat_template_override", return_value = None), patch.object(self.route, "_reject_diffusion_memory_mode", return_value = None), + patch.object( + self.route, + "_resolve_gguf_gpu_ids_for_request", + return_value = ([0], False), + ), patch.object(self.route, "get_inference_backend", return_value = inf), patch.object(self.route, "get_llama_cpp_backend", return_value = llama), patch.object(self.route, "_hf_offline_if_dns_dead", lambda: contextlib.nullcontext()), @@ -1281,6 +1328,11 @@ class TestLoadModelGuardIntegration(unittest.TestCase): patch.object(self.route, "resolve_effective_chat_template_override", return_value = None), patch.object(self.route, "_reject_diffusion_memory_mode", return_value = None), patch.object(self.route, "_request_matches_loaded_settings", return_value = False), + patch.object( + self.route, + "_resolve_gguf_gpu_ids_for_request", + return_value = ([0], False), + ), patch.object(self.route, "get_inference_backend", return_value = inf), patch.object(self.route, "get_llama_cpp_backend", return_value = llama), patch.object(self.route, "_hf_offline_if_dns_dead", lambda: contextlib.nullcontext()), diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index 9910c0dfd5..f56c83d9d9 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -566,17 +566,12 @@ def test_manual_allows_tensor_parallel_via_split_mode(): def test_fit_keeps_upstream_target_margin(): # Manual + Auto must keep llama.cpp's default fit target. Reducing its # headroom is especially risky on integrated GPUs that share system RAM. - caps = {"supports_fit_target": True} - flags = LlamaCppBackend._ctx_integrity_flags(1, True, True, 0, 0, caps) + flags = LlamaCppBackend._ctx_integrity_flags(1, True, True, 0, 0, {}) assert "--fit-target" not in flags # Not emitted on the legacy auto path (fit on but not auto_fit). - assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, True, False, 0, 0, caps) + assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, True, False, 0, 0, {}) # Not emitted when fit is off. - assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, False, False, 0, 0, caps) - # Not emitted when the binary lacks support. - assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags( - 1, True, True, 0, 0, {"supports_fit_target": False} - ) + assert "--fit-target" not in LlamaCppBackend._ctx_integrity_flags(1, False, False, 0, 0, {}) # ── GPU picker (gpu_ids -> CUDA_VISIBLE_DEVICES) ───────────────────── diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index af0275e290..a41b940063 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -928,6 +928,7 @@ def test_remote_diffusion_load_rejects_vulkan_ordinal_after_download(tmp_path): backend = LlamaCppBackend() backend._find_llama_server_binary = lambda include_denied = False: "/fake/llama-server" backend._is_vulkan_backend = lambda _binary = None: True + backend._get_gpu_memory = lambda _binary = None: [(1, 8 * 1024**3, 8 * 1024**3)] backend._download_gguf = lambda **_kwargs: str(gguf) backend._read_gguf_metadata = lambda _path: setattr(backend, "_is_diffusion", True) backend._start_diffusion_server = lambda **_kwargs: pytest.fail( diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 180af96398..4c32da138a 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -193,6 +193,7 @@ export async function fetchGgufStagedMetadata(payload: { contextLength: number | null; layerCount: number | null; moeLayerCount: number | null; + isDiffusion: boolean; }> { let nativePathLease: string | null = null; if (payload.nativePathToken) { @@ -202,7 +203,12 @@ export async function fetchGgufStagedMetadata(payload: { ).nativePathLease; } catch { // Lease expired / revoked: degrade to no metadata (the load can re-mint). - return { contextLength: null, layerCount: null, moeLayerCount: null }; + return { + contextLength: null, + layerCount: null, + moeLayerCount: null, + isDiffusion: false, + }; } } const response = await authFetch("/api/inference/validate", { @@ -221,6 +227,7 @@ export async function fetchGgufStagedMetadata(payload: { contextLength: res.context_length ?? null, layerCount: res.layer_count ?? null, moeLayerCount: res.moe_layer_count ?? null, + isDiffusion: res.is_diffusion ?? false, }; } diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 7452cf3447..976fc84f80 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -1932,6 +1932,9 @@ export function ChatPage({ } = useActiveModelConfig(); const activeModelIsGguf = runtimeCheckpoint != null && !isExternalModel && runtimeModelIsGguf; + const activeModelIsDiffusion = useChatRuntimeStore( + (s) => s.loadedIsDiffusion, + ); const activeModelIsLora = useMemo(() => { const checkpoint = inferenceParams.checkpoint; if (!checkpoint || isExternalModel) return false; @@ -3368,6 +3371,7 @@ export function ChatPage({ modelId={inferenceParams.checkpoint} ggufVariant={activeGgufVariant ?? null} isGguf={activeModelIsGguf} + isDiffusion={activeModelIsDiffusion} nativeContextLength={ggufNativeContextLength} loadedContextLength={ggufContextLength} loadedConfig={activeModelConfig} diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 7f96539c34..7f7890d325 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -89,6 +89,7 @@ export interface ValidateModelResponse { identifier?: string | null; display_name?: string | null; is_gguf?: boolean; + is_diffusion?: boolean; is_lora?: boolean; is_vision?: boolean; requires_trust_remote_code?: boolean; diff --git a/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx b/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx index bc86a6302c..9b63b21f03 100644 --- a/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx +++ b/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx @@ -103,6 +103,9 @@ export function SamplingSettingsButton({ className }: { className?: string }) { const { selectModel } = useChatModelRuntime(); const modelLoading = useChatRuntimeStore((s) => s.modelLoading); const activeGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant); + const activeModelIsDiffusion = useChatRuntimeStore( + (s) => s.loadedIsDiffusion, + ); const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); const ggufNativeContextLength = useChatRuntimeStore( (s) => s.ggufNativeContextLength, @@ -235,6 +238,7 @@ export function SamplingSettingsButton({ className }: { className?: string }) { modelId={checkpoint} ggufVariant={activeGgufVariant ?? null} isGguf={activeModelIsGguf} + isDiffusion={activeModelIsDiffusion} nativeContextLength={ggufNativeContextLength} loadedContextLength={ggufContextLength} loadedConfig={activeModelConfig} diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 653caa0d35..a9b12bbbeb 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -88,6 +88,26 @@ function hasNonDefaultAdvanced(config: PerModelConfig): boolean { ); } +function withoutUnsupportedDiffusionMemory( + config: PerModelConfig, +): PerModelConfig { + if ( + (config.gpuMemoryMode ?? "auto") === "auto" && + config.gpuLayers == null && + config.nCpuMoe == null && + config.ggufMemoryMode == null + ) { + return config; + } + return { + ...config, + gpuMemoryMode: "auto", + gpuLayers: undefined, + nCpuMoe: undefined, + ggufMemoryMode: undefined, + }; +} + function ChatTemplateSetting({ config, onEditTemplate, @@ -233,11 +253,13 @@ function GpuMemorySettings({ update, layerCount, moeLayerCount, + isDiffusion, }: { config: PerModelConfig; update: (patch: Partial) => void; layerCount: number | null; moeLayerCount: number | null; + isDiffusion: boolean; }) { const gpuDevices = useGpuDevices(); const mode = config.gpuMemoryMode ?? "auto"; @@ -282,7 +304,7 @@ function GpuMemorySettings({ }; return ( <> -
+
GPU Memory @@ -302,9 +324,7 @@ function GpuMemorySettings({
- {isManual && ( + {!isDiffusion && isManual && ( <>
)} -
+
Host Memory @@ -450,6 +470,7 @@ function GgufAdvancedSettings({ onEditTemplate, layerCount, moeLayerCount, + isDiffusion, }: { config: PerModelConfig; update: (patch: Partial) => void; @@ -458,6 +479,7 @@ function GgufAdvancedSettings({ onEditTemplate: () => void; layerCount: number | null; moeLayerCount: number | null; + isDiffusion: boolean; }) { return ( <> @@ -586,6 +608,7 @@ function GgufAdvancedSettings({ update={update} layerCount={layerCount} moeLayerCount={moeLayerCount} + isDiffusion={isDiffusion} /> @@ -600,6 +623,7 @@ interface ModelConfigPageProps { loadedConfig?: PerModelConfig | null; loadedContextLength?: number | null; initialConfig?: PerModelConfig | null; + isDiffusion?: boolean; variant?: "page" | "sidebar"; } @@ -610,6 +634,7 @@ export function ModelConfigPage({ loadedConfig = null, loadedContextLength = null, initialConfig = null, + isDiffusion = false, variant = "page", }: ModelConfigPageProps) { const rememberId = useId(); @@ -640,7 +665,11 @@ export function ModelConfigPage({ return resolved; }; const [initial] = useState(resolveInitial); - const [config, setConfig] = useState(() => initial.config); + const [config, setConfig] = useState(() => + isDiffusion + ? withoutUnsupportedDiffusionMemory(initial.config) + : initial.config, + ); const [remember, setRemember] = useState(() => initial.remembered); const [savedRemember, setSavedRemember] = useState(() => initial.remembered); const [speculativeFallback] = useState(readPersistedSpeculativeType); @@ -683,6 +712,7 @@ export function ModelConfigPage({ contextLength: number | null; layerCount: number | null; moeLayerCount: number | null; + isDiffusion: boolean; } | null>(null); useEffect(() => { if (contextFetchKey == null) { @@ -698,6 +728,9 @@ export function ModelConfigPage({ .then((dims) => { if (!cancelled) { setFetchedStagedDims({ key: contextFetchKey, ...dims }); + if (dims.isDiffusion) { + setConfig(withoutUnsupportedDiffusionMemory); + } } }) .catch(() => { @@ -707,6 +740,7 @@ export function ModelConfigPage({ contextLength: null, layerCount: null, moeLayerCount: null, + isDiffusion: false, }); } }); @@ -722,6 +756,13 @@ export function ModelConfigPage({ ]); const stagedDims = fetchedStagedDims?.key === contextFetchKey ? fetchedStagedDims : null; + const stagedMetadataPending = + contextFetchKey != null && + stagedDims == null && + (config.gpuMemoryMode === "manual" || + config.ggufMemoryMode != null); + const resolvedIsDiffusion = + isDiffusion || stagedDims?.isDiffusion === true; const isMtp = config.speculativeType != null && @@ -751,7 +792,10 @@ export function ModelConfigPage({ ); const setContextLength = (v: number) => update({ customContextLength: v }); - const baseline = loadedConfig ?? DEFAULT_PER_MODEL_CONFIG; + const rawBaseline = loadedConfig ?? DEFAULT_PER_MODEL_CONFIG; + const baseline = resolvedIsDiffusion + ? withoutUnsupportedDiffusionMemory(rawBaseline) + : rawBaseline; const atBaseline = perModelConfigsEqual(config, baseline); // An explicit customContextLength equal to the native ceiling is still an // override (Reset stays enabled). "At default" means no override at all AND the @@ -780,21 +824,24 @@ export function ModelConfigPage({ // customContextLength stays null. If the user fixes GPU Layers (Manual) and // remembers, pin that shown context so a later fresh load keeps the fitted // placement instead of sending native/0 for fixed layers and recreating the OOM. + const loadableConfig = resolvedIsDiffusion + ? withoutUnsupportedDiffusionMemory(config) + : config; const pinFixedLayerContext = target.isGguf && - config.gpuMemoryMode === "manual" && - config.gpuLayers != null && - config.gpuLayers >= 0 && - config.customContextLength == null && + loadableConfig.gpuMemoryMode === "manual" && + loadableConfig.gpuLayers != null && + loadableConfig.gpuLayers >= 0 && + loadableConfig.customContextLength == null && activeLoadedContext != null; // Persisted record: keep config as-is (non-GGUF keeps maxSeqLength null) so // isDefaultConfig recognises it and clears a remembered override instead of // pinning the app default. const runtimeConfig = target.isGguf ? pinFixedLayerContext - ? { ...config, customContextLength: activeLoadedContext } - : config - : config; + ? { ...loadableConfig, customContextLength: activeLoadedContext } + : loadableConfig + : loadableConfig; // Load request needs a concrete max length; substitute the fallback here only, // never in the persisted runtimeConfig. const loadConfig = target.isGguf @@ -937,6 +984,7 @@ export function ModelConfigPage({ onEditTemplate={() => setTemplateOpen(true)} layerCount={stagedDims?.layerCount ?? null} moeLayerCount={stagedDims?.moeLayerCount ?? null} + isDiffusion={resolvedIsDiffusion} /> )} @@ -1021,7 +1069,10 @@ export function ModelConfigPage({ type="button" size="sm" className="h-8" - disabled={isActiveModel && atBaseline && !rememberChanged} + disabled={ + stagedMetadataPending || + (isActiveModel && atBaseline && !rememberChanged) + } onClick={handleRun} > {primaryActionLabel} diff --git a/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx index 2d12c503a4..52959d7497 100644 --- a/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx +++ b/studio/frontend/src/features/model-picker/components/sidebar-model-config.tsx @@ -11,6 +11,7 @@ interface SidebarModelConfigProps { modelId: string; ggufVariant: string | null; isGguf: boolean; + isDiffusion: boolean; nativeContextLength: number | null; loadedContextLength: number | null; loadedConfig: PerModelConfig; @@ -55,6 +56,7 @@ export function SidebarModelConfig({ modelId, ggufVariant, isGguf, + isDiffusion, nativeContextLength, loadedContextLength, loadedConfig, @@ -86,6 +88,7 @@ export function SidebarModelConfig({ loadedConfig={loadedConfig} loadedContextLength={loadedContextLength} variant="sidebar" + isDiffusion={isDiffusion} /> ); } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index d420718d51..0444c744c2 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -368,7 +368,7 @@ def test_fixed_layer_gguf_pins_displayed_context(): instead of sending native/0 and recreating the OOM.""" src = _read("features/model-picker/components/model-config-page.tsx") assert "const pinFixedLayerContext =" in src - assert 'config.gpuMemoryMode === "manual"' in src + assert 'loadableConfig.gpuMemoryMode === "manual"' in src assert "customContextLength: activeLoadedContext" in src @@ -502,6 +502,23 @@ def test_model_switch_clears_host_memory_mode_before_validation(): assert "gguf_memory_mode: validateMemoryMode" in src +def test_diffusion_picker_hides_and_clears_unsupported_memory_modes(): + api = _read("features/chat/api/chat-api.ts") + assert "isDiffusion: res.is_diffusion ?? false" in api + + page = _read("features/model-picker/components/model-config-page.tsx") + assert 'className={isDiffusion ? "hidden" : ROW_CLASS}' in page + assert "withoutUnsupportedDiffusionMemory(config)" in page + assert "stagedMetadataPending ||" in page + for field in ( + 'gpuMemoryMode: "auto"', + "gpuLayers: undefined", + "nCpuMoe: undefined", + "ggufMemoryMode: undefined", + ): + assert field in page + + def test_legacy_migration_is_idempotent_and_non_destructive(): """The v1->v2 localStorage migration (unsloth_load_settings -> unsloth_model_configs) is invoked on every store read, so it must be From 034e5007db4d30ed8c476419ee6a0e6a69c0be66 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:57:17 -0300 Subject: [PATCH 046/110] Use semantic preset summary font size From d62e908f7b7de1416b8ae4cab0accac9603d89f6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:01:17 +0000 Subject: [PATCH 047/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_chat_load_during_training.py | 84 +++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 82c4b8b650..35914c3d84 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -239,7 +239,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): required_override = 20.0, single_device_gpu = "0", gpu_ids = [0], - is_vulkan = True, + gpu_ids_are_vulkan_ordinals = True, ) self.assertFalse(ok) self.assertEqual(info["mode"], "gguf_vulkan") @@ -251,7 +251,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): devices = _devices((0, 80, 70), (1, 80, 70), (2, 80, 0)), required_override = 10.0, gpu_ids = [0, 1], - is_vulkan = True, + gpu_ids_are_vulkan_ordinals = True, ) self.assertTrue(ok) self.assertEqual(info["mode"], "gguf_vulkan") @@ -975,9 +975,7 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), patch.object(self.route, "load_inference_config", return_value = {}), ): - response = asyncio.run( - self.route.validate_model(request, current_subject = "u") - ) + response = asyncio.run(self.route.validate_model(request, current_subject = "u")) self.assertTrue(response.is_diffusion) def test_gguf_validate_rejects_inherited_draft_device_before_unload(self): @@ -993,6 +991,7 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): is_vision = False, path = None, base_model = None, + gguf_variant = None, ) loaded = SimpleNamespace( is_loaded = True, @@ -1025,6 +1024,80 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): self.assertIn("draft-model device", ctx.exception.detail) self.assertEqual(captured, []) + def _validate_gguf_template( + self, + *, + template, + canonical_path = "/picked/model.gguf", + ): + # Drive validate_model for a native lease-backed GGUF template probe and + # capture what the embedded-template reader was called with. + from models.inference import ValidateModelRequest + + request = ValidateModelRequest( + model_path = "model.gguf", + gguf_variant = "Q4_K_M", + native_path_lease = "signed-lease", + include_chat_template = True, + ) + cfg = SimpleNamespace( + identifier = canonical_path, + display_name = "model.gguf", + is_gguf = True, + is_lora = False, + is_vision = False, + gguf_file = canonical_path, + path = None, + base_model = None, + ) + import utils.models.gguf_metadata as gguf_meta + + seen = {} + + def _fake_read(path): + seen["path"] = path + return template + + guard_called = [] + with ( + patch.object( + self.route, + "_resolve_model_identifier_for_request", + return_value = (canonical_path, "model.gguf", True), + ), + patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), + patch.object(self.route, "load_inference_config", return_value = {}), + patch.object(gguf_meta, "read_gguf_chat_template", _fake_read), + patch.object( + self.route, + "_guard_chat_load_against_training", + lambda *a, **kw: guard_called.append(True), + ), + ): + resp = asyncio.run(self.route.validate_model(request, current_subject = "u")) + return resp, seen, guard_called + + def test_include_chat_template_reads_leased_gguf_embedded_template(self): + # The picker chat-template GET has no lease plumbing, so a native picked + # GGUF surfaces its default template through this lease-aware probe: the + # embedded template is read from the granted canonical path and returned. + resp, seen, _ = self._validate_gguf_template(template = "{{ messages }}") + self.assertEqual(resp.chat_template, "{{ messages }}") + # Read strictly the leased file's own embedded template, never a sibling + # sidecar: the grant authorizes just this one path. + self.assertEqual(seen["path"], "/picked/model.gguf") + + def test_include_chat_template_skips_training_guard(self): + # A template-only probe allocates no VRAM, so like include_context_length + # it must not be refused by the training guard. + _, _, guard_called = self._validate_gguf_template(template = "{{ messages }}") + self.assertEqual(guard_called, []) + + def test_include_chat_template_over_cap_is_dropped(self): + from picker.schemas import MAX_CHAT_TEMPLATE_BYTES + resp, _, _ = self._validate_gguf_template(template = "a" * (MAX_CHAT_TEMPLATE_BYTES + 1)) + self.assertIsNone(resp.chat_template) + # ── _estimate_gguf_required_gb (sizes the same weights the loader loads) ────── @@ -1310,6 +1383,7 @@ class TestLoadModelGuardIntegration(unittest.TestCase): base_model = None, identifier = "x.gguf", display_name = "x", + gguf_variant = None, ) request = LoadRequest(model_path = "x.gguf", gpu_ids = [0], max_seq_length = 4096) captured = [] From d81dfb3c27979ed21e63b6f2621eddc201f1377d Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:13:20 -0300 Subject: [PATCH 048/110] Enforce host memory environment overrides --- studio/backend/core/inference/llama_cpp.py | 51 +++++++----- studio/backend/models/inference.py | 23 +++--- studio/backend/routes/inference.py | 5 +- .../tests/test_inference_model_validation.py | 3 +- .../tests/test_llama_cpp_memory_mode.py | 82 ++++++++++++++----- .../tests/test_tp_vision_regression.py | 6 +- .../components/model-config-page.tsx | 19 ++--- tests/studio/test_model_picker_contracts.py | 10 +++ 8 files changed, 126 insertions(+), 73 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 25059ea588..695cfed680 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2618,13 +2618,13 @@ class LlamaCppBackend: def matches_memory_mode(self, memory_mode: Optional[str]) -> bool: """Whether the child already has the requested host-memory behavior.""" + if self._memory_mode_env_override(): + return self._launched_with_inherited_mem_env + if self._launched_with_inherited_mem_env: + return False if self.memory_mode != self._canonical_memory_mode(memory_mode): return False - if memory_mode is not None: - return not self._launched_with_inherited_mem_env - return self._launched_with_inherited_mem_env or not any( - name in os.environ for name in _LLAMA_MEMORY_MODE_ENV_VARS - ) + return True @property def n_layers(self) -> Optional[int]: @@ -6414,6 +6414,14 @@ class LlamaCppBackend: return None return mode + @staticmethod + def _memory_mode_env_override( + env: Optional[Mapping[str, str]] = None, + ) -> bool: + """Whether the operator environment owns host-memory placement.""" + source = os.environ if env is None else env + return any(name in source for name in _LLAMA_MEMORY_MODE_ENV_VARS) + @staticmethod def _memory_mode_flags( memory_mode: Optional[str], *, supports_load_mode: bool = False @@ -6478,8 +6486,10 @@ class LlamaCppBackend: Returns True if the server started and the health check passed. """ - # An explicit mode owns all memory-placement flags, including auto. - if memory_mode is not None and extra_args: + # LLAMA_ARG_* host-memory settings are operator overrides. When present, + # they also own raw pass-through flags so no request can shadow them. + _memory_env_override = self._memory_mode_env_override() + if (memory_mode is not None or _memory_env_override) and extra_args: extra_args = strip_shadowing_flags( extra_args, strip_context = False, @@ -6597,7 +6607,9 @@ class LlamaCppBackend: # Classify remote loads before teardown when diffusion would change # the meaning or validity of requested placement. _preflight_model_path = None - _preflight_memory_mode = self._canonical_memory_mode(memory_mode) + _preflight_memory_mode = self._canonical_memory_mode( + None if _memory_env_override else memory_mode + ) if hf_repo and ( _vulkan_ordinal_pin or _cpu_only_pin or _preflight_memory_mode is not None ): @@ -6622,7 +6634,7 @@ class LlamaCppBackend: if _preflight_memory_mode is not None: raise ValueError( "GGUF host-memory modes are not supported for " - "DiffusionGemma models. Use Auto." + "DiffusionGemma models. Use memory-mapped loading." ) if _vulkan_ordinal_pin: raise ValueError( @@ -6714,10 +6726,13 @@ class LlamaCppBackend: # Block-diffusion GGUFs (DiffusionGemma) cannot run on llama-server; # serve them with the diffusion runner (same OpenAI-compat interface). if self._is_diffusion: - if self._canonical_memory_mode(memory_mode) is not None: + if ( + not _memory_env_override + and self._canonical_memory_mode(memory_mode) is not None + ): raise ValueError( "GGUF host-memory modes are not supported for " - "DiffusionGemma models. Use Auto." + "DiffusionGemma models. Use memory-mapped loading." ) # The diffusion runner cannot translate Vulkan ordinals to CUDA IDs. if is_vulkan_backend and gpu_ids and gpu_ids_are_vulkan_ordinals is not False: @@ -7914,10 +7929,10 @@ class LlamaCppBackend: server_caps = self.probe_server_capabilities(binary) - # Emit host-memory policy directly so manual env cleanup cannot remove it. + # Emit the requested policy unless the operator environment owns it. cmd.extend( self._memory_mode_flags( - memory_mode, + None if _memory_env_override else memory_mode, supports_load_mode = bool(server_caps.get("supports_load_mode")), ) ) @@ -8293,13 +8308,9 @@ class LlamaCppBackend: if "--threads" not in cmd: env.pop("LLAMA_ARG_THREADS", None) - # Explicit modes scrub inherited placement env; omission preserves it. - if memory_mode is not None: - for _mm_var in _LLAMA_MEMORY_MODE_ENV_VARS: - env.pop(_mm_var, None) - _child_inherited_mem_env = False - else: - _child_inherited_mem_env = any(_v in env for _v in _LLAMA_MEMORY_MODE_ENV_VARS) + # Host-memory environment variables are authoritative over UI/API + # choices and raw llama-server arguments. + _child_inherited_mem_env = self._memory_mode_env_override(env) # Reconcile the inherited LLAMA_ARG_* env with Unsloth's final # decision: stripping CLI extras on a tensor->layer downgrade diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 05847d11fc..3abcd81656 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -191,20 +191,19 @@ class LoadRequest(BaseModel): "GGUF host-memory placement mode (llama.cpp --mlock/--no-mmap). These " "control system RAM residency and file mapping on the host, NOT GPU VRAM " "placement, so they do not by themselves keep offloaded weights pinned in " - "VRAM. Omit the field to preserve inherited llama.cpp settings. 'auto' " - "explicitly restores normal memory-mapped loading. 'pinned' locks mapped " - "host pages so the OS cannot page them out. 'resident' loads a RAM copy " - "instead of mapping the file; on newer llama.cpp builds that copy cannot " - "also be locked and may still be swapped. Ignored for non-GGUF models." + "VRAM. Omit the field or use 'auto' for normal memory-mapped loading. " + "'pinned' locks mapped host pages so the OS cannot page them out. " + "'resident' loads a RAM copy instead of mapping the file; on newer " + "llama.cpp builds that copy cannot also be locked and may still be " + "swapped. LLAMA_ARG_* host-memory environment variables are authoritative. " + "Ignored for non-GGUF models." ), ) @field_validator("gguf_memory_mode", mode = "before") @classmethod def normalize_blank_gguf_memory_mode(cls, value: Any) -> Any: - # Map a form's blank default to explicit "auto" (not None) so it counts as a - # choice: the scrub of inherited LLAMA_ARG_MLOCK/NO_MMAP/MMAP only runs when the - # value is not None, so blank -> None would let those env vars survive (#7164). + # A blank form value means normal memory-mapped loading. if isinstance(value, str) and value.strip() == "": return "auto" return value @@ -278,14 +277,16 @@ class ValidateModelRequest(BaseModel): ) gguf_memory_mode: Optional[GgufMemoryMode] = Field( None, - description = "Intended GGUF memory placement mode; mirrors /load so validate's sizing agrees with the follow-up load.", + description = ( + "Intended GGUF host-memory placement mode; mirrors /load. " + "LLAMA_ARG_* host-memory environment variables are authoritative." + ), ) @field_validator("gguf_memory_mode", mode = "before") @classmethod def normalize_blank_gguf_memory_mode(cls, value: Any) -> Any: - # Mirror LoadRequest: blank maps to explicit "auto" so validate and load agree - # and the inherited-env scrub isn't skipped (and it avoids a 422) (#7164). + # Mirror LoadRequest and avoid a 422 for blank form values. if isinstance(value, str) and value.strip() == "": return "auto" return value diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 542d0cf29d..d5de296d83 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3329,7 +3329,7 @@ def _request_matches_loaded_settings( return False else: # Compare against the managed flags that load_model actually persisted. - # Keep backend memory flags so explicit auto can detect inherited placement. + # Keep backend memory flags so an explicit mode can replace raw argv. _strip_dev = bool(request.gpu_ids) _request_extra = ( strip_shadowing_flags( @@ -4268,7 +4268,8 @@ def _resolve_inherited_extra_args( or effective_chat_template_override is not None ), strip_split_mode = _should_strip_split_mode(request, stored_args), - # A non-null mode owns inherited memory flags. + # A non-null first-class mode owns prior raw argv. Operator + # LLAMA_ARG_* values remain authoritative in load_model. strip_memory_mode = getattr(request, "gguf_memory_mode", None) is not None, # manual + per-GPU ratio emits its own --tensor-split; drop # an inherited one (appended last would override it) while diff --git a/studio/backend/tests/test_inference_model_validation.py b/studio/backend/tests/test_inference_model_validation.py index 3717693eef..3c9dffe0e5 100644 --- a/studio/backend/tests/test_inference_model_validation.py +++ b/studio/backend/tests/test_inference_model_validation.py @@ -44,8 +44,7 @@ from models.inference import ValidateModelRequest # noqa: E402 @pytest.mark.parametrize("blank", ["", " ", "\n\t"]) def test_blank_gguf_memory_mode_normalizes_to_auto(blank): - # A form may serialize the default placement as ""; map it to explicit "auto" so it - # counts as a choice and the scrub runs, not 422 or inherit LLAMA_ARG_MLOCK (#7164). + # A form may serialize normal memory-mapped loading as ""; avoid a 422. assert _base_load_request(gguf_memory_mode = blank).gguf_memory_mode == "auto" assert ( ValidateModelRequest.model_validate( diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index a41b940063..940a142e54 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -756,32 +756,31 @@ def test_memory_mode_auto_matches_none_in_target_state(): assert backend._already_in_target_state(**kwargs) is True -def test_explicit_auto_reloads_when_child_inherited_mem_env(): - """Explicit auto reloads a child with inherited placement env vars.""" +def test_explicit_auto_matches_child_with_authoritative_mem_env(monkeypatch): + """A live environment override makes the UI/API mode irrelevant.""" + monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") backend = _loaded_backend(_launched_with_inherited_mem_env = True) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "auto" - assert backend._already_in_target_state(**kwargs) is False - - -def test_omitted_mode_does_not_reload_child_with_inherited_mem_env(): - """Omission preserves inherited placement without reloading.""" - backend = _loaded_backend(_launched_with_inherited_mem_env = True) - kwargs = _base_target_state_kwargs(backend) - kwargs["memory_mode"] = None assert backend._already_in_target_state(**kwargs) is True -def test_explicit_auto_matches_scrubbed_child(): - """Explicit auto deduplicates after inherited env has been scrubbed.""" +def test_removed_mem_env_reloads_child_that_inherited_it(): + """Removing an operator override requires a reload to drop its effect.""" + backend = _loaded_backend(_launched_with_inherited_mem_env = True) + kwargs = _base_target_state_kwargs(backend) + kwargs["memory_mode"] = None + assert backend._already_in_target_state(**kwargs) is False + + +def test_explicit_auto_matches_child_without_mem_env(): backend = _loaded_backend(_launched_with_inherited_mem_env = False) kwargs = _base_target_state_kwargs(backend) kwargs["memory_mode"] = "auto" assert backend._already_in_target_state(**kwargs) is True -def test_omitted_mode_reloads_scrubbed_child_to_reinherit_parent_env(monkeypatch): - """Default must restart an explicit-Auto child when the parent has a mode.""" +def test_new_mem_env_reloads_child_to_apply_operator_override(monkeypatch): monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") backend = _loaded_backend(_launched_with_inherited_mem_env = False) kwargs = _base_target_state_kwargs(backend) @@ -844,7 +843,7 @@ def test_requested_memory_mode_preserves_explicit_auto( assert backend.memory_mode == expected_canonical -# ── inherited LLAMA_ARG_* mmap/mlock env is scrubbed when a mode is set ─────── +# ── LLAMA_ARG_* host-memory env is authoritative ───────────────────────────── def _mem_env_backend(gguf): @@ -865,12 +864,8 @@ def _mem_env_backend(gguf): return backend -@pytest.mark.parametrize( - "mode,scrubbed", - [("auto", True), ("pinned", True), ("resident", True), (None, False)], -) -def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scrubbed): - """Only an explicit memory mode scrubs inherited placement env vars.""" +@pytest.mark.parametrize("mode", ["auto", "pinned", "resident", None]) +def test_memory_mode_env_overrides_every_request(tmp_path, monkeypatch, mode): monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") monkeypatch.setenv("LLAMA_ARG_NO_MMAP", "1") monkeypatch.setenv("LLAMA_ARG_MMAP", "true") @@ -882,6 +877,7 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru backend = _mem_env_backend(gguf) captured_envs = [] + captured_cmds = [] def _make_fake_popen(cmd, **kwargs): if not cmd or str(cmd[0]) != "/fake/llama-server": @@ -891,6 +887,7 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru pid = 12345 def __init__(self, cmd, **kwargs): + captured_cmds.append(list(cmd)) captured_envs.append(kwargs.get("env") or {}) def poll(self): @@ -914,7 +911,48 @@ def test_memory_mode_scrubs_inherited_mmap_env(tmp_path, monkeypatch, mode, scru "LLAMA_ARG_MMAP", "LLAMA_ARG_DIO", ): - assert (var not in env) == scrubbed + assert var in env + cmd = captured_cmds[-1] + assert "--load-mode" not in cmd + assert "--mlock" not in cmd + assert "--mmap" not in cmd + assert "--no-mmap" not in cmd + + +def test_memory_mode_env_strips_conflicting_passthrough_flag(tmp_path, monkeypatch): + """Raw argv must not regain precedence over the operator override.""" + monkeypatch.setenv("LLAMA_ARG_LOAD_MODE", "mlock") + gguf = tmp_path / "model.gguf" + _write_minimal_gguf(gguf) + backend = _mem_env_backend(gguf) + captured_cmds = [] + + def _make_fake_popen(cmd, **kwargs): + if not cmd or str(cmd[0]) != "/fake/llama-server": + return _REAL_POPEN(cmd, **kwargs) + + class _FakePopen: + pid = 12345 + + def __init__(self, cmd, **kwargs): + captured_cmds.append(list(cmd)) + + def poll(self): + return None + + return _FakePopen(cmd, **kwargs) + + with patch.object(subprocess, "Popen", side_effect = _make_fake_popen): + backend.load_model( + gguf_path = str(gguf), + model_identifier = "test", + memory_mode = "resident", + extra_args = ["--load-mode", "none", "--top-k", "20"], + ) + + cmd = captured_cmds[-1] + assert "--load-mode" not in cmd + assert "--top-k" in cmd # ── diffusion GGUF placement ───────────────────────────────────────────────── diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py index 15e8c92260..a61e1cebfa 100644 --- a/studio/backend/tests/test_tp_vision_regression.py +++ b/studio/backend/tests/test_tp_vision_regression.py @@ -845,8 +845,7 @@ def _mem_loaded_backend( def test_explicit_auto_reloads_over_passthrough_mlock_in_extras(): """A server loaded with no memory_mode keeps a pass-through --mlock and is still mlocked. An explicit "auto" repeating --mlock must NOT dedupe: stripping only the - request side keeps the backend's --mlock visible, so the matcher reloads and the - scrub runs (Codex #7164).""" + request side keeps the backend's --mlock visible, so the matcher reloads.""" from models.inference import LoadRequest inference_routes = _load_inference_routes_module() @@ -881,8 +880,7 @@ def test_explicit_null_memory_mode_dedupes_over_passthrough_mlock(): assert inference_routes._request_matches_loaded_settings(req, backend) is True -def test_default_memory_mode_reloads_to_reinherit_parent_env(monkeypatch): - """Default cannot dedupe to an Auto child that scrubbed the parent mode.""" +def test_memory_env_override_reloads_child_that_did_not_inherit_it(monkeypatch): from models.inference import LoadRequest inference_routes = _load_inference_routes_module() diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index a9b12bbbeb..77bb2e7eec 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -422,23 +422,19 @@ function GpuMemorySettings({ )}
- Host Memory + Host RAM Controls host RAM only, not whether a GPU driver keeps weights in VRAM. - Default preserves inherited llama.cpp settings. Auto uses normal - memory-mapped loading. Locked RAM prevents mapped host pages from being - swapped. RAM copy disables memory mapping, but newer llama.cpp builds - cannot also lock that copy. + Memory mapped uses normal llama.cpp loading. Locked RAM prevents mapped + host pages from being swapped. RAM copy disables memory mapping, but + newer llama.cpp builds cannot also lock that copy.
- Memory mapped + Default Locked RAM RAM copy diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 698057e304..0444c744c2 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -502,16 +502,6 @@ def test_model_switch_clears_host_memory_mode_before_validation(): assert "gguf_memory_mode: validateMemoryMode" in src -def test_host_ram_picker_has_only_concrete_modes(): - page = _read("features/model-picker/components/model-config-page.tsx") - assert ">Host RAM" in page - assert 'value={config.ggufMemoryMode ?? "auto"}' in page - assert 'Memory mapped' in page - assert 'Locked RAM' in page - assert 'RAM copy' in page - assert '' not in page - - def test_diffusion_picker_hides_and_clears_unsupported_memory_modes(): api = _read("features/chat/api/chat-api.ts") assert "isDiffusion: res.is_diffusion ?? false" in api From 807115f43197bd2c06ddefa54df54b9b5d092fa3 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:28:24 -0300 Subject: [PATCH 050/110] Clarify host memory settings --- studio/backend/core/inference/llama_cpp.py | 8 +- .../core/inference/llama_server_args.py | 2 +- studio/backend/models/inference.py | 40 +++++----- studio/backend/routes/chat_history.py | 2 +- studio/backend/routes/inference.py | 76 ++++++------------- studio/backend/tests/test_gpu_memory_mode.py | 10 +-- studio/backend/tests/test_gpu_selection.py | 15 +--- .../tests/test_inference_model_validation.py | 20 ++--- .../tests/test_llama_cpp_memory_mode.py | 42 +++++----- .../backend/tests/test_llama_server_args.py | 4 +- studio/backend/tests/test_mtp_vram_budget.py | 6 +- .../tests/test_tp_vision_regression.py | 18 ++--- .../src/features/chat/api/chat-adapter.ts | 16 ++-- .../src/features/chat/api/chat-api.ts | 2 +- .../src/features/chat/chat-settings-sheet.tsx | 2 +- .../chat/hooks/use-chat-model-runtime.ts | 16 ++-- studio/frontend/src/features/chat/index.ts | 2 +- .../lib/apply-inference-status-to-store.ts | 14 ++-- .../chat/presets/preset-load-config.ts | 36 +++++---- .../src/features/chat/shared-composer.tsx | 10 +-- .../chat/stores/chat-runtime-store.ts | 25 +++--- .../frontend/src/features/chat/types/api.ts | 12 +-- .../components/model-config-page.tsx | 16 ++-- .../hooks/use-active-model-config.ts | 6 +- .../model-config/apply-per-model-config.ts | 8 +- .../model-config/per-model-config.ts | 27 +++---- tests/studio/test_chat_preset_load_config.py | 13 ++-- tests/studio/test_model_picker_contracts.py | 8 +- 28 files changed, 214 insertions(+), 242 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8bdd462e2b..3b387e8566 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -2608,12 +2608,12 @@ class LlamaCppBackend: @property def memory_mode(self) -> Optional[str]: - """Canonical active GGUF memory mode; auto is None.""" + """Canonical active GGUF host-memory mode; default is None.""" return self._canonical_memory_mode(self._requested_memory_mode) @property def requested_memory_mode(self) -> Optional[str]: - """Raw mode for status responses, preserving explicit auto.""" + """Raw mode for status responses, preserving explicit default.""" return self._requested_memory_mode def matches_memory_mode(self, memory_mode: Optional[str]) -> bool: @@ -6408,9 +6408,9 @@ class LlamaCppBackend: @staticmethod def _canonical_memory_mode(memory_mode: Optional[str]) -> Optional[str]: - """Normalize auto, blank, and None to the default mode.""" + """Normalize default, blank, and None to no explicit policy.""" mode = (memory_mode or "").strip().lower() - if mode in ("", "auto"): + if mode in ("", "default"): return None return mode diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index e54dc88686..79c17253a4 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -497,7 +497,7 @@ def strip_shadowing_flags( row/none/layer choice intact. ``strip_device`` and ``strip_memory_mode`` are off by default (opt-in, like ``strip_offload`` / ``strip_tensor_split``): a user may pass ``--device`` / ``--mlock`` / ``--no-mmap`` when Unsloth has no opinion, - so they're stripped only when the caller sets gpu_ids / gguf_memory_mode. Enabling + so they're stripped only when the caller sets gpu_ids / host_memory_mode. Enabling them by default would silently drop those inherited pass-through flags on an Apply that omits the field. """ diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 61f5f5e36d..9ec783863e 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -20,7 +20,7 @@ from pydantic import ( from picker.schemas import MAX_CHAT_TEMPLATE_BYTES -GgufMemoryMode = Literal["auto", "pinned", "resident"] +HostMemoryMode = Literal["default", "pinned", "resident"] class LoadRequest(BaseModel): @@ -185,13 +185,13 @@ class LoadRequest(BaseModel): raise ValueError("tensor_split must have a positive total") return value - gguf_memory_mode: Optional[GgufMemoryMode] = Field( + host_memory_mode: Optional[HostMemoryMode] = Field( None, description = ( "GGUF host-memory placement mode (llama.cpp --mlock/--no-mmap). These " "control system RAM residency and file mapping on the host, NOT GPU VRAM " "placement, so they do not by themselves keep offloaded weights pinned in " - "VRAM. Omit the field or use 'auto' to leave the loading policy to the " + "VRAM. Omit the field or use 'default' to leave the loading policy to the " "installed llama.cpp version. 'pinned' locks mapped host pages so the OS " "cannot page them out. 'resident' loads a RAM copy instead of mapping the " "file; on newer llama.cpp builds that copy cannot also be locked and may " @@ -200,12 +200,12 @@ class LoadRequest(BaseModel): ), ) - @field_validator("gguf_memory_mode", mode = "before") + @field_validator("host_memory_mode", mode = "before") @classmethod - def normalize_blank_gguf_memory_mode(cls, value: Any) -> Any: + def normalize_blank_host_memory_mode(cls, value: Any) -> Any: # A blank form value leaves the policy to llama.cpp. if isinstance(value, str) and value.strip() == "": - return "auto" + return "default" return value llama_extra_args: Optional[List[str]] = Field( @@ -275,7 +275,7 @@ class ValidateModelRequest(BaseModel): "delegate fitting to llama.cpp, while explicit layers are user-owned." ), ) - gguf_memory_mode: Optional[GgufMemoryMode] = Field( + host_memory_mode: Optional[HostMemoryMode] = Field( None, description = ( "Intended GGUF host-memory placement mode; mirrors /load. " @@ -283,12 +283,12 @@ class ValidateModelRequest(BaseModel): ), ) - @field_validator("gguf_memory_mode", mode = "before") + @field_validator("host_memory_mode", mode = "before") @classmethod - def normalize_blank_gguf_memory_mode(cls, value: Any) -> Any: + def normalize_blank_host_memory_mode(cls, value: Any) -> Any: # Mirror LoadRequest and avoid a 422 for blank form values. if isinstance(value, str) and value.strip() == "": - return "auto" + return "default" return value include_context_length: bool = Field( @@ -545,18 +545,18 @@ class LoadResponse(BaseModel): ) gpu_ids: Optional[List[int]] = Field( None, - description = "Effective GPU indices the model is using, or None for automatic selection.", + description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.", ) requested_gpu_ids: Optional[List[int]] = Field( None, description = ( - "GPU indices requested by the user before fit-time narrowing, or " - "None for automatic selection." + "GPU placement pool requested by the user before fit-time narrowing, " + "or None for automatic selection." ), ) - gguf_memory_mode: Optional[GgufMemoryMode] = Field( + host_memory_mode: Optional[HostMemoryMode] = Field( None, - description = "Active GGUF memory placement mode. Only meaningful when is_gguf is True.", + description = "Active GGUF host-memory placement mode. Only meaningful when is_gguf is True.", ) @@ -724,18 +724,18 @@ class InferenceStatusResponse(BaseModel): ) gpu_ids: Optional[List[int]] = Field( None, - description = "Effective GPU indices the model is using, or None for automatic selection.", + description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.", ) requested_gpu_ids: Optional[List[int]] = Field( None, description = ( - "GPU indices requested by the user before fit-time narrowing, or " - "None for automatic selection." + "GPU placement pool requested by the user before fit-time narrowing, " + "or None for automatic selection." ), ) - gguf_memory_mode: Optional[GgufMemoryMode] = Field( + host_memory_mode: Optional[HostMemoryMode] = Field( None, - description = "Active GGUF memory placement mode. Only meaningful when is_gguf is True.", + description = "Active GGUF host-memory placement mode. Only meaningful when is_gguf is True.", ) llama_cpp_supports_mtp: bool = Field( True, diff --git a/studio/backend/routes/chat_history.py b/studio/backend/routes/chat_history.py index 9dd7260174..cc1b0830f6 100644 --- a/studio/backend/routes/chat_history.py +++ b/studio/backend/routes/chat_history.py @@ -172,7 +172,7 @@ class ChatPresetLoadConfig(BaseModel): gpuMemoryMode: Optional[Literal["manual"]] = None gpuLayers: Optional[int] = None nCpuMoe: Optional[int] = Field(default = None, ge = 0) - ggufMemoryMode: Optional[Literal["auto", "pinned", "resident"]] = None + hostMemoryMode: Optional[Literal["default", "pinned", "resident"]] = None class ChatPreset(BaseModel): diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index d5de296d83..363a4d8476 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3219,7 +3219,7 @@ def _request_matches_loaded_settings( # tensor load isn't seen as a mismatch that needlessly reloads the server. backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else [] # Explicit null means no opinion, so only a real value owns memory flags. - _strip_mem = request.gguf_memory_mode is not None + _strip_mem = request.host_memory_mode is not None effective_extra = ( request.llama_extra_args if request.llama_extra_args is not None @@ -3255,19 +3255,12 @@ def _request_matches_loaded_settings( ) ): return False - # A changed GPU pick must reload. The diffusion runner collapses a multi-GPU - # request to its single lowest device (it drives one device only), so the - # backend records just that device; compare the request the same way, or a - # multi-GPU pick that resolves to the same device needlessly reloads. - if llama_backend.is_diffusion: - _req_gpu_ids = [sorted(request.gpu_ids)[0]] if request.gpu_ids else None - _loaded_gpu_ids = llama_backend.gpu_ids - else: - _req_gpu_ids = sorted(request.gpu_ids) if request.gpu_ids else None - _loaded_gpu_ids = llama_backend.requested_gpu_ids - if _req_gpu_ids != _loaded_gpu_ids: + # A regular GGUF may narrow the requested placement pool. Accept either the + # original request or the effective status-echoed subset; diffusion keeps + # its single-device normalization. + if not llama_backend.matches_gpu_ids(request.gpu_ids): return False - if not llama_backend.matches_memory_mode(request.gguf_memory_mode): + if not llama_backend.matches_memory_mode(request.host_memory_mode): return False # Preserved tensor->layer fallback (both report tensor=off, so the check above # matches): if the user now explicitly drops tensor intent, reload so placement @@ -3967,7 +3960,7 @@ def _reject_diffusion_memory_mode(config: ModelConfig, memory_mode: Optional[str raise HTTPException( status_code = 400, detail = ( - "Explicit gguf_memory_mode is not supported for diffusion " + "Explicit host_memory_mode is not supported for diffusion " "(DiffusionGemma) GGUF models." ), ) @@ -4051,20 +4044,21 @@ async def _resolve_gguf_gpu_ids_for_request( raise HTTPException( status_code = 400, detail = ( - "GPU selection (gpu_ids) is not supported for a DiffusionGemma " - "GGUF on a Vulkan llama.cpp build: the diffusion runner selects " - "its device by CUDA physical index, which has no defined mapping " - "to ggml Vulkan device ordinals. Omit gpu_ids to use the default " - "device." + f"Requested gpu_ids {list(gpu_ids)} but the llama.cpp build has " + "no GPU backend (CPU-only build); it would ignore the pin and run " + "on CPU. Omit gpu_ids to run on CPU." ), ) try: - resolved = resolve_requested_gpu_ids(gpu_ids, is_vulkan = is_vulkan) + resolved = resolve_requested_gpu_ids( + gpu_ids, + is_vulkan = ids_are_vulkan_ordinals, + ) except ValueError as exc: raise HTTPException(status_code = 400, detail = str(exc)) from exc - if is_vulkan and resolved: + if ids_are_vulkan_ordinals and resolved: binary = LlamaCppBackend._find_llama_server_binary() if binary: probed = { @@ -4080,29 +4074,7 @@ async def _resolve_gguf_gpu_ids_for_request( ), ) - return resolved - - -def _reject_diffusion_memory_mode(config: ModelConfig, memory_mode: Optional[str]) -> None: - """Raise 400 if a DiffusionGemma GGUF was given an explicit gguf_memory_mode. - - The diffusion runner is single-GPU (DG_GPU) with no --mlock/--no-mmap plumbing. - Called by BOTH /validate and /load before either tears down the active model, so - they agree and a placement /load will reject isn't validated post-unload (#7188). - gpu_ids for diffusion is handled by the runner (collapsed to a single device), so - only the host-memory mode is rejected here.""" - if not config.is_gguf: - return - if LlamaCppBackend._canonical_memory_mode(memory_mode) is None: - return - if _classify_diffusion_gguf(config) is True: - raise HTTPException( - status_code = 400, - detail = ( - "Explicit gguf_memory_mode is not supported for diffusion " - "(DiffusionGemma) GGUF models." - ), - ) + return resolved, ids_are_vulkan_ordinals def _guard_chat_load_against_training( @@ -4270,7 +4242,7 @@ def _resolve_inherited_extra_args( strip_split_mode = _should_strip_split_mode(request, stored_args), # A non-null first-class mode owns prior raw argv. Operator # LLAMA_ARG_* values remain authoritative in load_model. - strip_memory_mode = getattr(request, "gguf_memory_mode", None) is not None, + strip_memory_mode = getattr(request, "host_memory_mode", None) is not None, # manual + per-GPU ratio emits its own --tensor-split; drop # an inherited one (appended last would override it) while # keeping the user's --split-mode row/none/layer choice. @@ -4486,7 +4458,7 @@ async def _load_model_impl( and getattr(llama_backend, "_audio_probed", True) ): llama_backend._record_matching_gpu_request(request.gpu_ids) - llama_backend._record_matching_memory_request(request.gguf_memory_mode) + llama_backend._record_matching_memory_request(request.host_memory_mode) logger.info( "Model already loaded (GGUF): " f"{model_log_label} variant={request.gguf_variant or llama_backend.hf_variant}, skipping reload" @@ -4534,7 +4506,7 @@ async def _load_model_impl( n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, requested_gpu_ids = llama_backend.requested_gpu_ids, - gguf_memory_mode = llama_backend.requested_memory_mode, + host_memory_mode = llama_backend.requested_memory_mode, ) else: if ( @@ -4598,7 +4570,7 @@ async def _load_model_impl( detail = f"Invalid model identifier: {model_log_label}", ) - _reject_diffusion_memory_mode(config, request.gguf_memory_mode) + _reject_diffusion_memory_mode(config, request.host_memory_mode) # Resolve inherited extras once before command-dependent preflights. extra_llama_args = _resolve_inherited_extra_args( @@ -4734,7 +4706,7 @@ async def _load_model_impl( n_cpu_moe = request.n_cpu_moe, tensor_split = request.tensor_split, gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, - memory_mode = request.gguf_memory_mode, + memory_mode = request.host_memory_mode, n_parallel = _n_parallel, # Issue #7164: explicit GPU pin resolved to physical ids above. gpu_ids = gguf_gpu_ids, @@ -4912,7 +4884,7 @@ async def _load_model_impl( n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, requested_gpu_ids = llama_backend.requested_gpu_ids, - gguf_memory_mode = llama_backend.requested_memory_mode, + host_memory_mode = llama_backend.requested_memory_mode, ) # ── Standard path: load via Unsloth/transformers ────────── @@ -5203,7 +5175,7 @@ async def validate_model( detail = f"Invalid model identifier: {model_log_label}", ) - _reject_diffusion_memory_mode(config, request.gguf_memory_mode) + _reject_diffusion_memory_mode(config, request.host_memory_mode) effective_extra_args = _resolve_inherited_extra_args( request, config, model_identifier, None @@ -6049,7 +6021,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)): n_moe_layers = llama_backend.n_moe_layers, gpu_ids = llama_backend.gpu_ids, requested_gpu_ids = llama_backend.requested_gpu_ids, - gguf_memory_mode = llama_backend.requested_memory_mode, + host_memory_mode = llama_backend.requested_memory_mode, llama_cpp_supports_mtp = _supports_mtp, spec_fallback_reason = llama_backend.spec_fallback_reason, llama_cpp_prebuilt_stale = _stale, diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index f56c83d9d9..f85de0b768 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -605,16 +605,16 @@ def test_gguf_load_and_status_responses_include_requested_gpu_pool(): assert route_src.count("requested_gpu_ids = llama_backend.requested_gpu_ids") == 3 -def test_already_loaded_response_preserves_explicit_auto_memory_mode(): +def test_already_loaded_response_preserves_explicit_default_memory_mode(): route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8") - assert "llama_backend._record_matching_memory_request(request.gguf_memory_mode)" in route_src + assert "llama_backend._record_matching_memory_request(request.host_memory_mode)" in route_src backend = LlamaCppBackend() backend._last_load_kwargs = {"memory_mode": None} - backend._record_matching_memory_request("auto") - assert backend.requested_memory_mode == "auto" + backend._record_matching_memory_request("default") + assert backend.requested_memory_mode == "default" assert backend.memory_mode is None - assert backend._last_load_kwargs["memory_mode"] == "auto" + assert backend._last_load_kwargs["memory_mode"] == "default" def test_gpu_ids_property_default_and_reset(): diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index cc14beb7ce..f6de01d24b 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1027,11 +1027,12 @@ class TestRouteErrors(unittest.TestCase): return_value = None, ), ): - resolved = asyncio.run( + resolved, uses_vulkan_ordinals = asyncio.run( inference_route._resolve_gguf_gpu_ids_for_request(config, [1, 0]) ) self.assertEqual(resolved, [0, 1]) + self.assertTrue(uses_vulkan_ordinals) def test_inference_route_validates_gpu_ids_for_gguf(self): import utils.hardware.hardware as hardware_mod @@ -1079,16 +1080,6 @@ class TestRouteErrors(unittest.TestCase): ), patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext), - # Pin the device so the test deterministically exercises the CUDA - # physical-ID resolver it patches, rather than drifting into the - # non-CUDA/Vulkan deferral branch on a GPU-less runner (whose - # "no GPU backend detected" message contains "not supported"). - patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CUDA), - patch.object( - hardware_mod, - "resolve_requested_gpu_ids", - side_effect = ValueError("Invalid gpu_ids [0, 1]: rejected by test"), - ), ): with self.assertRaises(HTTPException) as exc_info: asyncio.run( @@ -1343,7 +1334,7 @@ class TestRouteErrors(unittest.TestCase): extra_args_source = (model_id, None), ) config = SimpleNamespace(is_gguf = True, gguf_variant = None) - request = LoadRequest(model_path = model_id, gguf_memory_mode = "resident") + request = LoadRequest(model_path = model_id, host_memory_mode = "resident") with patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend): out = inference_route._resolve_inherited_extra_args(request, config, model_id, None) self.assertNotIn("--mlock", out) diff --git a/studio/backend/tests/test_inference_model_validation.py b/studio/backend/tests/test_inference_model_validation.py index 2f13c8df46..2174a9f152 100644 --- a/studio/backend/tests/test_inference_model_validation.py +++ b/studio/backend/tests/test_inference_model_validation.py @@ -43,26 +43,26 @@ from models.inference import ValidateModelRequest # noqa: E402 @pytest.mark.parametrize("blank", ["", " ", "\n\t"]) -def test_blank_gguf_memory_mode_normalizes_to_auto(blank): +def test_blank_host_memory_mode_normalizes_to_default(blank): # A form may serialize the llama.cpp default as ""; avoid a 422. - assert _base_load_request(gguf_memory_mode = blank).gguf_memory_mode == "auto" + assert _base_load_request(host_memory_mode = blank).host_memory_mode == "default" assert ( ValidateModelRequest.model_validate( - {"model_path": "x", "gguf_memory_mode": blank} - ).gguf_memory_mode - == "auto" + {"model_path": "x", "host_memory_mode": blank} + ).host_memory_mode + == "default" ) -@pytest.mark.parametrize("mode", ["auto", "pinned", "resident"]) -def test_valid_gguf_memory_mode_preserved(mode): - assert _base_load_request(gguf_memory_mode = mode).gguf_memory_mode == mode +@pytest.mark.parametrize("mode", ["default", "pinned", "resident"]) +def test_valid_host_memory_mode_preserved(mode): + assert _base_load_request(host_memory_mode = mode).host_memory_mode == mode -def test_invalid_gguf_memory_mode_still_rejected(): +def test_invalid_host_memory_mode_still_rejected(): # Non-blank typos must still fail Literal validation (only blanks are rescued). with pytest.raises(ValidationError): - _base_load_request(gguf_memory_mode = "resdent") + _base_load_request(host_memory_mode = "resdent") # ---------- ChatCompletionRequest tool_call_id walkback ---------- diff --git a/studio/backend/tests/test_llama_cpp_memory_mode.py b/studio/backend/tests/test_llama_cpp_memory_mode.py index 1be533f04a..605244bdd7 100644 --- a/studio/backend/tests/test_llama_cpp_memory_mode.py +++ b/studio/backend/tests/test_llama_cpp_memory_mode.py @@ -151,8 +151,8 @@ def _loaded_backend(**overrides): "mode,expected", [ (None, []), - ("auto", []), - ("AUTO", []), + ("default", []), + ("DEFAULT", []), ("pinned", ["--mlock"]), ("PINNED", ["--mlock"]), ("resident", ["--no-mmap", "--mlock"]), @@ -168,7 +168,7 @@ def test_memory_mode_flags_maps_modes(mode, expected): "mode,expected", [ (None, []), - ("auto", []), + ("default", []), ("pinned", ["--load-mode", "mlock"]), ("resident", ["--load-mode", "none"]), ], @@ -531,7 +531,7 @@ def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_id [ ("resident", "--mmap", "--no-mmap"), ("pinned", "--no-mmap", None), - ("auto", "--mlock", None), + ("default", "--mlock", None), ], ) def test_memory_mode_strips_conflicting_extra_args(tmp_path, mode, user_flag, winning): @@ -748,20 +748,20 @@ def test_explicit_gpu_ids_strips_stored_device_extra_args(tmp_path): assert "--top-k" in stored # unrelated extras are preserved -def test_memory_mode_auto_matches_none_in_target_state(): - """An explicit 'auto' request should not reload a load that omitted the field.""" +def test_memory_mode_default_matches_none_in_target_state(): + """An explicit default request should not reload a load that omitted the field.""" backend = _loaded_backend() kwargs = _base_target_state_kwargs(backend) - kwargs["memory_mode"] = "auto" + kwargs["memory_mode"] = "default" assert backend._already_in_target_state(**kwargs) is True -def test_explicit_auto_matches_child_with_authoritative_mem_env(monkeypatch): +def test_explicit_default_matches_child_with_authoritative_mem_env(monkeypatch): """A live environment override makes the UI/API mode irrelevant.""" monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") backend = _loaded_backend(_launched_with_inherited_mem_env = True) kwargs = _base_target_state_kwargs(backend) - kwargs["memory_mode"] = "auto" + kwargs["memory_mode"] = "default" assert backend._already_in_target_state(**kwargs) is True @@ -773,10 +773,10 @@ def test_removed_mem_env_reloads_child_that_inherited_it(): assert backend._already_in_target_state(**kwargs) is False -def test_explicit_auto_matches_child_without_mem_env(): +def test_explicit_default_matches_child_without_mem_env(): backend = _loaded_backend(_launched_with_inherited_mem_env = False) kwargs = _base_target_state_kwargs(backend) - kwargs["memory_mode"] = "auto" + kwargs["memory_mode"] = "default" assert backend._already_in_target_state(**kwargs) is True @@ -806,33 +806,33 @@ def test_load_response_and_status_round_trip_placement_fields(): is_gguf = True, inference = {}, gpu_ids = [0, 1], - gguf_memory_mode = "resident", + host_memory_mode = "resident", ) assert load_resp.gpu_ids == [0, 1] - assert load_resp.gguf_memory_mode == "resident" + assert load_resp.host_memory_mode == "resident" status_resp = InferenceStatusResponse( is_gguf = True, gpu_ids = [0, 1], - gguf_memory_mode = "pinned", + host_memory_mode = "pinned", ) assert status_resp.gpu_ids == [0, 1] - assert status_resp.gguf_memory_mode == "pinned" + assert status_resp.host_memory_mode == "pinned" @pytest.mark.parametrize( "mode,expected_requested,expected_canonical", [ - ("auto", "auto", None), - ("AUTO", "auto", None), + ("default", "default", None), + ("DEFAULT", "default", None), ("pinned", "pinned", "pinned"), (None, None, None), ], ) -def test_requested_memory_mode_preserves_explicit_auto( +def test_requested_memory_mode_preserves_explicit_default( tmp_path, mode, expected_requested, expected_canonical ): - """Preserve explicit auto for status while canonicalizing it to None.""" + """Preserve explicit default for status while canonicalizing it to None.""" gguf = tmp_path / "model.gguf" _write_minimal_gguf(gguf) backend = _mem_env_backend(gguf) @@ -864,7 +864,7 @@ def _mem_env_backend(gguf): return backend -@pytest.mark.parametrize("mode", ["auto", "pinned", "resident", None]) +@pytest.mark.parametrize("mode", ["default", "pinned", "resident", None]) def test_memory_mode_env_overrides_every_request(tmp_path, monkeypatch, mode): monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") monkeypatch.setenv("LLAMA_ARG_NO_MMAP", "1") @@ -1054,7 +1054,7 @@ def test_remote_diffusion_cpu_only_pin_reaches_runner(tmp_path): assert captured["gpu_ids"] == [1] -@pytest.mark.parametrize("mode", [None, "auto", "AUTO", ""]) +@pytest.mark.parametrize("mode", [None, "default", "DEFAULT", ""]) def test_diffusion_load_clears_stale_memory_mode(tmp_path, mode): """A diffusion load clears stale llama-server memory-mode state.""" gguf = tmp_path / "diffusion.gguf" diff --git a/studio/backend/tests/test_llama_server_args.py b/studio/backend/tests/test_llama_server_args.py index 2b5bcdd8c7..065708fe55 100644 --- a/studio/backend/tests/test_llama_server_args.py +++ b/studio/backend/tests/test_llama_server_args.py @@ -931,7 +931,7 @@ def test_strip_shadowing_flags_keeps_memory_mode_when_not_requested(): def test_strip_shadowing_flags_default_keeps_memory_mode(): # Memory-mode stripping is opt-in (like offload/tensor_split/device): the default # must PRESERVE inherited --mlock/--mmap/--no-mmap so an Apply that omits - # gguf_memory_mode doesn't silently drop a user's pass-through memory flag (#7188). + # host_memory_mode doesn't silently drop a user's pass-through memory flag (#7188). assert strip_shadowing_flags(["--mlock", "--no-mmap", "--mmap"]) == [ "--mlock", "--no-mmap", @@ -962,6 +962,6 @@ def test_strip_memory_mode_valueless_preserves_next_token(flag): def test_strip_memory_mode_kept_when_field_not_supplied(): - # Pass-through preserved when the user didn't change gguf_memory_mode. + # Pass-through preserved when the user didn't change host_memory_mode. out = strip_shadowing_flags(["--mmap"], strip_memory_mode = False) assert out == ["--mmap"] diff --git a/studio/backend/tests/test_mtp_vram_budget.py b/studio/backend/tests/test_mtp_vram_budget.py index 834d10de07..53bc2a45c9 100644 --- a/studio/backend/tests/test_mtp_vram_budget.py +++ b/studio/backend/tests/test_mtp_vram_budget.py @@ -870,7 +870,7 @@ class TestExtraArgsMtpDetection: assert "llama_backend.hf_repo" in body def test_route_matcher_strips_memory_flags_from_explicit_extras(self): - # Strip only request-side memory flags so explicit auto can still trigger cleanup. + # Strip only request-side memory flags so explicit default can still trigger cleanup. routes_src = ( Path(__file__).resolve().parent.parent / "routes" / "inference.py" ).read_text() @@ -879,8 +879,8 @@ class TestExtraArgsMtpDetection: body = "".join(routes_src[start:end].split()) # Gated on the VALUE, not model_fields_set: an explicit null must not strip, # so it dedupes as "no opinion" (#7188). - assert "_strip_mem=request.gguf_memory_modeisnotNone" in body - assert '"gguf_memory_mode"infields_set' not in body + assert "_strip_mem=request.host_memory_modeisnotNone" in body + assert '"host_memory_mode"infields_set' not in body # The request side is stripped and compared against the UNstripped backend. assert "_request_extra" in body assert "if_request_extra!=backend_extra:" in body diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py index a61e1cebfa..c28697aff9 100644 --- a/studio/backend/tests/test_tp_vision_regression.py +++ b/studio/backend/tests/test_tp_vision_regression.py @@ -842,9 +842,9 @@ def _mem_loaded_backend( return b -def test_explicit_auto_reloads_over_passthrough_mlock_in_extras(): +def test_explicit_default_reloads_over_passthrough_mlock_in_extras(): """A server loaded with no memory_mode keeps a pass-through --mlock and is still - mlocked. An explicit "auto" repeating --mlock must NOT dedupe: stripping only the + mlocked. An explicit default repeating --mlock must NOT dedupe: stripping only the request side keeps the backend's --mlock visible, so the matcher reloads.""" from models.inference import LoadRequest @@ -852,16 +852,16 @@ def test_explicit_auto_reloads_over_passthrough_mlock_in_extras(): req = LoadRequest( model_path = "owner/repo", - gguf_memory_mode = "auto", + host_memory_mode = "default", llama_extra_args = ["--mlock"], ) - assert "gguf_memory_mode" in req.model_fields_set + assert "host_memory_mode" in req.model_fields_set backend = _mem_loaded_backend(memory_mode = None, extra_args = ["--mlock"]) assert inference_routes._request_matches_loaded_settings(req, backend) is False def test_explicit_null_memory_mode_dedupes_over_passthrough_mlock(): - """An explicit gguf_memory_mode=null (a client echoing the status response) is "no + """An explicit host_memory_mode=null (a client echoing the status response) is "no opinion", not a mode change. Pydantic marks it set, but the dedup gates the strip on the VALUE: null must NOT strip the request's --mlock, so a status-hydrated Apply dedupes to the running server (#7188).""" @@ -871,11 +871,11 @@ def test_explicit_null_memory_mode_dedupes_over_passthrough_mlock(): req = LoadRequest( model_path = "owner/repo", - gguf_memory_mode = None, + host_memory_mode = None, llama_extra_args = ["--mlock"], ) # Pydantic marks an explicit null as set -- why gating on model_fields_set was wrong. - assert "gguf_memory_mode" in req.model_fields_set + assert "host_memory_mode" in req.model_fields_set backend = _mem_loaded_backend(memory_mode = None, extra_args = ["--mlock"]) assert inference_routes._request_matches_loaded_settings(req, backend) is True @@ -885,7 +885,7 @@ def test_memory_env_override_reloads_child_that_did_not_inherit_it(monkeypatch): inference_routes = _load_inference_routes_module() monkeypatch.setenv("LLAMA_ARG_MLOCK", "1") - req = LoadRequest(model_path = "owner/repo", gguf_memory_mode = None) + req = LoadRequest(model_path = "owner/repo", host_memory_mode = None) backend = _mem_loaded_backend( memory_mode = None, extra_args = None, @@ -904,7 +904,7 @@ def test_explicit_pinned_dedupes_when_flags_already_applied(): req = LoadRequest( model_path = "owner/repo", - gguf_memory_mode = "pinned", + host_memory_mode = "pinned", llama_extra_args = ["--mlock"], ) backend = _mem_loaded_backend(memory_mode = "pinned", extra_args = None) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 986ed30322..9ade300acd 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -63,7 +63,7 @@ import { import type { ModelType } from "../types"; import { isMultimodalResponse } from "../types/api"; import type { - GgufMemoryMode, + HostMemoryMode, GgufVariantDetail, OpenAIChatCompletionsRequest, OpenAIChatMessage, @@ -1502,7 +1502,7 @@ async function autoLoadSmallestModel(): Promise<{ // The safetensors fallback omits both fields and uses HF auto-placement. gpu_ids?: number[]; gpu_memory_mode?: "auto" | "manual"; - gguf_memory_mode?: GgufMemoryMode | null; + host_memory_mode?: HostMemoryMode | null; }): Promise { const validation = await validateModel({ ...payload, @@ -1570,7 +1570,7 @@ async function autoLoadSmallestModel(): Promise<{ config.selectedGpuIndexKind, ) : null; - const effectiveMemoryMode = config.ggufMemoryMode ?? null; + const effectiveHostMemoryMode = config.hostMemoryMode ?? null; // Under Manual GPU memory + Auto layers, llama.cpp's --fit owns context // sizing, so send 0 (or the pinned length). GGUF-only; a no-op otherwise. // The context pin is per-model too, so it comes from the saved config, not @@ -1600,7 +1600,7 @@ async function autoLoadSmallestModel(): Promise<{ ? { gpu_ids: effectiveGpuIds ?? undefined, gpu_memory_mode: effectiveGpuMemoryMode, - gguf_memory_mode: effectiveMemoryMode, + host_memory_mode: effectiveHostMemoryMode, } : {}), })) @@ -1634,7 +1634,7 @@ async function autoLoadSmallestModel(): Promise<{ gpu_layers: effectiveGpuLayers, n_cpu_moe: effectiveNCpuMoe, gpu_ids: effectiveGpuIds ?? undefined, - gguf_memory_mode: effectiveMemoryMode, + host_memory_mode: effectiveHostMemoryMode, } : {}), }); @@ -1934,7 +1934,7 @@ async function autoLoadSmallestModel(): Promise<{ "unsloth/Qwen3.5-4B-MTP-GGUF", "UD-Q4_K_XL", ); - const defaultMemoryMode = defaultConfig.ggufMemoryMode ?? null; + const defaultHostMemoryMode = defaultConfig.hostMemoryMode ?? null; if (rt.selectedGpuIds != null) { await ensureGpuDeviceCache(); } @@ -1952,7 +1952,7 @@ async function autoLoadSmallestModel(): Promise<{ // model has no remembered settings to prefer). gpu_ids: defaultGpuIds ?? undefined, gpu_memory_mode: rt.gpuMemoryMode, - gguf_memory_mode: defaultMemoryMode, + host_memory_mode: defaultHostMemoryMode, })) ) { toast.dismiss(toastId); @@ -1983,7 +1983,7 @@ async function autoLoadSmallestModel(): Promise<{ gpu_layers: GPU_LAYERS_AUTO, n_cpu_moe: 0, gpu_ids: defaultGpuIds ?? undefined, - gguf_memory_mode: defaultMemoryMode, + host_memory_mode: defaultHostMemoryMode, }); saveSpeculativeType(specSettings.speculativeType); persistGpuMemoryModeOnLoad(loadResp, rt.gpuMemoryMode); diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 4c32da138a..cd0833cb35 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -170,7 +170,7 @@ export async function validateModel( // so it applies the same training-guard policy as /load. gpu_memory_mode: payload.gpu_memory_mode, // Keep validate and load on the same host-memory policy. - gguf_memory_mode: payload.gguf_memory_mode ?? null, + host_memory_mode: payload.host_memory_mode ?? null, }), }); return parseJsonOrThrow(response); diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 7b310c50d4..c2247465d1 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -968,7 +968,7 @@ export function ChatSettingsPanel({

Saving a preset also stores current load settings (context length, - KV cache dtype, speculative decoding, GPU layers). + KV cache dtype, speculative decoding, GPU layers, Host RAM). {currentLoadSummary ? ( <> {" "} diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index b7306ff5a0..04ea798451 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -670,7 +670,7 @@ export function useChatModelRuntime() { stateBeforeUnload.selectedGpuIds, stateBeforeUnload.selectedGpuIndexKind, ); - let loadMemoryMode = stateBeforeUnload.ggufMemoryMode; + let loadHostMemoryMode = stateBeforeUnload.hostMemoryMode; let loadSpeculativeType = stateBeforeUnload.speculativeType; let loadSpecDraftNMax = stateBeforeUnload.specDraftNMax; try { @@ -700,9 +700,9 @@ export function useChatModelRuntime() { const validateGpuIds = resetsPerModelSettings ? null : loadSelectedGpuIds; - const validateMemoryMode = resetsPerModelSettings + const validateHostMemoryMode = resetsPerModelSettings ? null - : loadMemoryMode; + : loadHostMemoryMode; // The reset below re-baselines gpuLayers to Auto; mirror it here. const validateGpuLayers = resetsPerModelSettings ? GPU_LAYERS_AUTO @@ -734,7 +734,7 @@ export function useChatModelRuntime() { gguf_variant: ggufVariant ?? null, gpu_ids: validateGpuIds ?? undefined, ...(isGguf ? { gpu_memory_mode: loadGpuMemoryMode } : {}), - gguf_memory_mode: validateMemoryMode, + host_memory_mode: validateHostMemoryMode, }); // Upgrade consent runs before the security dialogs; Accept installs and the load continues. if (validation.requires_transformers_upgrade) { @@ -810,7 +810,7 @@ export function useChatModelRuntime() { // (gpuMemoryMode is a standing preference and is kept). selectedGpuIds: null, selectedGpuIndexKind: null, - ggufMemoryMode: null, + hostMemoryMode: null, gpuLayers: GPU_LAYERS_AUTO, nCpuMoe: 0, splitRatio: null, @@ -825,7 +825,7 @@ export function useChatModelRuntime() { // previous model's (gpuMemoryMode is standing, so left as captured). loadCustomContextLength = null; loadSelectedGpuIds = null; - loadMemoryMode = null; + loadHostMemoryMode = null; loadGpuLayers = GPU_LAYERS_AUTO; loadNCpuMoe = 0; loadSplitRatio = null; @@ -888,7 +888,7 @@ export function useChatModelRuntime() { n_cpu_moe: loadNCpuMoe, tensor_split: loadSplitRatio ?? undefined, gpu_ids: loadSelectedGpuIds ?? undefined, - gguf_memory_mode: loadMemoryMode ?? null, + host_memory_mode: loadHostMemoryMode ?? null, }); // If cancelled while loading, don't update UI to show @@ -1118,7 +1118,7 @@ export function useChatModelRuntime() { n_cpu_moe: stateBeforeUnload.loadedNCpuMoe ?? 0, tensor_split: stateBeforeUnload.loadedSplitRatio ?? undefined, gpu_ids: stateBeforeUnload.loadedGpuIds ?? undefined, - gguf_memory_mode: stateBeforeUnload.activeMemoryMode ?? null, + host_memory_mode: stateBeforeUnload.loadedHostMemoryMode ?? null, }); const rollbackSpeculativeType = normalizeSpeculativeType( rollbackResponse.speculative_type, diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index 623d5bc385..64dba33025 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -28,7 +28,7 @@ export { type LocalModelInfo, type ScanFolderInfo, } from "./api/chat-api"; -export type { GgufMemoryMode, GgufVariantDetail } from "./types/api"; +export type { HostMemoryMode, GgufVariantDetail } from "./types/api"; export { ChatSettingsPanel, ParamSlider, diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index 0a47f2389e..a239102850 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -226,8 +226,8 @@ export function applyActiveModelStatusToStore( const incomingGpuIds = status.is_gguf ? requestedGpuIdsFromResponse(status) : null; - const incomingMemoryMode = status.is_gguf - ? (status.gguf_memory_mode ?? null) + const incomingHostMemoryMode = status.is_gguf + ? (status.host_memory_mode ?? null) : null; const gpuStatusChanged = prevState.loadedGpuMemoryMode !== incomingGpuMode || @@ -235,7 +235,7 @@ export function applyActiveModelStatusToStore( prevState.loadedNCpuMoe !== incomingNCpuMoe || !sameArray(prevState.loadedSplitRatio, incomingSplit) || !sameArray(prevState.loadedGpuIds, incomingGpuIds) || - prevState.activeMemoryMode !== incomingMemoryMode || + prevState.loadedHostMemoryMode !== incomingHostMemoryMode || prevState.loadedCustomContextLength !== gpuPin; const gpuMemoryEditsPending = (prevState.loadedGpuMemoryMode !== null && @@ -249,8 +249,8 @@ export function applyActiveModelStatusToStore( prevState.selectedGpuIds, prevState.loadedGpuIds, ); - const memoryModeEditPending = - prevState.ggufMemoryMode !== prevState.activeMemoryMode; + const hostMemoryModeEditPending = + prevState.hostMemoryMode !== prevState.loadedHostMemoryMode; const incomingGpuFields = loadedGpuMemoryFields(status); // A same-model reload from another client advances every loaded baseline. // Preserve each editable group only when this tab has an unapplied change. @@ -273,7 +273,7 @@ export function applyActiveModelStatusToStore( selectedGpuIndexKind: prevState.selectedGpuIndexKind, }), ...(preserveSameModelEdits && - memoryModeEditPending && { ggufMemoryMode: prevState.ggufMemoryMode }), + hostMemoryModeEditPending && { hostMemoryMode: prevState.hostMemoryMode }), }; useChatRuntimeStore.setState({ @@ -344,7 +344,7 @@ export function applyActiveModelStatusToStore( gpuStatusFields), // Advance the loaded baseline without overwriting same-model edits. ...(seedLoadParams && { - activeMemoryMode: status.gguf_memory_mode ?? null, + loadedHostMemoryMode: status.host_memory_mode ?? null, }), ...(status.chat_template_override !== undefined && prevState.loadedChatTemplateOverride === null && diff --git a/studio/frontend/src/features/chat/presets/preset-load-config.ts b/studio/frontend/src/features/chat/presets/preset-load-config.ts index ff21346cf9..37ae3739fc 100644 --- a/studio/frontend/src/features/chat/presets/preset-load-config.ts +++ b/studio/frontend/src/features/chat/presets/preset-load-config.ts @@ -34,7 +34,7 @@ export type PresetLoadConfig = Pick< | "gpuMemoryMode" | "gpuLayers" | "nCpuMoe" - | "ggufMemoryMode" + | "hostMemoryMode" >; const VALID_KV_CACHE_DTYPES = new Set(KV_CACHE_DTYPES); @@ -81,11 +81,11 @@ export function normalizePresetLoadConfig( : null; const gpuMemoryMode = partial.gpuMemoryMode === "manual" ? ("manual" as const) : undefined; - const ggufMemoryMode = - partial.ggufMemoryMode === "auto" || - partial.ggufMemoryMode === "pinned" || - partial.ggufMemoryMode === "resident" - ? partial.ggufMemoryMode + const hostMemoryMode = + partial.hostMemoryMode === "default" || + partial.hostMemoryMode === "pinned" || + partial.hostMemoryMode === "resident" + ? partial.hostMemoryMode : undefined; let gpuLayers: number | undefined; if (typeof partial.gpuLayers === "number" && Number.isFinite(partial.gpuLayers)) { @@ -121,10 +121,11 @@ export function normalizePresetLoadConfig( ...(gpuMemoryMode ? { gpuMemoryMode } : {}), ...(gpuLayers !== undefined ? { gpuLayers } : {}), ...(nCpuMoe !== undefined ? { nCpuMoe } : {}), - ...(ggufMemoryMode ? { ggufMemoryMode } : {}), + ...(hostMemoryMode ? { hostMemoryMode } : {}), }; - return hasPresetLoadConfig(normalized) ? normalized : undefined; + const coalesced = coalesceDefaultLoadKnobs(normalized); + return hasPresetLoadConfig(coalesced) ? coalesced : undefined; } export function hasPresetLoadConfig( @@ -171,13 +172,12 @@ export function capturePresetLoadConfig(): PresetLoadConfig | undefined { ...(snapshot.nCpuMoe != null && snapshot.nCpuMoe > 0 ? { nCpuMoe: snapshot.nCpuMoe } : {}), - ...(snapshot.ggufMemoryMode - ? { ggufMemoryMode: snapshot.ggufMemoryMode } + ...(snapshot.hostMemoryMode + ? { hostMemoryMode: snapshot.hostMemoryMode } : {}), }; - return hasPresetLoadConfig(coalesceDefaultLoadKnobs(captured)) - ? coalesceDefaultLoadKnobs(captured) - : undefined; + const coalesced = coalesceDefaultLoadKnobs(captured); + return hasPresetLoadConfig(coalesced) ? coalesced : undefined; } function coalesceDefaultLoadKnobs( @@ -200,6 +200,9 @@ function coalesceDefaultLoadKnobs( if ((result.nCpuMoe ?? 0) === 0) { delete result.nCpuMoe; } + if (result.hostMemoryMode === "default") { + delete result.hostMemoryMode; + } return result; } @@ -224,7 +227,7 @@ export function applyPresetLoadConfig( nCpuMoe: config.nCpuMoe, selectedGpuIds: store.selectedGpuIds, selectedGpuIndexKind: store.selectedGpuIndexKind, - ggufMemoryMode: config.ggufMemoryMode, + hostMemoryMode: config.hostMemoryMode, }); } @@ -253,5 +256,10 @@ export function formatPresetLoadConfigSummary( if (config.tensorParallel) { parts.push("TP"); } + if (config.hostMemoryMode === "pinned") { + parts.push("Host RAM locked"); + } else if (config.hostMemoryMode === "resident") { + parts.push("RAM copy"); + } return parts.length > 0 ? parts.join(" · ") : null; } diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 9bb4c04f8a..d69e66f01f 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -995,7 +995,7 @@ export function SharedComposer({ gpuLayers: store.gpuLayers, nCpuMoe: store.nCpuMoe, splitRatio: store.splitRatio, - ggufMemoryMode: store.ggufMemoryMode, + hostMemoryMode: store.hostMemoryMode, // Reconcile the pick against the GPUs present now, like the model-switch // path: an early remember-restore can hold a stale cross-host pick that // /load would reject (the device cache is populated by send time). @@ -1065,8 +1065,8 @@ export function SharedComposer({ ownConfig.selectedGpuIndexKind, ) : compareLoadKnobs.selectedGpuIds; - const effectiveMemoryMode = - ownConfig.ggufMemoryMode ?? compareLoadKnobs.ggufMemoryMode; + const effectiveHostMemoryMode = + ownConfig.hostMemoryMode ?? compareLoadKnobs.hostMemoryMode; // A pane's context comes from its own config only: a saved pin, or null // (Auto/native). It must not inherit the active model's shared snapshot -- // resolveFitMaxSeqLength would treat that as a pin and load this pane at @@ -1111,7 +1111,7 @@ export function SharedComposer({ ? { gpu_ids: effectiveSelectedGpuIds ?? undefined, gpu_memory_mode: effectiveGpuMemoryMode, - gguf_memory_mode: effectiveMemoryMode, + host_memory_mode: effectiveHostMemoryMode, } : {}), }); @@ -1180,7 +1180,7 @@ export function SharedComposer({ n_cpu_moe: effectiveNCpuMoe, tensor_split: compareLoadKnobs.splitRatio ?? undefined, gpu_ids: effectiveSelectedGpuIds ?? undefined, - gguf_memory_mode: effectiveMemoryMode, + host_memory_mode: effectiveHostMemoryMode, } : {}), }); diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 5f0305d9cf..5410ecf4ae 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -23,7 +23,7 @@ import { DEFAULT_INFERENCE_PARAMS, type InferenceParams, } from "../types/runtime"; -import type { GgufMemoryMode } from "../types/api"; +import type { HostMemoryMode } from "../types/api"; import { loadChatSettingsWithLegacyImport, savePersistedChatSettingsPatch, @@ -636,7 +636,7 @@ export function loadedGpuMemoryFields(resp: { n_moe_layers?: number; gpu_ids?: number[] | null; requested_gpu_ids?: number[] | null; - gguf_memory_mode?: GgufMemoryMode | null; + host_memory_mode?: HostMemoryMode | null; }) { // GPU-memory state is meaningful only for a GGUF chat load. A non-GGUF response // still carries gpu_memory_mode (its default "auto" is serialized), so gate on @@ -661,8 +661,8 @@ export function loadedGpuMemoryFields(resp: { loadedSplitRatio: null, ggufLayerCount: null, moeLayerCount: null, - ggufMemoryMode: null, - activeMemoryMode: resp.gguf_memory_mode ?? null, + hostMemoryMode: null, + loadedHostMemoryMode: resp.host_memory_mode ?? null, }; } const mode = resp.gpu_memory_mode ?? "auto"; @@ -714,8 +714,8 @@ export function loadedGpuMemoryFields(resp: { selectedGpuIndexKind: gpuIndexKind, loadedGpuIds: gpuIds, // Reset both editable and loaded state to the applied backend mode. - ggufMemoryMode: resp.gguf_memory_mode ?? null, - activeMemoryMode: resp.gguf_memory_mode ?? null, + hostMemoryMode: resp.host_memory_mode ?? null, + loadedHostMemoryMode: resp.host_memory_mode ?? null, ...manualKnobs, }; } @@ -1011,9 +1011,9 @@ type ChatRuntimeStore = { selectedGpuIndexKind: GpuIndexKind | null; loadedGpuIds: number[] | null; /** Requested GGUF host-memory loading policy. null = backend/default behavior. */ - ggufMemoryMode: GgufMemoryMode | null; + hostMemoryMode: HostMemoryMode | null; /** Loaded GGUF host-memory mode used for hydration and rollback. */ - activeMemoryMode: GgufMemoryMode | null; + loadedHostMemoryMode: HostMemoryMode | null; /** Persisted: expand every On Device GGUF repo's quantizations by default * instead of waiting for a click. */ expandQuantizations: boolean; @@ -1471,9 +1471,8 @@ export const useChatRuntimeStore = create((set, get) => ({ selectedGpuIds: null, selectedGpuIndexKind: null, loadedGpuIds: null, - ggufMemoryMode: null, - activeMemoryMode: null, - loadOnSelection: loadBool(CHAT_LOAD_ON_SELECTION_KEY, true), + hostMemoryMode: null, + loadedHostMemoryMode: null, expandQuantizations: loadBool(CHAT_EXPAND_QUANTIZATIONS_KEY, false), showAllQuantizations: loadBool(CHAT_SHOW_ALL_QUANTIZATIONS_KEY, true), fitOnDeviceOnly: loadBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, false), @@ -1729,8 +1728,8 @@ export const useChatRuntimeStore = create((set, get) => ({ selectedGpuIds: null, selectedGpuIndexKind: null, loadedGpuIds: null, - ggufMemoryMode: null, - activeMemoryMode: null, + hostMemoryMode: null, + loadedHostMemoryMode: null, loadedIsMultimodal: false, loadedIsDiffusion: false, customContextLength: null, diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 7f7890d325..8960eae333 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -3,7 +3,7 @@ import type { TransformersUpgradeInfo } from "@/features/transformers-upgrade"; -export type GgufMemoryMode = "auto" | "pinned" | "resident"; +export type HostMemoryMode = "default" | "pinned" | "resident"; export interface BackendModelDetails { id: string; @@ -80,7 +80,7 @@ export interface LoadModelRequest { /** Picked CUDA/ROCm physical IDs or Vulkan ordinals (omit/empty = automatic). */ gpu_ids?: number[]; /** GGUF host-only loading policy. It does not control GPU VRAM residency. */ - gguf_memory_mode?: GgufMemoryMode | null; + host_memory_mode?: HostMemoryMode | null; } export interface ValidateModelResponse { @@ -195,10 +195,10 @@ export interface LoadModelResponse { n_moe_layers?: number; /** Effective GPU placement after fit-time narrowing. */ gpu_ids?: number[] | null; - /** User-requested GPU set before fit-time narrowing. */ + /** User-requested GPU placement pool before fit-time narrowing. */ requested_gpu_ids?: number[] | null; /** GGUF host-memory placement mode the load was invoked with. */ - gguf_memory_mode?: GgufMemoryMode | null; + host_memory_mode?: HostMemoryMode | null; } export interface UnloadModelRequest { @@ -252,10 +252,10 @@ export interface InferenceStatusResponse { requested_context_length?: number | null; /** Effective GPU placement after fit-time narrowing. */ gpu_ids?: number[] | null; - /** User-requested GPU set before fit-time narrowing. */ + /** User-requested GPU placement pool before fit-time narrowing. */ requested_gpu_ids?: number[] | null; /** Active GGUF host-memory placement mode (from /status). */ - gguf_memory_mode?: GgufMemoryMode | null; + host_memory_mode?: HostMemoryMode | null; n_layers?: number | null; /** Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not MoE. */ n_moe_layers?: number; diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 6ef074747b..f85cc11c29 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -17,7 +17,7 @@ import { GPU_LAYERS_AUTO, fetchGgufStagedMetadata, readPersistedSpeculativeType, - type GgufMemoryMode, + type HostMemoryMode, useChatRuntimeStore, } from "@/features/chat"; import { useGpuDevices } from "@/hooks/use-gpu-info"; @@ -84,7 +84,7 @@ function hasNonDefaultAdvanced(config: PerModelConfig): boolean { (config.gpuLayers != null && config.gpuLayers >= 0) || (config.nCpuMoe ?? 0) > 0 || config.selectedGpuIds != null || - config.ggufMemoryMode != null + (config.hostMemoryMode ?? "default") !== "default" ); } @@ -95,7 +95,7 @@ function withoutUnsupportedDiffusionMemory( (config.gpuMemoryMode ?? "auto") === "auto" && config.gpuLayers == null && config.nCpuMoe == null && - config.ggufMemoryMode == null + (config.hostMemoryMode ?? "default") === "default" ) { return config; } @@ -104,7 +104,7 @@ function withoutUnsupportedDiffusionMemory( gpuMemoryMode: "auto", gpuLayers: undefined, nCpuMoe: undefined, - ggufMemoryMode: undefined, + hostMemoryMode: undefined, }; } @@ -432,10 +432,10 @@ function GpuMemorySettings({

Default - Locked RAM - RAM copy + Mapped + locked + No memory map
From a32f8cbb09963865e77d0e9588e21d4ea3eee956 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:04:26 -0300 Subject: [PATCH 052/110] Clarify Host RAM choices --- .../components/model-config-page.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index ffd559520f..b45fd341ef 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -424,10 +424,20 @@ function GpuMemorySettings({
Host RAM - Controls system RAM, not GPU VRAM. Default lets llama.cpp and the - operating system manage model pages. Mapped + locked keeps mapped model - pages in RAM. No memory map loads the model without mapping its file, but - does not guarantee that it stays in RAM. +
+
+ Default: uses llama.cpp's + default host RAM behavior. +
+
+ Mapped + locked:{" "} + memory-maps the model and locks its pages in RAM. +
+
+ No memory map: reads the + model data into allocated RAM. +
+
- update({ - hostMemoryMode: value as HostMemoryMode, - }) - } - > - - - - - Default - Mapped + locked - No memory map - - -
); } @@ -776,7 +729,6 @@ export function ModelConfigPage({ contextFetchKey != null && stagedDims == null && (config.gpuMemoryMode === "manual" || - (config.hostMemoryMode ?? "default") !== "default" || config.selectedGpuIds != null); const resolvedIsDiffusion = isDiffusion || stagedDims?.isDiffusion === true; diff --git a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts index f46333f993..2637579248 100644 --- a/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts +++ b/studio/frontend/src/features/model-picker/hooks/use-active-model-config.ts @@ -31,7 +31,6 @@ export function useActiveModelConfig(): ActiveModelConfigState { const selectedGpuIndexKind = useChatRuntimeStore( (s) => s.selectedGpuIndexKind, ); - const hostMemoryMode = useChatRuntimeStore((s) => s.hostMemoryMode); const isGguf = activeGgufVariant != null || @@ -61,7 +60,6 @@ export function useActiveModelConfig(): ActiveModelConfigState { nCpuMoe, selectedGpuIds, selectedGpuIndexKind, - hostMemoryMode: hostMemoryMode ?? undefined, }; }, [ checkpoint, @@ -78,7 +76,6 @@ export function useActiveModelConfig(): ActiveModelConfigState { nCpuMoe, selectedGpuIds, selectedGpuIndexKind, - hostMemoryMode, ]); return { checkpoint, isGguf, config }; diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index edb8ecf5c7..d1d7a2d50e 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -64,7 +64,6 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { : config.selectedGpuIndexKind === undefined ? "physical" : config.selectedGpuIndexKind, - hostMemoryMode: config.hostMemoryMode ?? null, }); } @@ -98,7 +97,6 @@ export function currentRuntimePerModelConfig( nCpuMoe: s.nCpuMoe, selectedGpuIds: s.selectedGpuIds, selectedGpuIndexKind: s.selectedGpuIndexKind, - hostMemoryMode: s.hostMemoryMode ?? undefined, }; } @@ -133,9 +131,6 @@ export function gpuFieldsSignature(config: PerModelConfig): string { ? "all" : [...config.selectedGpuIds].sort((a, b) => a - b).join(","), config.selectedGpuIndexKind ?? "untagged", - config.hostMemoryMode == null || config.hostMemoryMode === "default" - ? "unset" - : config.hostMemoryMode, ].join("|"); } diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts index f69623179a..839d22df8d 100644 --- a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts @@ -9,7 +9,6 @@ import { normalizeModelIdentity, } from "./model-identity"; import type { GpuIndexKind } from "@/hooks/use-gpu-info"; -import type { HostMemoryMode } from "@/features/chat"; export interface PerModelConfig { customContextLength: number | null; @@ -28,7 +27,6 @@ export interface PerModelConfig { nCpuMoe?: number; selectedGpuIds?: number[] | null; selectedGpuIndexKind?: GpuIndexKind | null; - hostMemoryMode?: HostMemoryMode; } export const DEFAULT_PER_MODEL_CONFIG: PerModelConfig = { @@ -103,7 +101,6 @@ const STORED_CONFIG_FIELDS = new Set([ "nCpuMoe", "selectedGpuIds", "selectedGpuIndexKind", - "hostMemoryMode", ]); function normalizeGpuFields(partial: RawConfig): { @@ -112,7 +109,6 @@ function normalizeGpuFields(partial: RawConfig): { nCpuMoe?: number; selectedGpuIds?: number[] | null; selectedGpuIndexKind?: GpuIndexKind | null; - hostMemoryMode?: HostMemoryMode; } { const out: { gpuMemoryMode?: "auto" | "manual"; @@ -120,7 +116,6 @@ function normalizeGpuFields(partial: RawConfig): { nCpuMoe?: number; selectedGpuIds?: number[] | null; selectedGpuIndexKind?: GpuIndexKind | null; - hostMemoryMode?: HostMemoryMode; } = {}; // Only "manual" is a real override; persisting "auto" would pin the model and // stop it following later changes to the global GPU Memory preference. @@ -157,12 +152,6 @@ function normalizeGpuFields(partial: RawConfig): { ) { out.selectedGpuIndexKind = partial.selectedGpuIndexKind; } - if ( - partial.hostMemoryMode === "pinned" || - partial.hostMemoryMode === "resident" - ) { - out.hostMemoryMode = partial.hostMemoryMode; - } return out; } @@ -331,10 +320,6 @@ function legacyEntryToConfig(raw: Record): PerModelConfig { : Array.isArray(raw.selectedGpuIds) ? (raw.selectedGpuIds as number[]) : undefined, - hostMemoryMode: - raw.hostMemoryMode === "pinned" || raw.hostMemoryMode === "resident" - ? raw.hostMemoryMode - : undefined, }); } @@ -638,8 +623,7 @@ function gpuFieldsAtDefault(config: PerModelConfig): boolean { (config.gpuMemoryMode ?? "auto") === "auto" && (config.gpuLayers == null || config.gpuLayers < 0) && (config.nCpuMoe == null || config.nCpuMoe === 0) && - config.selectedGpuIds == null && - (config.hostMemoryMode ?? "default") === "default" + config.selectedGpuIds == null ); } diff --git a/tests/studio/test_chat_preset_load_config.py b/tests/studio/test_chat_preset_load_config.py index bb802df56e..1588c7d96d 100644 --- a/tests/studio/test_chat_preset_load_config.py +++ b/tests/studio/test_chat_preset_load_config.py @@ -53,20 +53,6 @@ def test_apply_skips_missing_load_config(): assert "if (p.loadConfig)" in sheet -def test_host_memory_mode_round_trips_through_presets(): - source = _read("studio/frontend/src/features/chat/presets/preset-load-config.ts") - assert '| "hostMemoryMode"' in source - assert "partial.hostMemoryMode ===" in source - assert "snapshot.hostMemoryMode" in source - assert "hostMemoryMode: config.hostMemoryMode" in source - assert 'result.hostMemoryMode === "default"' in source - assert 'config.hostMemoryMode === "pinned"' in source - assert 'config.hostMemoryMode === "resident"' in source - - routes = _read("studio/backend/routes/chat_history.py") - assert 'hostMemoryMode: Optional[Literal["default", "pinned", "resident"]]' in routes - - def test_hydration_does_not_replay_preset_load_config(): store = _read("studio/frontend/src/features/chat/stores/chat-runtime-store.ts") assert "applyPresetLoadConfig(activeDefinition.loadConfig)" not in store diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index db4863dbbd..7bffa6e54c 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -196,8 +196,6 @@ def test_compare_load_uses_each_models_gpu_config(): assert "ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode" in src assert "ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers" in src assert "ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe" in src - assert "ownConfig.hostMemoryMode ?? null" in src - assert "compareLoadKnobs.hostMemoryMode" not in src assert "if (ownConfig.selectedGpuIds != null)" in src assert "ownConfig.selectedGpuIndexKind," in src for field in ( @@ -499,12 +497,6 @@ def test_vulkan_gguf_devices_do_not_replace_global_gpu_info(): assert "data?.gpu?.gguf_devices ?? data?.gpu?.devices" in frontend -def test_model_switch_clears_host_memory_mode_before_validation(): - src = _read("features/chat/hooks/use-chat-model-runtime.ts") - assert "const validateHostMemoryMode = resetsPerModelSettings" in src - assert "host_memory_mode: validateHostMemoryMode" in src - - def test_diffusion_picker_hides_and_clears_unsupported_memory_modes(): api = _read("features/chat/api/chat-api.ts") assert "isDiffusion: res.is_diffusion ?? false" in api @@ -519,7 +511,6 @@ def test_diffusion_picker_hides_and_clears_unsupported_memory_modes(): 'gpuMemoryMode: "auto"', "gpuLayers: undefined", "nCpuMoe: undefined", - "hostMemoryMode: undefined", "selectedGpuIds: undefined", "selectedGpuIndexKind: undefined", ): From b21fb8e5c36eb90dea4cf48ff974e923b77cbf5d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:49:30 +0000 Subject: [PATCH 060/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/models/inference.py | 2 ++ studio/backend/tests/test_llama_cpp_mtp_detection.py | 1 + studio/backend/tests/test_tp_vision_regression.py | 4 +--- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 3f5226b96c..3b13f0e153 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -513,6 +513,8 @@ class LoadResponse(BaseModel): "or None for automatic selection." ), ) + + class UnloadResponse(BaseModel): """Response after unloading a model""" diff --git a/studio/backend/tests/test_llama_cpp_mtp_detection.py b/studio/backend/tests/test_llama_cpp_mtp_detection.py index 7eef857330..27c1b17a85 100644 --- a/studio/backend/tests/test_llama_cpp_mtp_detection.py +++ b/studio/backend/tests/test_llama_cpp_mtp_detection.py @@ -820,6 +820,7 @@ _CACHE_FLAGS_HELP = """\ --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) diff --git a/studio/backend/tests/test_tp_vision_regression.py b/studio/backend/tests/test_tp_vision_regression.py index 86813a1806..f49fe9dbf7 100644 --- a/studio/backend/tests/test_tp_vision_regression.py +++ b/studio/backend/tests/test_tp_vision_regression.py @@ -869,8 +869,6 @@ def test_empty_gpu_ids_dedupes_without_stripping_device(): gpu_ids = [], llama_extra_args = ["--device", "Vulkan3", "--top-k", "5"], ) - backend = _dedup_loaded_backend( - extra_args = ["--device", "Vulkan3", "--top-k", "5"] - ) + backend = _dedup_loaded_backend(extra_args = ["--device", "Vulkan3", "--top-k", "5"]) backend._gpu_ids = None assert inference_routes._request_matches_loaded_settings(req, backend) is True From 5dc08312b3a9da2b661c864be30cc29863b66a9f Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:51:33 -0300 Subject: [PATCH 061/110] Correct GPU placement comments --- studio/backend/core/inference/llama_cpp.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index e475a3a553..1986140868 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -8153,8 +8153,8 @@ class LlamaCppBackend: ", ".join(unsupported_cache_flags), ) - # Vulkan pins via --device (a cmd arg), before user extras so a user - # --device wins. Fall back to raw ids when the fit did not narrow. + # Vulkan pins via --device. Fall back to the requested IDs when + # the fit did not narrow the selection. _vulkan_pin_ids = gpu_indices if gpu_indices is not None else (gpu_ids or None) # Record the pin actually applied (fit-narrowed gpu_indices, else the raw @@ -8193,9 +8193,8 @@ class LlamaCppBackend: if is_vulkan_backend and _vulkan_pin_ids is not None: cmd += LlamaCppBackend._vulkan_pin_args(_vulkan_pin_ids) - # User pass-through args go last so llama.cpp's last-wins parsing - # lets the user override Unsloth's auto-set flags. Already - # validated by the route via validate_extra_args(). + # User pass-through args go last. Placement flags are removed + # below when the Studio picker owns the GPU selection. if extra_args: _emit_extra_args = list(extra_args) if gpu_ids is not None: From 8c974d5917147cd1100b7940b7cd9838fc5eaec6 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:04:06 -0300 Subject: [PATCH 062/110] Fix DiffusionGemma GPU selection capabilities --- .../frontend/src/features/chat/chat-page.tsx | 4 ++ .../chat/hooks/use-chat-model-runtime.ts | 25 +++++--- .../chat/stores/chat-runtime-store.ts | 9 ++- .../hub/catalog/sampling-settings-dialog.tsx | 7 +- .../components/model-config-page.tsx | 64 +++++++++++++------ .../components/model-selector.tsx | 3 +- .../components/model-selector/types.ts | 2 + .../model-config/apply-per-model-config.ts | 9 ++- studio/frontend/src/hooks/use-gpu-info.ts | 25 ++++++-- 9 files changed, 105 insertions(+), 43 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 976fc84f80..15d9a597d6 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -2337,6 +2337,7 @@ export function ChatPage({ }); const hasAppliedConfig = applyModelLoadConfigToRuntime( selection.config ?? rememberedConfigFor(selection), + { isDiffusion: selection.isDiffusion }, ); await selectModel({ ...selection, @@ -2676,6 +2677,7 @@ export function ChatPage({ isDownloaded: meta?.isDownloaded || isSameLoadedModel, expectedBytes: meta?.expectedBytes, isGguf: meta?.isGguf, + isDiffusion: meta?.isDiffusion, config: meta?.config, nativePathToken: meta?.nativePathToken, nativePathExpiresAtMs: meta?.nativePathExpiresAtMs, @@ -2718,6 +2720,7 @@ export function ChatPage({ nativePathToken: nativeToken ?? undefined, nativePathExpiresAtMs: nativeExpiry, isGguf: activeModelIsGguf, + isDiffusion: activeModelIsDiffusion, isDownloaded: true, config, forceReload: true, @@ -2728,6 +2731,7 @@ export function ChatPage({ activeGgufVariant, activeModelIsLora, activeModelIsGguf, + activeModelIsDiffusion, handleCheckpointChange, ], ); diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index dd34b72903..3d2bee141b 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -87,6 +87,8 @@ export type SelectedModelInput = { /** Direct local .gguf file (no HF variant / native token) — still a GGUF * source, so the staging flow treats it as one. */ isGguf?: boolean; + /** Staged metadata confirmed the separate DiffusionGemma runner. */ + isDiffusion?: boolean; throwOnError?: boolean; /** Keep the current speculative-decoding choice across the model switch * instead of resetting it to the standing preference. */ @@ -487,15 +489,23 @@ export function useChatModelRuntime() { : selection.nativePathExpiresAtMs ?? null; const explicitIsGguf = typeof selection === "string" ? undefined : selection.isGguf; + const isDiffusion = + typeof selection === "string" ? false : selection.isDiffusion === true; + const restorePreviousConfig = () => { + if (typeof selection !== "string" && selection.previousConfig) { + applyPerModelConfigToRuntime(selection.previousConfig, { + isDiffusion: + useChatRuntimeStore.getState().loadedIsDiffusion, + }); + } + }; const throwOnError = typeof selection === "string" ? false : selection.throwOnError ?? false; const keepSpeculative = typeof selection === "string" ? false : selection.keepSpeculative ?? false; const currentVariant = useChatRuntimeStore.getState().activeGgufVariant; if (!forceReload && (!modelId || (params.checkpoint === modelId && (ggufVariant ?? null) === (currentVariant ?? null)))) { - if (typeof selection !== "string" && selection.previousConfig) { - applyPerModelConfigToRuntime(selection.previousConfig); - } + restorePreviousConfig(); return; } // A load is already in flight. If it's this exact pick (id + variant + token), @@ -508,9 +518,7 @@ export function useChatModelRuntime() { loadingModelRef.current ?? useChatRuntimeStore.getState().loadingModelPick; if (inFlightLoad) { - if (typeof selection !== "string" && selection.previousConfig) { - applyPerModelConfigToRuntime(selection.previousConfig); - } + restorePreviousConfig(); const loadingSamePick = inFlightLoad.id === modelId && (inFlightLoad.ggufVariant ?? null) === (ggufVariant ?? null) && @@ -669,6 +677,7 @@ export function useChatModelRuntime() { let loadSelectedGpuIds = reconcilePersistedGpuIds( stateBeforeUnload.selectedGpuIds, stateBeforeUnload.selectedGpuIndexKind, + isDiffusion, ); let loadSpeculativeType = stateBeforeUnload.speculativeType; let loadSpecDraftNMax = stateBeforeUnload.specDraftNMax; @@ -1473,9 +1482,7 @@ export function useChatModelRuntime() { resetLoadingUi(); } } catch (error) { - if (typeof selection !== "string" && selection.previousConfig) { - applyPerModelConfigToRuntime(selection.previousConfig); - } + restorePreviousConfig(); if (abortCtrl.signal.aborted) return; // User cancelled, nothing to report resetLoadingUi(); const message = diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 6dafd8e22d..69382271e0 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -593,10 +593,11 @@ export function rebalanceSplit( export function reconcilePersistedGpuIds( ids: number[] | null, savedIndexKind?: GpuIndexKind | null, + forDiffusion = false, ): number[] | null { if (ids == null) return ids; if (arguments.length >= 2) { - const currentIndexKind = cachedPinnableGpuIndexKind(); + const currentIndexKind = cachedPinnableGpuIndexKind(forDiffusion); const expectedIndexKind = savedIndexKind === undefined ? "physical" : savedIndexKind; if ( @@ -607,7 +608,7 @@ export function reconcilePersistedGpuIds( return null; } } - const pinnable = cachedPinnableGpuIndices(); + const pinnable = cachedPinnableGpuIndices(forDiffusion); if (pinnable === null) return ids; // cache not ready: can't validate, keep it const kept = ids.filter((i) => pinnable.includes(i)); return kept.length > 0 ? kept : null; @@ -666,7 +667,9 @@ export function loadedGpuMemoryFields(resp: { // gpu_ids remains the effective fitted subset for diagnostics. const reportedGpuIds = requestedGpuIdsFromResponse(resp); const gpuIndexKind = - reportedGpuIds == null ? null : cachedPinnableGpuIndexKind(); + reportedGpuIds == null + ? null + : cachedPinnableGpuIndexKind(resp.is_diffusion === true); // A numeric ID is unsafe to adopt or persist until discovery says whether it // is a physical CUDA/ROCm ID or a Vulkan ordinal. A later status refresh can // restore the requested pool after the shared system cache is warm. diff --git a/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx b/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx index 9b63b21f03..3c512a5512 100644 --- a/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx +++ b/studio/frontend/src/features/hub/catalog/sampling-settings-dialog.tsx @@ -134,7 +134,9 @@ export function SamplingSettingsButton({ className }: { className?: string }) { const previousConfig = currentRuntimePerModelConfig({ includeMaxSeqLength: true, }); - applyPerModelConfigToRuntime(config); + applyPerModelConfigToRuntime(config, { + isDiffusion: activeModelIsDiffusion, + }); void selectModel({ id: activeCheckpoint, source: "local", @@ -142,13 +144,14 @@ export function SamplingSettingsButton({ className }: { className?: string }) { nativePathToken: nativeToken ?? undefined, nativePathExpiresAtMs: nativeExpiry, isGguf: activeModelIsGguf, + isDiffusion: activeModelIsDiffusion, isDownloaded: true, keepSpeculative: true, previousConfig, forceReload: true, }); }, - [selectModel, activeModelIsGguf], + [selectModel, activeModelIsGguf, activeModelIsDiffusion], ); // Reasoning + tools: same store bindings as the chat page. diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 6788132be5..0fb3c15141 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -19,7 +19,11 @@ import { readPersistedSpeculativeType, useChatRuntimeStore, } from "@/features/chat"; -import { useGpuDevices } from "@/hooks/use-gpu-info"; +import { + type GpuIndexKind, + type SystemGpuDevice, + useGpuDevices, +} from "@/hooks/use-gpu-info"; import { ChevronDownStandardIcon } from "@/lib/chevron-icons"; import { toast } from "@/lib/toast"; import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"; @@ -88,15 +92,17 @@ function hasNonDefaultAdvanced(config: PerModelConfig): boolean { function withoutUnsupportedDiffusionSettings( config: PerModelConfig, + currentGpuIndexKind: GpuIndexKind | null = null, ): PerModelConfig { - const hasVulkanGpuPick = + const hasUnsupportedGpuPick = config.selectedGpuIds != null && - config.selectedGpuIndexKind === "vulkan"; + (config.selectedGpuIndexKind === "vulkan" || + currentGpuIndexKind === "vulkan"); if ( (config.gpuMemoryMode ?? "auto") === "auto" && config.gpuLayers == null && config.nCpuMoe == null && - !hasVulkanGpuPick + !hasUnsupportedGpuPick ) { return config; } @@ -105,7 +111,7 @@ function withoutUnsupportedDiffusionSettings( gpuMemoryMode: "auto", gpuLayers: undefined, nCpuMoe: undefined, - ...(hasVulkanGpuPick + ...(hasUnsupportedGpuPick ? { selectedGpuIds: undefined, selectedGpuIndexKind: undefined, @@ -114,6 +120,16 @@ function withoutUnsupportedDiffusionSettings( }; } +function gpuIndexKindOf( + gpuDevices: SystemGpuDevice[], +): GpuIndexKind | null { + return gpuDevices.length > 0 && + gpuDevices[0].indexKind !== null && + gpuDevices.every((device) => device.indexKind === gpuDevices[0].indexKind) + ? gpuDevices[0].indexKind + : null; +} + function ChatTemplateSetting({ config, onEditTemplate, @@ -260,14 +276,15 @@ function GpuMemorySettings({ layerCount, moeLayerCount, isDiffusion, + gpuDevices, }: { config: PerModelConfig; update: (patch: Partial) => void; layerCount: number | null; moeLayerCount: number | null; isDiffusion: boolean; + gpuDevices: SystemGpuDevice[]; }) { - const gpuDevices = useGpuDevices(); const mode = config.gpuMemoryMode ?? "auto"; const isManual = mode === "manual"; const gpuLayers = config.gpuLayers ?? GPU_LAYERS_AUTO; @@ -280,20 +297,16 @@ function GpuMemorySettings({ const moeLayersMax = moeLayerCount ?? 0; const showMoeSlider = isManual && !autoLayers && moeLayersMax > 0; const selectedGpuIds = config.selectedGpuIds ?? null; - const gpuIndexKind = - gpuDevices.length > 0 && - gpuDevices[0].indexKind !== null && - gpuDevices.every((device) => device.indexKind === gpuDevices[0].indexKind) - ? gpuDevices[0].indexKind - : null; + const gpuIndexKind = gpuIndexKindOf(gpuDevices); const singleGpuInUse = (selectedGpuIds ?? gpuDevices.map((device) => device.index)).length <= 1; // Multi-GPU only, with one backend-declared index namespace. null = all. const showGpuPicker = - !(isDiffusion && gpuIndexKind === "vulkan") && gpuDevices.length > 1 && gpuIndexKind !== null && - gpuDevices.every((d) => d.pinnable); + gpuDevices.every((d) => + isDiffusion ? d.diffusionPinnable : d.pinnable, + ); const isGpuChecked = (index: number) => selectedGpuIds === null || selectedGpuIds.includes(index); const toggleGpu = (index: number) => { @@ -440,6 +453,7 @@ function GgufAdvancedSettings({ layerCount, moeLayerCount, isDiffusion, + gpuDevices, }: { config: PerModelConfig; update: (patch: Partial) => void; @@ -449,6 +463,7 @@ function GgufAdvancedSettings({ layerCount: number | null; moeLayerCount: number | null; isDiffusion: boolean; + gpuDevices: SystemGpuDevice[]; }) { return ( <> @@ -578,6 +593,7 @@ function GgufAdvancedSettings({ layerCount={layerCount} moeLayerCount={moeLayerCount} isDiffusion={isDiffusion} + gpuDevices={gpuDevices} /> @@ -588,7 +604,7 @@ function GgufAdvancedSettings({ interface ModelConfigPageProps { target: ModelPickTarget; onBack?: () => void; - onRun: (config: PerModelConfig) => void; + onRun: (config: PerModelConfig, isDiffusion: boolean) => void; loadedConfig?: PerModelConfig | null; loadedContextLength?: number | null; initialConfig?: PerModelConfig | null; @@ -618,6 +634,8 @@ export function ModelConfigPage({ const loadedMaxContextLength = useChatRuntimeStore( (s) => s.ggufMaxContextLength, ); + const gpuDevices = useGpuDevices(); + const gpuIndexKind = gpuIndexKindOf(gpuDevices); const resolveInitial = () => { const resolved = resolveInitialConfig(target.id, target.ggufVariant); if (loadedConfig) { @@ -636,7 +654,7 @@ export function ModelConfigPage({ const [initial] = useState(resolveInitial); const [config, setConfig] = useState(() => isDiffusion - ? withoutUnsupportedDiffusionSettings(initial.config) + ? withoutUnsupportedDiffusionSettings(initial.config, gpuIndexKind) : initial.config, ); const [remember, setRemember] = useState(() => initial.remembered); @@ -732,6 +750,13 @@ export function ModelConfigPage({ config.selectedGpuIds != null); const resolvedIsDiffusion = isDiffusion || stagedDims?.isDiffusion === true; + useEffect(() => { + if (resolvedIsDiffusion && gpuIndexKind === "vulkan") { + setConfig((current) => + withoutUnsupportedDiffusionSettings(current, gpuIndexKind), + ); + } + }, [resolvedIsDiffusion, gpuIndexKind]); const isMtp = config.speculativeType != null && @@ -763,7 +788,7 @@ export function ModelConfigPage({ update({ customContextLength: v }); const rawBaseline = loadedConfig ?? DEFAULT_PER_MODEL_CONFIG; const baseline = resolvedIsDiffusion - ? withoutUnsupportedDiffusionSettings(rawBaseline) + ? withoutUnsupportedDiffusionSettings(rawBaseline, gpuIndexKind) : rawBaseline; const atBaseline = perModelConfigsEqual(config, baseline); // An explicit customContextLength equal to the native ceiling is still an @@ -794,7 +819,7 @@ export function ModelConfigPage({ // remembers, pin that shown context so a later fresh load keeps the fitted // placement instead of sending native/0 for fixed layers and recreating the OOM. const loadableConfig = resolvedIsDiffusion - ? withoutUnsupportedDiffusionSettings(config) + ? withoutUnsupportedDiffusionSettings(config, gpuIndexKind) : config; const pinFixedLayerContext = target.isGguf && @@ -858,7 +883,7 @@ export function ModelConfigPage({ if (saveFailed) { toast.error("Couldn't save these settings, loading with them anyway."); } - onRun(loadConfig); + onRun(loadConfig, resolvedIsDiffusion); }; return ( @@ -954,6 +979,7 @@ export function ModelConfigPage({ layerCount={stagedDims?.layerCount ?? null} moeLayerCount={stagedDims?.moeLayerCount ?? null} isDiffusion={resolvedIsDiffusion} + gpuDevices={gpuDevices} /> )} diff --git a/studio/frontend/src/features/model-picker/components/model-selector.tsx b/studio/frontend/src/features/model-picker/components/model-selector.tsx index 9ae7e59a1f..32b6df238e 100644 --- a/studio/frontend/src/features/model-picker/components/model-selector.tsx +++ b/studio/frontend/src/features/model-picker/components/model-selector.tsx @@ -535,10 +535,11 @@ function ModelSelectorContent({ key={`${visibleConfigTarget.id}::${visibleConfigTarget.ggufVariant ?? ""}`} target={visibleConfigTarget} onBack={() => setConfigTarget(null)} - onRun={(config) => + onRun={(config, isDiffusion) => onSelect(visibleConfigTarget.id, { ...visibleConfigTarget.meta, config, + isDiffusion, forceReload: true, }) } diff --git a/studio/frontend/src/features/model-picker/components/model-selector/types.ts b/studio/frontend/src/features/model-picker/components/model-selector/types.ts index 9adf2d899e..59639157a6 100644 --- a/studio/frontend/src/features/model-picker/components/model-selector/types.ts +++ b/studio/frontend/src/features/model-picker/components/model-selector/types.ts @@ -37,6 +37,8 @@ export interface ModelSelectorChangeMeta { /** Direct local .gguf file picked without a variant (custom folder / LM * Studio). Marks it as a GGUF source for the deferred-load staging flow. */ isGguf?: boolean; + /** Staged metadata confirmed the separate DiffusionGemma runner. */ + isDiffusion?: boolean; config?: PerModelConfig; forceReload?: boolean; /** Native path token so an active-model reload can reopen a file-picked GGUF. */ diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index d1d7a2d50e..bab257e80d 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -20,7 +20,10 @@ function cleanTemplate(value: string | null | undefined): string | null { return value?.trim() ? value : null; } -export function applyPerModelConfigToRuntime(config: PerModelConfig): void { +export function applyPerModelConfigToRuntime( + config: PerModelConfig, + options: { isDiffusion?: boolean } = {}, +): void { // Fall back to the standing default when the model has no saved // maxSeqLength. maxSeqLength is the only per-model field carried on // params (the rest are reset below), so without this a model with no @@ -37,6 +40,7 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { ? reconcilePersistedGpuIds( config.selectedGpuIds, config.selectedGpuIndexKind, + options.isDiffusion, ) : null; useChatRuntimeStore.setState({ @@ -69,9 +73,10 @@ export function applyPerModelConfigToRuntime(config: PerModelConfig): void { export function applyModelLoadConfigToRuntime( config: PerModelConfig | null | undefined, + options: { isDiffusion?: boolean } = {}, ): boolean { const hasConfig = config != null; - applyPerModelConfigToRuntime(config ?? DEFAULT_PER_MODEL_CONFIG); + applyPerModelConfigToRuntime(config ?? DEFAULT_PER_MODEL_CONFIG, options); return hasConfig; } diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 6f2245f183..6b28924f84 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -24,6 +24,8 @@ export interface SystemGpuDevice { memoryFreeGb: number; /** Whether `index` is safe to send as gpu_ids. */ pinnable: boolean; + /** Whether the separate DiffusionGemma runner can use this physical ID. */ + diffusionPinnable: boolean; } export type GpuIndexKind = "physical" | "vulkan"; @@ -84,6 +86,8 @@ function toGpuInfo(data: SystemInfoResponse | null): GpuInfo { function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { // GGUF placement may use a different namespace from the global torch view. const pinnableBackend = data?.gpu?.gguf_gpu_ids_supported !== false; + const diffusionBackend = + data?.device_backend === "cuda" || data?.device_backend === "rocm"; return (data?.gpu?.gguf_devices ?? data?.gpu?.devices ?? []) .filter((d) => typeof d.index === "number") .map((d) => ({ @@ -98,6 +102,8 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { pinnable: pinnableBackend && (d.index_kind === "physical" || d.index_kind === "vulkan"), + diffusionPinnable: + diffusionBackend && d.index_kind === "physical", })); } @@ -145,19 +151,24 @@ export async function ensureGpuDeviceCache(): Promise { } /** Cached pinnable IDs, null before fetch, or [] when pinning is unavailable. */ -export function cachedPinnableGpuIndices(): number[] | null { +export function cachedPinnableGpuIndices( + forDiffusion = false, +): number[] | null { if (!cachedSystem) return null; - const pinnable = toGpuDevices(cachedSystem).filter((d) => d.pinnable); + const pinnable = toGpuDevices(cachedSystem).filter((d) => + forDiffusion ? d.diffusionPinnable : d.pinnable, + ); return pinnable.length > 1 ? pinnable.map((d) => d.index) : []; } /** Cached index namespace, undefined before fetch and null when unavailable. */ -export function cachedPinnableGpuIndexKind(): - | GpuIndexKind - | null - | undefined { +export function cachedPinnableGpuIndexKind( + forDiffusion = false, +): GpuIndexKind | null | undefined { if (!cachedSystem) return undefined; - const pinnable = toGpuDevices(cachedSystem).filter((d) => d.pinnable); + const pinnable = toGpuDevices(cachedSystem).filter((d) => + forDiffusion ? d.diffusionPinnable : d.pinnable, + ); const kinds = new Set(pinnable.map((d) => d.indexKind).filter((k) => k)); return pinnable.length > 1 && kinds.size === 1 ? ([...kinds][0] as GpuIndexKind) From abfe226dd051d9c824fb60264c97a2cacaa8f3ed Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:06:43 -0300 Subject: [PATCH 063/110] Make GPU selections own main device placement --- studio/backend/core/inference/llama_cpp.py | 11 ++++++----- studio/backend/core/inference/llama_server_args.py | 10 ++++++---- studio/backend/tests/test_llama_cpp_placement.py | 4 +++- studio/backend/tests/test_llama_server_args.py | 4 ++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1986140868..a980b1a24b 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3405,7 +3405,7 @@ class LlamaCppBackend: @staticmethod def _strip_device_extra_args(extra_args): - """Drop --device/-dev so gpu_ids remains authoritative.""" + """Drop device and main-GPU flags so gpu_ids remains authoritative.""" return strip_shadowing_flags( extra_args, strip_context = False, @@ -8198,12 +8198,12 @@ class LlamaCppBackend: if extra_args: _emit_extra_args = list(extra_args) if gpu_ids is not None: - # gpu_ids owns placement, so remove a competing --device. + # gpu_ids owns placement, so remove competing device flags. _emit_extra_args = self._strip_device_extra_args(extra_args) if _emit_extra_args != list(extra_args): logger.info( - "Dropped a user --device/-dev from extra args: explicit " - "gpu_ids owns device placement." + "Dropped user device placement flags from extra args: " + "explicit gpu_ids owns placement." ) cmd.extend(str(a) for a in _emit_extra_args) logger.info( @@ -8247,9 +8247,10 @@ class LlamaCppBackend: if _ct_raw and _ct_raw not in self._TENSOR_PARALLEL_KV_TYPES: env.pop(_ct_var, None) - # A gpu_ids pin also owns the inherited --device equivalent. + # A gpu_ids pin also owns inherited device placement. if gpu_ids is not None: env.pop("LLAMA_ARG_DEVICE", None) + env.pop("LLAMA_ARG_MAIN_GPU", None) # Windows + full offload: PASSIVE OMP + 2 threads stop # spin-wait burning CPU. CPU/partial offload keeps default diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index d02be9ab9a..0e313bd585 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -202,10 +202,12 @@ _MEMORY_MODE_FLAGS: frozenset[str] = frozenset( _SPLIT_MODE_FLAGS: frozenset[str] = frozenset({"-sm", "--split-mode"}) _TENSOR_SPLIT_FLAGS: frozenset[str] = frozenset({"-ts", "--tensor-split"}) _SPLIT_SHADOWING_FLAGS: frozenset[str] = _SPLIT_MODE_FLAGS | _TENSOR_SPLIT_FLAGS -# llama.cpp offload device list (--device / -dev). Opt-in (users may pass it under -# auto-select): stripped only when gpu_ids is set, so it can't override the pin and -# offload to a GPU the training guard never budgeted (#7188). -_DEVICE_FLAGS: frozenset[str] = frozenset({"--device", "-dev"}) +# llama.cpp placement flags. Opt-in (users may pass them under auto-select): +# stripped only when gpu_ids is set, so they cannot override the selected pool +# or choose a main GPU outside it (#7188). +_DEVICE_FLAGS: frozenset[str] = frozenset( + {"--device", "-dev", "--main-gpu", "-mg"} +) # GPU-offload flags. Stripped only when the GPU Memory mode owns offload # (manual emits --fit / --gpu-layers / --n-cpu-moe); in auto, a user's diff --git a/studio/backend/tests/test_llama_cpp_placement.py b/studio/backend/tests/test_llama_cpp_placement.py index ee5257c684..081fd44533 100644 --- a/studio/backend/tests/test_llama_cpp_placement.py +++ b/studio/backend/tests/test_llama_cpp_placement.py @@ -425,8 +425,9 @@ def test_gpu_ids_preserved_on_fit_fallback(tmp_path): @pytest.mark.parametrize("gpu_ids,scrubbed", [([1, 2], True), (None, False)]) def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_ids, scrubbed): - """Scrub inherited LLAMA_ARG_DEVICE only when gpu_ids owns placement.""" + """Scrub inherited placement variables only when gpu_ids owns placement.""" monkeypatch.setenv("LLAMA_ARG_DEVICE", "CUDA3") + monkeypatch.setenv("LLAMA_ARG_MAIN_GPU", "3") gguf = tmp_path / "model.gguf" _write_minimal_gguf(gguf) @@ -476,6 +477,7 @@ def test_gpu_ids_scrubs_inherited_llama_arg_device(tmp_path, monkeypatch, gpu_id assert captured_envs, "llama-server was not spawned" assert ("LLAMA_ARG_DEVICE" not in captured_envs[-1]) == scrubbed + assert ("LLAMA_ARG_MAIN_GPU" not in captured_envs[-1]) == scrubbed def test_vulkan_gpu_ids_used_as_direct_ordinals_not_remapped(tmp_path): diff --git a/studio/backend/tests/test_llama_server_args.py b/studio/backend/tests/test_llama_server_args.py index df77d7dd0d..3907d0e0fb 100644 --- a/studio/backend/tests/test_llama_server_args.py +++ b/studio/backend/tests/test_llama_server_args.py @@ -400,8 +400,8 @@ def test_strip_shadowing_flags_keeps_device_by_default(): def test_strip_shadowing_flags_drops_device_when_requested(): - # strip_device drops --device/-dev + value when explicit gpu_ids owns placement. - for flag in ("--device", "-dev"): + # strip_device drops device placement flags when gpu_ids owns placement. + for flag in ("--device", "-dev", "--main-gpu", "-mg"): out = strip_shadowing_flags( [flag, "Vulkan1", "--top-k", "20"], strip_context = False, From 6d2f0359257450037a3f3a4a3855acbfb2a62d0e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:07:20 +0000 Subject: [PATCH 064/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_server_args.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index 0e313bd585..b130ca3f6d 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -205,9 +205,7 @@ _SPLIT_SHADOWING_FLAGS: frozenset[str] = _SPLIT_MODE_FLAGS | _TENSOR_SPLIT_FLAGS # llama.cpp placement flags. Opt-in (users may pass them under auto-select): # stripped only when gpu_ids is set, so they cannot override the selected pool # or choose a main GPU outside it (#7188). -_DEVICE_FLAGS: frozenset[str] = frozenset( - {"--device", "-dev", "--main-gpu", "-mg"} -) +_DEVICE_FLAGS: frozenset[str] = frozenset({"--device", "-dev", "--main-gpu", "-mg"}) # GPU-offload flags. Stripped only when the GPU Memory mode owns offload # (manual emits --fit / --gpu-layers / --n-cpu-moe); in auto, a user's From d8e2b99f0fcebc1a772c1649602eb2eef8fa9ddf Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Sat, 25 Jul 2026 02:24:19 -0300 Subject: [PATCH 065/110] Trim GGUF placement regression coverage --- .../tests/test_chat_load_during_training.py | 244 ----- studio/backend/tests/test_gpu_selection.py | 131 --- .../backend/tests/test_llama_cpp_placement.py | 921 +++--------------- studio/backend/tests/test_mtp_vram_budget.py | 14 - .../tests/test_tp_vision_regression.py | 19 - tests/studio/test_model_picker_contracts.py | 8 +- 6 files changed, 144 insertions(+), 1193 deletions(-) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 79c4fd226a..87aa39c169 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -305,84 +305,6 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertFalse(ok) self.assertEqual(info["reason"], "estimate_unavailable") - # ── Vulkan ordinal budgeting ───────────────────────────────────────────── - - def test_vulkan_build_budgets_least_free_gpu(self): - # The model fits even on the least-free card. - ok, info, _ = self._run( - devices = _devices((0, 80, 10), (1, 80, 20)), - required_override = 5.0, - gpu_ids = [0], - gpu_ids_are_vulkan_ordinals = True, - ) - self.assertTrue(ok) - self.assertEqual(info["mode"], "gguf_vulkan") - - def test_vulkan_build_rejected_when_worst_card_too_full(self): - # The unknown mapping could select the near-full card. - ok, info, _ = self._run( - devices = _devices((0, 80, 10), (1, 80, 77)), - required_override = 10.0, - gpu_ids = [0], - gpu_ids_are_vulkan_ordinals = True, - ) - self.assertFalse(ok) - self.assertEqual(info["mode"], "gguf_vulkan") - - def test_cuda_indexed_build_allows_same_layout(self): - # CUDA IDs resolve physically, so the same aggregate can pass. - ok, info, _ = self._run( - devices = _devices((0, 80, 10), (1, 80, 77)), - required_override = 10.0, - gpu_ids = [0, 1], - gpu_ids_are_vulkan_ordinals = False, - ) - self.assertTrue(ok) - self.assertEqual(info["mode"], "gguf") - - def test_vulkan_build_no_visible_gpus_refuses(self): - # Vulkan build with no visible GPUs -> nothing to budget -> default-deny. - ok, info, _ = self._run( - devices = [], - required_override = 5.0, - gpu_ids = [0], - gpu_ids_are_vulkan_ordinals = True, - ) - self.assertFalse(ok) - self.assertEqual(info["reason"], "no_visible_gpus") - - def test_vulkan_multi_gpu_shards_budget_per_device(self): - # Two Vulkan devices split a model that cannot fit the least-free card alone. - ok_single, info_single, _ = self._run( - devices = _devices((0, 80, 10), (1, 80, 76)), - required_override = 2.0, - gpu_ids = [0], - gpu_ids_are_vulkan_ordinals = True, - ) - self.assertFalse(ok_single) - ok_multi, info_multi, _ = self._run( - devices = _devices((0, 80, 10), (1, 80, 76)), - required_override = 2.0, - gpu_ids = [0, 1], - gpu_ids_are_vulkan_ordinals = True, - ) - self.assertTrue(ok_multi) - self.assertEqual(info_multi["mode"], "gguf_vulkan") - self.assertEqual(info_multi["per_gpu_needed_gb"], round(6.3 / 2, 3)) - - def test_vulkan_aggregate_budgets_requested_subset_not_all_visible(self): - # Budget only the requested two-card subset, not all visible GPUs. - ok, info, _ = self._run( - devices = _devices((0, 80, 30), (1, 80, 30), (2, 80, 30), (3, 80, 30)), - required_override = 80.0, - gpu_ids = [0, 1], - gpu_ids_are_vulkan_ordinals = True, - ) - self.assertFalse(ok) - self.assertEqual(info["mode"], "gguf_vulkan") - self.assertEqual(info["usable_gb"], 92.5) - - # ── can_load_chat_during_training: device-independent paths ────────────────── @@ -555,15 +477,6 @@ class TestChatLoadGuardRoute(unittest.TestCase): ) self.assertIsNone(self.route._classify_diffusion_gguf(config)) - def test_uncached_normal_model_with_diffusion_in_name_remains_unknown(self): - config = SimpleNamespace( - identifier = "owner/stable-diffusion-prompt-model-GGUF", - gguf_hf_repo = "owner/stable-diffusion-prompt-model-GGUF", - gguf_variant = "Q4_K_M", - gguf_file = None, - ) - self.assertIsNone(self.route._classify_diffusion_gguf(config)) - def test_diffusion_detection_reuses_loader_metadata_probe(self): import tempfile @@ -601,28 +514,6 @@ class TestChatLoadGuardRoute(unittest.TestCase): with patch.object(self.route, "LlamaCppBackend", _Probe): self.assertFalse(self.route._classify_diffusion_gguf(config)) - def test_local_normal_gguf_in_diffusion_named_path_is_normal(self): - # A readable GGUF header overrides a diffusion-like path. - import tempfile - class _Probe: - is_diffusion = False - _architecture = "llama" - - def _read_gguf_metadata(self, _path): - pass - - with tempfile.TemporaryDirectory() as d: - model = Path(d) / "diffusion-experiments" / "qwen3.gguf" - model.parent.mkdir(parents = True) - model.write_bytes(b"GGUF") - config = SimpleNamespace( - identifier = "me/qwen3-diffusion-tests", - gguf_hf_repo = "me/qwen3-diffusion-tests", - gguf_file = str(model), - ) - with patch.object(self.route, "LlamaCppBackend", _Probe): - self.assertFalse(self.route._classify_diffusion_gguf(config)) - def test_manual_known_normal_gguf_bypasses_training_estimate(self): captured = [] config = SimpleNamespace(is_gguf = True) @@ -682,27 +573,6 @@ class TestChatLoadGuardRoute(unittest.TestCase): self.assertTrue(captured[0]["gpu_ids_are_vulkan_ordinals"]) self.assertIsNone(captured[0]["single_device_gpu"]) - def test_unclassified_gguf_on_cuda_build_keeps_single_device(self): - # Same unknown GGUF on a CUDA-indexed build: the Vulkan flag stays off and the - # single-device CUDA guard is preserved (no regression from the Vulkan gating) (#7188). - captured = [] - config = SimpleNamespace(is_gguf = True) - with ( - patch.object(self.route, "_classify_diffusion_gguf", return_value = None), - patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), - patch.object(self.route.LlamaCppBackend, "_diffusion_gpu_arg", return_value = "0"), - ): - self._guard( - config = config, - captured = captured, - training_active = True, - decision = (True, {"mode": "single_device"}), - requested_gpu_ids = [0, 1], - ) - self.assertEqual(len(captured), 1) - self.assertFalse(captured[0]["gpu_ids_are_vulkan_ordinals"]) - self.assertEqual(captured[0]["single_device_gpu"], "0") - def test_refuses_with_headroom_number(self): info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"} with self.assertRaises(HTTPException) as exc: @@ -978,52 +848,6 @@ class TestValidateRefusesDuringTraining(unittest.TestCase): response = asyncio.run(self.route.validate_model(request, current_subject = "u")) self.assertTrue(response.is_diffusion) - def test_gguf_validate_rejects_inherited_draft_device_before_unload(self): - # Validate inherited draft placement before the client unloads. - from models.inference import ValidateModelRequest - - request = ValidateModelRequest(model_path = "x.gguf", gpu_ids = [0]) - cfg = SimpleNamespace( - identifier = "x.gguf", - display_name = "x", - is_gguf = True, - is_lora = False, - is_vision = False, - path = None, - base_model = None, - gguf_variant = None, - ) - loaded = SimpleNamespace( - is_loaded = True, - model_identifier = "x.gguf", - extra_args = ["--spec-draft-device", "CUDA1"], - extra_args_source = ("x.gguf", None), - is_vulkan_build = lambda: False, - ) - captured = [] - with ( - patch.object( - self.route, - "_resolve_model_identifier_for_request", - return_value = ("x.gguf", "x.gguf", False), - ), - patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), - patch.object(self.route, "load_inference_config", return_value = {}), - patch.object(self.route, "_requires_trust_remote_code_for_model", return_value = False), - patch.object(self.route, "get_llama_cpp_backend", return_value = loaded), - patch.object( - self.route, - "_resolve_gguf_gpu_ids_for_request", - return_value = ([0], False), - ), - _stub_guard_deps(training_active = True, decision = (True, {}), captured = captured), - ): - with self.assertRaises(HTTPException) as ctx: - asyncio.run(self.route.validate_model(request, current_subject = "u")) - self.assertEqual(ctx.exception.status_code, 400) - self.assertIn("draft-model device", ctx.exception.detail) - self.assertEqual(captured, []) - def _validate_gguf_template( self, *, @@ -1288,74 +1112,6 @@ class TestLoadModelGuardIntegration(unittest.TestCase): inf._shutdown_subprocess.assert_not_called() llama.unload_model.assert_not_called() - def test_gguf_draft_device_gpu_rejected_under_gpu_ids_before_unload(self): - # Reject escaping draft placement before teardown. - import contextlib - from unittest.mock import MagicMock - from models.inference import LoadRequest - - inf = SimpleNamespace(active_model_name = None) - inf.unload_model = MagicMock() - inf._shutdown_subprocess = MagicMock() - llama = SimpleNamespace( - is_loaded = False, - model_identifier = None, - hf_variant = None, - is_vulkan_build = lambda: False, - ) - llama.unload_model = MagicMock() - cfg = SimpleNamespace( - is_gguf = True, - is_lora = False, - is_vision = False, - path = None, - base_model = None, - identifier = "x.gguf", - display_name = "x", - ) - request = LoadRequest( - model_path = "x.gguf", - gpu_ids = [0], - llama_extra_args = ["--spec-draft-device", "CUDA1"], - max_seq_length = 4096, - ) - captured = [] - with ( - patch("utils.transformers_version.latest_tier_active_for", return_value = False), - patch.object( - self.route, - "validate_extra_args", - return_value = ["--spec-draft-device", "CUDA1"], - ), - patch.object( - self.route, - "_resolve_model_identifier_for_request", - return_value = ("x.gguf", "x.gguf", False), - ), - patch.object(self.route, "resolve_effective_chat_template_override", return_value = None), - patch.object( - self.route, - "_resolve_gguf_gpu_ids_for_request", - return_value = ([0], False), - ), - patch.object(self.route, "get_inference_backend", return_value = inf), - patch.object(self.route, "get_llama_cpp_backend", return_value = llama), - patch.object(self.route, "_hf_offline_if_dns_dead", lambda: contextlib.nullcontext()), - patch.object(self.route.ModelConfig, "from_identifier", return_value = cfg), - _stub_guard_deps(training_active = True, decision = (True, {}), captured = captured), - ): - with self.assertRaises(HTTPException) as exc: - asyncio.run( - self.route.load_model(request, fastapi_request = MagicMock(), current_subject = "u") - ) - - self.assertEqual(exc.exception.status_code, 400) - self.assertIn("draft-model device", exc.exception.detail) - self.assertEqual(captured, []) - inf.unload_model.assert_not_called() - inf._shutdown_subprocess.assert_not_called() - llama.unload_model.assert_not_called() - def test_gguf_inherited_draft_device_rejected_under_gpu_ids(self): # Check effective inherited extras, not only the raw request. import contextlib diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index d1319ef00e..8e4eeaf09e 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1152,71 +1152,6 @@ class TestRouteErrors(unittest.TestCase): self.assertEqual(exc_info.exception.status_code, 400) self.assertIn("cpu-only build", exc_info.exception.detail.lower()) - def test_cpu_only_llama_build_defers_reject_for_unknown_diffusion_gguf(self): - # A remote GGUF may prove to use the independent diffusion runner only - # after download, so the route must not reject it as llama.cpp yet. - import utils.hardware as hardware_pkg - - inference_route = _load_route_module( - "inference_route_module_for_diffusion_gpu_ids_test", - "routes/inference.py", - ) - request = LoadRequest(model_path = "unsloth/diffusion.gguf", gpu_ids = [0, 1]) - model_config = SimpleNamespace( - is_gguf = True, - is_lora = False, - gguf_hf_repo = None, - gguf_file = "/tmp/diffusion.gguf", - gguf_mmproj_file = None, - gguf_variant = None, - identifier = "unsloth/diffusion.gguf", - display_name = "unsloth/diffusion.gguf", - is_vision = False, - is_audio = False, - audio_type = None, - has_audio_input = False, - ) - fake_backend = SimpleNamespace( - is_loaded = False, - model_identifier = None, - is_vulkan_build = lambda: False, - _backend_lacks_gpu_lib = lambda *a, **k: True, - ) - with ( - patch.object( - inference_route, - "ModelConfig", - SimpleNamespace(from_identifier = lambda **_kwargs: model_config), - ), - patch.object( - inference_route, - "_classify_diffusion_gguf", - return_value = None, - ), - patch.object( - _hw_module, - "resolve_requested_gpu_ids", - side_effect = ValueError("SENTINEL_AFTER_GATE"), - ), - patch.object(inference_route, "_guard_chat_load_against_training", return_value = None), - patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), - patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext), - patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend), - patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CUDA), - ): - with self.assertRaises(HTTPException) as exc_info: - asyncio.run( - inference_route._load_model_impl( - request, - SimpleNamespace( - app = SimpleNamespace(state = SimpleNamespace(llama_parallel_slots = 1)), - ), - current_subject = "test-user", - ) - ) - self.assertNotIn("cpu-only build", exc_info.exception.detail.lower()) - self.assertIn("SENTINEL_AFTER_GATE", exc_info.exception.detail) - def test_diffusion_gguf_on_vulkan_build_rejects_ordinal_pin(self): # The GGUF picker supplies Vulkan ordinals, which cannot be reinterpreted # as the CUDA physical IDs used by the diffusion runner. @@ -1278,72 +1213,6 @@ class TestRouteErrors(unittest.TestCase): self.assertEqual(exc_info.exception.status_code, 400) self.assertIn("no defined mapping", exc_info.exception.detail) - def test_diffusion_gguf_gpu_ids_require_cuda(self): - import utils.hardware as hardware_pkg - - inference_route = _load_route_module( - "inference_route_module_for_diffusion_non_cuda_test", - "routes/inference.py", - ) - config = SimpleNamespace(is_gguf = True) - fake_backend = SimpleNamespace(is_vulkan_build = lambda: True) - with ( - patch.object(inference_route, "_classify_diffusion_gguf", return_value = True), - patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend), - patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), - patch.object(hardware_pkg, "get_device", return_value = hardware_pkg.DeviceType.CPU), - patch.object( - _hw_module, - "resolve_requested_gpu_ids", - side_effect = AssertionError("non-CUDA diffusion must reject before resolving"), - ), - ): - with self.assertRaises(HTTPException) as exc_info: - asyncio.run(inference_route._resolve_gguf_gpu_ids_for_request(config, [0])) - - self.assertEqual(exc_info.exception.status_code, 400) - self.assertIn("requires CUDA", exc_info.exception.detail) - - def test_inherited_extras_preserve_memory_flags_when_mode_omitted(self): - # Omission preserves memory flags while explicit context still owns -c. - inference_route = _load_route_module( - "inference_route_module_for_inherit_memflags_keep_test", - "routes/inference.py", - ) - model_id = "unsloth/test.gguf" - fake_backend = SimpleNamespace( - extra_args = ["--mlock", "--no-mmap", "-c", "4096"], - extra_args_source = (model_id, None), - ) - config = SimpleNamespace(is_gguf = True, gguf_variant = None) - request = LoadRequest(model_path = model_id, max_seq_length = 8192) - with patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend): - out = inference_route._resolve_inherited_extra_args(request, config, model_id, None) - self.assertIn("--mlock", out) - self.assertIn("--no-mmap", out) - self.assertNotIn("-c", out) # first-class max_seq_length still strips the inherited -c - - def test_inherited_extras_strip_memory_flags_when_env_overrides(self): - inference_route = _load_route_module( - "inference_route_module_for_inherit_memflags_strip_test", - "routes/inference.py", - ) - model_id = "unsloth/test.gguf" - fake_backend = SimpleNamespace( - extra_args = ["--mlock", "--no-mmap", "--top-k", "20"], - extra_args_source = (model_id, None), - ) - config = SimpleNamespace(is_gguf = True, gguf_variant = None) - request = LoadRequest(model_path = model_id) - with ( - patch.object(inference_route, "get_llama_cpp_backend", return_value = fake_backend), - patch.dict(os.environ, {"LLAMA_ARG_MLOCK": "1"}, clear = False), - ): - out = inference_route._resolve_inherited_extra_args(request, config, model_id, None) - self.assertNotIn("--mlock", out) - self.assertNotIn("--no-mmap", out) - self.assertIn("--top-k", out) - def test_training_route_returns_400_for_invalid_gpu_ids(self): training_route = _load_route_module( "training_route_module_for_test", diff --git a/studio/backend/tests/test_llama_cpp_placement.py b/studio/backend/tests/test_llama_cpp_placement.py index 081fd44533..6aeb771a76 100644 --- a/studio/backend/tests/test_llama_cpp_placement.py +++ b/studio/backend/tests/test_llama_cpp_placement.py @@ -1,7 +1,7 @@ # 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 explicit GGUF GPU selection and placement.""" +"""Focused integration tests for explicit GGUF GPU placement.""" from __future__ import annotations @@ -9,762 +9,212 @@ import os import struct import subprocess import sys -import types as _types +import types from pathlib import Path from unittest.mock import patch -_REAL_POPEN = subprocess.Popen +import pytest _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) -def _install_stub_if_absent(name: str, build): - """Install a fallback stub without shadowing an importable module.""" +def _stub_module(name: str, **attrs): if name in sys.modules: return try: __import__(name) return except Exception: - sys.modules[name] = build() + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + sys.modules[name] = module -def _build_loggers_stub(): - mod = _types.ModuleType("loggers") - mod.get_logger = lambda name: __import__("logging").getLogger(name) - return mod - - -def _build_structlog_stub(): - mod = _types.ModuleType("structlog") - mod.get_logger = lambda *a, **k: __import__("logging").getLogger("stub") - return mod - - -def _build_httpx_stub(): - mod = _types.ModuleType("httpx") - for _exc_name in ( - "ConnectError", - "TimeoutException", - "ReadTimeout", - "ReadError", - "RemoteProtocolError", - "CloseError", - ): - setattr(mod, _exc_name, type(_exc_name, (Exception,), {})) - mod.Timeout = type("T", (), {"__init__": lambda s, *a, **k: None}) - mod.Client = type( - "Client", - (), - { - "__init__": lambda self, **kw: None, - "__enter__": lambda self: self, - "__exit__": lambda self, *a: None, - }, - ) - return mod - - -def _build_jwt_stub(): - mod = _types.ModuleType("jwt") - mod.decode = lambda *a, **k: {} - mod.ExpiredSignatureError = type("ExpiredSignatureError", (Exception,), {}) - mod.InvalidTokenError = type("InvalidTokenError", (Exception,), {}) - return mod - - -_install_stub_if_absent("loggers", _build_loggers_stub) -_install_stub_if_absent("structlog", _build_structlog_stub) -_install_stub_if_absent("httpx", _build_httpx_stub) -_install_stub_if_absent("jwt", _build_jwt_stub) - -import pytest +_stub_module("loggers", get_logger = lambda name: __import__("logging").getLogger(name)) +_stub_module("structlog", get_logger = lambda *a, **k: __import__("logging").getLogger("stub")) +_stub_module( + "jwt", + decode = lambda *a, **k: {}, + ExpiredSignatureError = type("ExpiredSignatureError", (Exception,), {}), + InvalidTokenError = type("InvalidTokenError", (Exception,), {}), +) +if "httpx" not in sys.modules: + try: + import httpx # noqa: F401 + except Exception: + module = types.ModuleType("httpx") + for name in ( + "ConnectError", + "TimeoutException", + "ReadTimeout", + "ReadError", + "RemoteProtocolError", + "CloseError", + ): + setattr(module, name, type(name, (Exception,), {})) + module.Timeout = type("Timeout", (), {"__init__": lambda self, *a, **k: None}) + module.Client = type( + "Client", + (), + { + "__init__": lambda self, **kwargs: None, + "__enter__": lambda self: self, + "__exit__": lambda self, *args: None, + }, + ) + sys.modules["httpx"] = module from core.inference.llama_cpp import LlamaCppBackend -_GGUF_MAGIC = 0x46554747 -_VTYPE_STRING = 8 +_REAL_POPEN = subprocess.Popen -def _enc_string(s: str) -> bytes: - b = s.encode("utf-8") - return struct.pack(" Path: + def string(value: str) -> bytes: + data = value.encode() + return struct.pack(" bytes: - return _enc_string(key) + struct.pack(" Path: - body = _enc_kv_string("general.architecture", arch) - header = struct.pack(" Date: Sat, 25 Jul 2026 05:25:17 +0000 Subject: [PATCH 066/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_chat_load_during_training.py | 1 + 1 file changed, 1 insertion(+) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 87aa39c169..fcbdaa2a8f 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -305,6 +305,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertFalse(ok) self.assertEqual(info["reason"], "estimate_unavailable") + # ── can_load_chat_during_training: device-independent paths ────────────────── From e8f8c2f6c6ba44f2258ba737f9484bb685b4c259 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:12:57 -0300 Subject: [PATCH 067/110] Remove orphaned memory policy and close Vulkan gaps --- studio/backend/core/inference/llama_cpp.py | 27 -------- .../core/inference/llama_server_args.py | 35 +--------- studio/backend/routes/inference.py | 30 +++++---- .../tests/test_chat_load_during_training.py | 25 ++++++- .../backend/tests/test_llama_server_args.py | 67 ------------------- .../frontend/src/features/chat/chat-page.tsx | 5 ++ .../src/features/chat/shared-composer.tsx | 17 ++--- .../components/model-config-page.tsx | 28 +++----- tests/studio/test_model_picker_contracts.py | 6 ++ 9 files changed, 72 insertions(+), 168 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1677530dc7..e6a54505dc 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -127,15 +127,6 @@ LLAMA_SERVER_NOT_FOUND_DETAIL = ( "then try again. (Advanced: set LLAMA_SERVER_PATH to an existing binary.)" ) -_LLAMA_MEMORY_MODE_ENV_VARS = ( - "LLAMA_ARG_LOAD_MODE", - "LLAMA_ARG_MLOCK", - "LLAMA_ARG_NO_MMAP", - "LLAMA_ARG_MMAP", - "LLAMA_ARG_DIO", -) - - # llama-server can serve HTTP 200 while running a model entirely on CPU when a # GPU backend fails to init (#5807 / #5106 / #5830). Classify the startup log so # Unsloth can warn. Priority: explicit "offloaded N/M layers to GPU" counts @@ -6473,12 +6464,6 @@ class LlamaCppBackend: ) self._stdout_thread.start() - @staticmethod - def _memory_mode_env_override(env: Optional[Mapping[str, str]] = None) -> bool: - """Whether the operator environment owns host-memory placement.""" - source = os.environ if env is None else env - return any(name in source for name in _LLAMA_MEMORY_MODE_ENV_VARS) - @_with_gguf_load_marker def load_model( self, @@ -6525,18 +6510,6 @@ class LlamaCppBackend: Returns True if the server started and the health check passed. """ - # LLAMA_ARG_* host-memory settings are operator overrides. When present, - # they also own raw pass-through flags so no request can shadow them. - if self._memory_mode_env_override() and extra_args: - extra_args = strip_shadowing_flags( - extra_args, - strip_context = False, - strip_cache = False, - strip_spec = False, - strip_template = False, - strip_split_mode = False, - strip_memory_mode = True, - ) # Raw load inputs so the runtime MTP-crash reload can replay this model # without MTP. Committed to _last_load_kwargs only on a healthy load. _pending_load_kwargs = { diff --git a/studio/backend/core/inference/llama_server_args.py b/studio/backend/core/inference/llama_server_args.py index b130ca3f6d..5239dd7dd6 100644 --- a/studio/backend/core/inference/llama_server_args.py +++ b/studio/backend/core/inference/llama_server_args.py @@ -177,21 +177,6 @@ _TEMPLATE_FLAGS: frozenset[str] = frozenset( "--no-jinja", } ) -# Host-memory flags can be removed when an operator supplied the corresponding -# LLAMA_ARG_* environment override. -_MEMORY_MODE_FLAGS: frozenset[str] = frozenset( - { - "-lm", - "--load-mode", - "--mlock", - "--no-mmap", - "--mmap", - "-dio", - "--direct-io", - "-ndio", - "--no-direct-io", - } -) # Multi-GPU split mode shadows the Tensor Parallelism toggle # (--split-mode tensor). Pass-through stays allowed so users keep the # row/none/layer modes the toggle doesn't expose, but it's stripped on @@ -219,12 +204,7 @@ _MOE_OFFLOAD_FLAGS: frozenset[str] = frozenset({"-ncmoe", "--n-cpu-moe", "-cmoe" _OFFLOAD_SHADOWING_FLAGS: frozenset[str] = _LAYER_OFFLOAD_FLAGS | _MOE_OFFLOAD_FLAGS _SHADOWING_FLAGS: frozenset[str] = ( - _CONTEXT_FLAGS - | _CACHE_FLAGS - | _SPEC_FLAGS - | _TEMPLATE_FLAGS - | _SPLIT_SHADOWING_FLAGS - | _MEMORY_MODE_FLAGS + _CONTEXT_FLAGS | _CACHE_FLAGS | _SPEC_FLAGS | _TEMPLATE_FLAGS | _SPLIT_SHADOWING_FLAGS ) # Shadowing flags that take no value -- strip the flag only, not the next token. @@ -235,13 +215,6 @@ _BOOLEAN_SHADOWING_FLAGS: frozenset[str] = frozenset( "--no-jinja", "-cmoe", "--cpu-moe", - "--mlock", - "--no-mmap", - "--mmap", - "-dio", - "--direct-io", - "-ndio", - "--no-direct-io", } ) @@ -478,7 +451,6 @@ def strip_shadowing_flags( strip_split_mode: bool = True, strip_tensor_split: bool = False, strip_offload: bool = False, - strip_memory_mode: bool = False, strip_device: bool = False, ) -> list[str]: """Strip flags that shadow first-class Unsloth settings. @@ -494,8 +466,7 @@ def strip_shadowing_flags( ``strip_tensor_split`` removes ``--tensor-split`` *alone*, so manual mode can replace an inherited per-GPU ratio while leaving the user's ``--split-mode`` row/none/layer choice intact. ``strip_device`` is enabled when ``gpu_ids`` - owns placement. ``strip_memory_mode`` is enabled only when an operator - ``LLAMA_ARG_*`` environment variable owns host-memory behavior. + owns placement. """ shadowing: set[str] = set() if strip_context: @@ -512,8 +483,6 @@ def strip_shadowing_flags( shadowing |= _TENSOR_SPLIT_FLAGS if strip_offload: shadowing |= _OFFLOAD_SHADOWING_FLAGS - if strip_memory_mode: - shadowing |= _MEMORY_MODE_FLAGS if strip_device: shadowing |= _DEVICE_FLAGS diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 12712ead5d..6e9c7a41fd 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3218,7 +3218,6 @@ def _request_matches_loaded_settings( # stored extras stripped the way the reload strips them, so an extras-driven # tensor load isn't seen as a mismatch that needlessly reloads the server. backend_extra = list(llama_backend.extra_args) if llama_backend.extra_args else [] - _strip_mem = LlamaCppBackend._memory_mode_env_override() effective_extra = ( request.llama_extra_args if request.llama_extra_args is not None @@ -3227,7 +3226,6 @@ def _request_matches_loaded_settings( strip_split_mode = _should_strip_split_mode(request, backend_extra), strip_tensor_split = _should_strip_tensor_split(request), strip_offload = request.gpu_memory_mode == "manual", - strip_memory_mode = _strip_mem, ) ) if not _tensor_parallel_matches_loaded( @@ -3312,14 +3310,12 @@ def _request_matches_loaded_settings( strip_split_mode = _should_strip_split_mode(request, backend_extra), strip_tensor_split = _should_strip_tensor_split(request), strip_offload = request.gpu_memory_mode == "manual", - strip_memory_mode = _strip_mem, ) != backend_extra ): return False else: # Compare against the managed flags that load_model actually persisted. - # Keep backend memory flags so an explicit mode can replace raw argv. _strip_dev = bool(request.gpu_ids) _request_extra = ( strip_shadowing_flags( @@ -3329,10 +3325,9 @@ def _request_matches_loaded_settings( strip_spec = False, strip_template = False, strip_split_mode = False, - strip_memory_mode = _strip_mem, strip_device = _strip_dev, ) - if (_strip_mem or _strip_dev) + if _strip_dev else list(request.llama_extra_args) ) if _request_extra != backend_extra: @@ -3948,10 +3943,13 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]: def _reject_draft_device_with_gpu_ids( - gpu_ids: Optional[List[int]], extra_args: Optional[list[str]] + gpu_ids: Optional[List[int]], + extra_args: Optional[list[str]], + *, + gpu_ids_are_vulkan_ordinals: bool, ) -> None: - """Reject a drafter pin that can escape the main model's GPU pool.""" - if not gpu_ids: + """Reject a physical drafter pin beside Vulkan-ordinal main placement.""" + if not gpu_ids or not gpu_ids_are_vulkan_ordinals: return draft_device = _extra_args_draft_device_pin(extra_args) if draft_device is not None: @@ -4221,8 +4219,6 @@ def _resolve_inherited_extra_args( or effective_chat_template_override is not None ), strip_split_mode = _should_strip_split_mode(request, stored_args), - # Operator LLAMA_ARG_* host-memory values own inherited raw argv. - strip_memory_mode = LlamaCppBackend._memory_mode_env_override(), # manual + per-GPU ratio emits its own --tensor-split; drop # an inherited one (appended last would override it) while # keeping the user's --split-mode row/none/layer choice. @@ -4568,7 +4564,11 @@ async def _load_model_impl( gguf_gpu_ids, gpu_ids_are_vulkan_ordinals, ) = await _resolve_gguf_gpu_ids_for_request(config, effective_gpu_ids) - _reject_draft_device_with_gpu_ids(gguf_gpu_ids, extra_llama_args) + _reject_draft_device_with_gpu_ids( + gguf_gpu_ids, + extra_llama_args, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, + ) if not config.is_gguf and _mlx_distributed_launch_detected(): raise HTTPException( status_code = 400, @@ -5165,7 +5165,11 @@ async def validate_model( config, effective_gpu_ids, ) - _reject_draft_device_with_gpu_ids(validated_gpu_ids, effective_extra_args) + _reject_draft_device_with_gpu_ids( + validated_gpu_ids, + effective_extra_args, + gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, + ) effective_load_in_4bit = _effective_load_in_4bit(config, request.load_in_4bit) # Both checks cover the [adapter, base] set (matching the scan route and workers): diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index fcbdaa2a8f..4a2022aad3 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -257,6 +257,18 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["mode"], "gguf_vulkan") self.assertEqual(info["usable_gb"], 18.5) + def test_vulkan_multi_gpu_enforces_per_device_floor(self): + # Aggregate capacity clears 15.5 GB, but one shard has less than half. + ok, info, _ = self._run( + devices = _devices((0, 80, 60), (1, 80, 78)), + required_override = 10.0, + gpu_ids = [0, 1], + gpu_ids_are_vulkan_ordinals = True, + ) + self.assertFalse(ok) + self.assertEqual(info["per_gpu_needed_gb"], 7.75) + self.assertEqual(info["min_free_gb"], 2.0) + def test_single_device_unresolved_token_sizes_against_worst_device(self): # Unknown single-device tokens use the least-free visible GPU. ok, info, _ = self._run( @@ -1066,6 +1078,13 @@ class TestLoadModelGuardIntegration(unittest.TestCase): def setUpClass(cls): cls.route = _load_inference_route() + def test_cuda_gpu_ids_allow_matching_draft_device(self): + self.route._reject_draft_device_with_gpu_ids( + [1], + ["--spec-draft-device", "CUDA0"], + gpu_ids_are_vulkan_ordinals = False, + ) + def test_refusal_409_and_no_unload(self): import contextlib from unittest.mock import MagicMock @@ -1113,7 +1132,7 @@ class TestLoadModelGuardIntegration(unittest.TestCase): inf._shutdown_subprocess.assert_not_called() llama.unload_model.assert_not_called() - def test_gguf_inherited_draft_device_rejected_under_gpu_ids(self): + def test_gguf_inherited_draft_device_rejected_under_vulkan_gpu_ids(self): # Check effective inherited extras, not only the raw request. import contextlib from unittest.mock import MagicMock @@ -1128,7 +1147,7 @@ class TestLoadModelGuardIntegration(unittest.TestCase): hf_variant = None, extra_args = ["--spec-draft-device", "CUDA1"], extra_args_source = ("x.gguf", None), - is_vulkan_build = lambda: False, + is_vulkan_build = lambda: True, ) llama.unload_model = MagicMock() cfg = SimpleNamespace( @@ -1160,7 +1179,7 @@ class TestLoadModelGuardIntegration(unittest.TestCase): patch.object( self.route, "_resolve_gguf_gpu_ids_for_request", - return_value = ([0], False), + return_value = ([0], True), ), patch.object(self.route, "get_inference_backend", return_value = inf), patch.object(self.route, "get_llama_cpp_backend", return_value = llama), diff --git a/studio/backend/tests/test_llama_server_args.py b/studio/backend/tests/test_llama_server_args.py index 3907d0e0fb..481cddac8f 100644 --- a/studio/backend/tests/test_llama_server_args.py +++ b/studio/backend/tests/test_llama_server_args.py @@ -394,7 +394,6 @@ def test_strip_shadowing_flags_keeps_device_by_default(): strip_spec = False, strip_template = False, strip_split_mode = False, - strip_memory_mode = False, ) assert out == ["--device", "Vulkan1", "--top-k", "20"] @@ -409,7 +408,6 @@ def test_strip_shadowing_flags_drops_device_when_requested(): strip_spec = False, strip_template = False, strip_split_mode = False, - strip_memory_mode = False, strip_device = True, ) assert out == ["--top-k", "20"], flag @@ -898,68 +896,3 @@ def test_strip_shadowing_flags_keeps_model_draft_without_spec(): strip_template = False, ) assert out == ["--model-draft", "/custom/mtp.gguf"] - - -# ── Host-memory environment override shadowing ─────────────────────────────── - - -def test_strip_shadowing_flags_drops_memory_mode_when_requested(): - out = strip_shadowing_flags( - [ - "--load-mode", - "dio", - "--mlock", - "--no-mmap", - "--mmap", - "--direct-io", - "--top-k", - "20", - ], - strip_memory_mode = True, - ) - assert out == ["--top-k", "20"] - - -def test_strip_shadowing_flags_keeps_memory_mode_when_not_requested(): - out = strip_shadowing_flags( - ["--mlock", "--no-mmap", "--mmap", "--top-k", "20"], - strip_memory_mode = False, - ) - assert out == ["--mlock", "--no-mmap", "--mmap", "--top-k", "20"] - - -def test_strip_shadowing_flags_default_keeps_memory_mode(): - # Host-memory stripping is opt-in: without an environment override, preserve - # the user's pass-through flags. - assert strip_shadowing_flags(["--mlock", "--no-mmap", "--mmap"]) == [ - "--mlock", - "--no-mmap", - "--mmap", - ] - - -def test_strip_split_mode_only_keeps_memory_mode(): - # Tensor->layer downgrade must not strip the user's memory mode choice. - assert strip_split_mode_only(["--mlock", "--no-mmap", "--mmap", "-sm", "tensor"]) == [ - "--mlock", - "--no-mmap", - "--mmap", - ] - - -def test_strip_shadowing_flags_drops_inverse_mmap_flag(): - # An inherited --mmap must not override an operator environment policy. - out = strip_shadowing_flags(["--mmap"], strip_memory_mode = True) - assert out == [] - - -@pytest.mark.parametrize("flag", ["--mlock", "--no-mmap", "--mmap"]) -def test_strip_memory_mode_valueless_preserves_next_token(flag): - # The memory-mode flags take no value; stripping must not consume the next token. - out = strip_shadowing_flags([flag, "--top-k", "40"], strip_memory_mode = True) - assert out == ["--top-k", "40"] - - -def test_strip_memory_mode_kept_without_environment_override(): - out = strip_shadowing_flags(["--mmap"], strip_memory_mode = False) - assert out == ["--mmap"] diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 91fabb9f17..71b9d83514 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -427,6 +427,7 @@ type CompareModelSelection = { id: string; isLora: boolean; ggufVariant?: string; + isDiffusion?: boolean; config?: PerModelConfig; }; @@ -774,6 +775,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ const globalCheckpoint = useChatRuntimeStore((s) => s.params.checkpoint); const globalGgufVariant = useChatRuntimeStore((s) => s.activeGgufVariant); + const globalIsDiffusion = useChatRuntimeStore((s) => s.loadedIsDiffusion); const active = useChatActive(); const compareRunning = useChatRuntimeStore( (s) => Object.keys(s.runningByThreadId).length > 0, @@ -782,6 +784,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ id: globalCheckpoint || "", isLora: false, ggufVariant: globalGgufVariant ?? undefined, + isDiffusion: globalIsDiffusion, }); const [model2, setModel2] = useState({ id: "", @@ -867,6 +870,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ id, isLora: meta.isLora, ggufVariant: meta.ggufVariant, + isDiffusion: meta.isDiffusion, config: meta.config, }) } @@ -897,6 +901,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ id, isLora: meta.isLora, ggufVariant: meta.ggufVariant, + isDiffusion: meta.isDiffusion, config: meta.config, }) } diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index d1ae6d4b68..33cce9a16d 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -421,6 +421,7 @@ type CompareModelSelection = { id: string; isLora: boolean; ggufVariant?: string; + isDiffusion?: boolean; config?: PerModelConfig; }; @@ -995,13 +996,8 @@ export function SharedComposer({ gpuLayers: store.gpuLayers, nCpuMoe: store.nCpuMoe, splitRatio: store.splitRatio, - // Reconcile the pick against the GPUs present now, like the model-switch - // path: an early remember-restore can hold a stale cross-host pick that - // /load would reject (the device cache is populated by send time). - selectedGpuIds: reconcilePersistedGpuIds( - store.selectedGpuIds, - store.selectedGpuIndexKind, - ), + selectedGpuIds: store.selectedGpuIds, + selectedGpuIndexKind: store.selectedGpuIndexKind, }; // Set when an accepted transformers install unloaded the active model // server-side; a later failure must then clear the stale checkpoint. @@ -1062,8 +1058,13 @@ export function SharedComposer({ ? reconcilePersistedGpuIds( ownConfig.selectedGpuIds, ownConfig.selectedGpuIndexKind, + sel.isDiffusion === true, ) - : compareLoadKnobs.selectedGpuIds; + : reconcilePersistedGpuIds( + compareLoadKnobs.selectedGpuIds, + compareLoadKnobs.selectedGpuIndexKind, + sel.isDiffusion === true, + ); // A pane's context comes from its own config only: a saved pin, or null // (Auto/native). It must not inherit the active model's shared snapshot -- // resolveFitMaxSeqLength would treat that as a pin and load this pane at diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 9c58777a1e..971be7ee4e 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -919,12 +919,12 @@ export function ModelConfigPage({ committedGpuLayers != null || committedMoeLayers != null; - const effectiveConfig = hasPending + const committedConfig = hasPending ? { ...config, ...pendingPatch } : config; - const effectiveLoadableConfig = resolvedIsDiffusion - ? withoutUnsupportedDiffusionSettings(effectiveConfig, gpuIndexKind) - : effectiveConfig; + const effectiveConfig = resolvedIsDiffusion + ? withoutUnsupportedDiffusionSettings(committedConfig, gpuIndexKind) + : committedConfig; // pinFixedLayerContext above was computed from the render-time config, before // the same-click GPU Layers draft was committed. Recompute it from // effectiveConfig so committing a positive fixed-layer value still pins the @@ -933,18 +933,15 @@ export function ModelConfigPage({ // the pin exists to avoid). const effectivePinFixedLayerContext = target.isGguf && - effectiveLoadableConfig.gpuMemoryMode === "manual" && - effectiveLoadableConfig.gpuLayers != null && - effectiveLoadableConfig.gpuLayers >= 0 && - effectiveLoadableConfig.customContextLength == null && + effectiveConfig.gpuMemoryMode === "manual" && + effectiveConfig.gpuLayers != null && + effectiveConfig.gpuLayers >= 0 && + effectiveConfig.customContextLength == null && activeLoadedContext != null; const effectiveRuntimeConfig = hasPending ? effectivePinFixedLayerContext - ? { - ...effectiveLoadableConfig, - customContextLength: activeLoadedContext, - } - : effectiveLoadableConfig + ? { ...effectiveConfig, customContextLength: activeLoadedContext } + : effectiveConfig : runtimeConfig; // Non-GGUF load substitutes the resolved max sequence length; recompute it // from the committed draft so a same-click Max Seq Length edit is not lost. @@ -954,10 +951,7 @@ export function ModelConfigPage({ : (normalizeMaxSeqLength(effectiveConfig.maxSeqLength) ?? clampMaxSeqLength(DEFAULT_MAX_SEQ_LENGTH, nativeMaxSeqLength)); // Recheck the committed draft so Save/Forget reloads when needed. - const effectiveAtBaseline = perModelConfigsEqual( - effectiveLoadableConfig, - baseline, - ); + const effectiveAtBaseline = perModelConfigsEqual(effectiveConfig, baseline); const effectivePersistenceOnly = isActiveModel && effectiveAtBaseline && rememberChanged; const defaultConfig = isDefaultConfig(effectiveRuntimeConfig); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index b29fd8139a..08baf90392 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -198,6 +198,8 @@ def test_compare_load_uses_each_models_gpu_config(): assert "ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe" in src assert "if (ownConfig.selectedGpuIds != null)" in src assert "ownConfig.selectedGpuIndexKind," in src + assert "compareLoadKnobs.selectedGpuIndexKind," in src + assert src.count("sel.isDiffusion === true") >= 2 for field in ( "gpu_memory_mode: effectiveGpuMemoryMode", "gpu_layers: effectiveGpuLayers", @@ -206,6 +208,10 @@ def test_compare_load_uses_each_models_gpu_config(): ): assert field in src + page = _read("features/chat/chat-page.tsx") + assert page.count("isDiffusion: meta.isDiffusion") >= 2 + assert "isDiffusion: globalIsDiffusion" in page + def test_active_native_gguf_metadata_uses_path_token(): src = _read("features/model-picker/components/model-config-page.tsx") From 89f3620f682dd1daf477caedd852febae9057cde Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:53:40 -0300 Subject: [PATCH 068/110] Correct draft-device recovery guidance --- studio/backend/routes/inference.py | 2 +- studio/backend/tests/test_chat_load_during_training.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6e9c7a41fd..bb98e8f03d 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3959,7 +3959,7 @@ def _reject_draft_device_with_gpu_ids( f"A draft-model device override ('{draft_device}') cannot be combined " "with explicit gpu_ids: it would place the speculative drafter outside " "the pinned GPUs the training guard budgeted. Remove the draft-device " - "flag to follow gpu_ids, or set it to cpu." + "flag to follow gpu_ids, or set it to none." ), ) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 4a2022aad3..478c156e45 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -1194,6 +1194,7 @@ class TestLoadModelGuardIntegration(unittest.TestCase): self.assertEqual(exc.exception.status_code, 400) self.assertIn("draft-model device", exc.exception.detail) + self.assertIn("set it to none", exc.exception.detail) self.assertEqual(captured, []) inf.unload_model.assert_not_called() llama.unload_model.assert_not_called() From fb951cbb8ae3896a64341b5e1782906990a3a98d Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:29:10 -0300 Subject: [PATCH 069/110] Fix Vulkan source and training device handling --- studio/backend/routes/inference.py | 21 +++++++ studio/backend/routes/training_vram.py | 16 +++-- .../tests/test_chat_load_during_training.py | 58 +++++++++++++++++-- .../tests/test_setup_llama_cpp_backend.py | 23 ++++++++ studio/setup.ps1 | 15 +++++ studio/setup.sh | 16 +++++ 6 files changed, 138 insertions(+), 11 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index bb98e8f03d..5a81b87e32 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4118,6 +4118,26 @@ def _guard_chat_load_against_training( else None ) + vulkan_free_vram_gb = None + if is_gguf: + binary = LlamaCppBackend._find_llama_server_binary() + is_vulkan_backend = bool( + gpu_ids_are_vulkan_ordinals + or (binary and LlamaCppBackend._is_vulkan_backend(binary)) + ) + if is_vulkan_backend and ( + gpu_ids_are_vulkan_ordinals or diffusion_kind is False + ): + vulkan_free_vram_gb = { + index: free_mib / 1024.0 + for index, free_mib, _total_mib in LlamaCppBackend._get_gpu_memory(binary) + } + elif is_vulkan_backend and diffusion_kind is None: + # Until the header is available, the model may use either the Vulkan + # llama-server or the CUDA-only diffusion runner. Neither device + # namespace can safely stand in for the other. + vulkan_free_vram_gb = {} + ok, info = can_load_chat_during_training( model_name = model_identifier, hf_token = hf_token, @@ -4126,6 +4146,7 @@ def _guard_chat_load_against_training( requested_gpu_ids = requested_gpu_ids, is_gguf = is_gguf, gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, + vulkan_free_vram_gb = vulkan_free_vram_gb, required_override_gb = required_override_gb, single_device_gpu = diffusion_gpu, ) diff --git a/studio/backend/routes/training_vram.py b/studio/backend/routes/training_vram.py index 492d5fe725..325a545af7 100644 --- a/studio/backend/routes/training_vram.py +++ b/studio/backend/routes/training_vram.py @@ -226,6 +226,7 @@ def can_load_chat_during_training( requested_gpu_ids: Optional[List[int]], is_gguf: bool = False, gpu_ids_are_vulkan_ordinals: bool = False, + vulkan_free_vram_gb: Optional[Dict[int, float]] = None, required_override_gb: Optional[float] = None, single_device_gpu: Optional[str] = None, ) -> Tuple[bool, Dict[str, Any]]: @@ -284,7 +285,8 @@ def can_load_chat_during_training( } # Explicit GPUs, or GGUF: size directly and check live free VRAM. - if requested_gpu_ids and gpu_ids_are_vulkan_ordinals: + uses_vulkan_memory = vulkan_free_vram_gb is not None + if is_gguf and uses_vulkan_memory: mode = "gguf_vulkan" elif single_device_gpu is not None: mode = "single_device" @@ -298,11 +300,13 @@ def can_load_chat_during_training( if required_gb is None: return False, {"mode": mode, "reason": "estimate_unavailable"} - free_by_index = _free_vram_by_index(get_visible_gpu_utilization().get("devices", [])) + free_by_index = ( + vulkan_free_vram_gb + if uses_vulkan_memory + else _free_vram_by_index(get_visible_gpu_utilization().get("devices", [])) + ) if requested_gpu_ids and gpu_ids_are_vulkan_ordinals: - # Use the safest N-device bound because Vulkan and CUDA IDs do not map. - visible_free = sorted(free_by_index.values()) - free_vals = visible_free[: min(len(requested_gpu_ids), len(visible_free))] + free_vals = [free_by_index.get(int(gpu_id), 0.0) for gpu_id in requested_gpu_ids] elif single_device_gpu is not None: token = str(single_device_gpu).strip() if not token: @@ -348,7 +352,7 @@ def can_load_chat_during_training( min_free_gb = min(free_vals) per_gpu_fits = True per_gpu_needed_gb = None - if mode == "gguf_vulkan": + if mode == "gguf_vulkan" and requested_gpu_ids: per_gpu_needed_gb = needed_gb / len(requested_gpu_ids) elif mode == "explicit" and len(free_vals) > 1: per_gpu_needed_gb = needed_gb / len(free_vals) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 478c156e45..46a96166af 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -170,6 +170,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): single_device_gpu = None, gpu_ids = None, gpu_ids_are_vulkan_ordinals = False, + vulkan_free_vram_gb = None, ): with ( patch("utils.hardware.get_device", return_value = DeviceType.CUDA), @@ -186,6 +187,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): requested_gpu_ids = gpu_ids, is_gguf = True, gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, + vulkan_free_vram_gb = vulkan_free_vram_gb, required_override_gb = required_override, single_device_gpu = single_device_gpu, ) @@ -233,25 +235,28 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(blocked_info["usable_gb"], 10.0) def test_vulkan_pin_takes_precedence_over_unknown_diffusion_fallback(self): - # Do not reinterpret a Vulkan ordinal as a speculative CUDA device. + # Use the Vulkan probe, not unrelated CUDA telemetry or a speculative + # CUDA diffusion device. ok, info, _ = self._run( - devices = _devices((0, 80, 0), (1, 80, 78)), + devices = _devices((0, 80, 0)), required_override = 20.0, single_device_gpu = "0", gpu_ids = [0], gpu_ids_are_vulkan_ordinals = True, + vulkan_free_vram_gb = {0: 2.0, 1: 80.0}, ) self.assertFalse(ok) self.assertEqual(info["mode"], "gguf_vulkan") self.assertEqual(info["usable_gb"], 2.0) def test_vulkan_multi_gpu_guard_counts_requested_devices(self): - # An unknown mapping uses the least-free two-card subset. + # Vulkan ordinals select the same entries reported by the Vulkan probe. ok, info, _ = self._run( - devices = _devices((0, 80, 70), (1, 80, 70), (2, 80, 0)), + devices = _devices((0, 80, 0)), required_override = 10.0, gpu_ids = [0, 1], gpu_ids_are_vulkan_ordinals = True, + vulkan_free_vram_gb = {0: 10.0, 1: 10.0, 2: 80.0}, ) self.assertTrue(ok) self.assertEqual(info["mode"], "gguf_vulkan") @@ -260,15 +265,26 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): def test_vulkan_multi_gpu_enforces_per_device_floor(self): # Aggregate capacity clears 15.5 GB, but one shard has less than half. ok, info, _ = self._run( - devices = _devices((0, 80, 60), (1, 80, 78)), + devices = _devices((0, 80, 0), (1, 80, 0)), required_override = 10.0, gpu_ids = [0, 1], gpu_ids_are_vulkan_ordinals = True, + vulkan_free_vram_gb = {0: 20.0, 1: 2.0}, ) self.assertFalse(ok) self.assertEqual(info["per_gpu_needed_gb"], 7.75) self.assertEqual(info["min_free_gb"], 2.0) + def test_vulkan_auto_uses_full_vulkan_pool(self): + ok, info, _ = self._run( + devices = _devices((0, 80, 79)), + required_override = 20.0, + vulkan_free_vram_gb = {0: 30.0, 1: 30.0}, + ) + self.assertTrue(ok) + self.assertEqual(info["mode"], "gguf_vulkan") + self.assertEqual(info["usable_gb"], 55.5) + def test_single_device_unresolved_token_sizes_against_worst_device(self): # Unknown single-device tokens use the least-free visible GPU. ok, info, _ = self._run( @@ -573,6 +589,11 @@ class TestChatLoadGuardRoute(unittest.TestCase): with ( patch.object(self.route, "_classify_diffusion_gguf", return_value = None), patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), + patch.object( + self.route.LlamaCppBackend, + "_get_gpu_memory", + return_value = [(0, 2048, 8192), (1, 4096, 8192)], + ), ): self._guard( config = config, @@ -584,8 +605,35 @@ class TestChatLoadGuardRoute(unittest.TestCase): ) self.assertEqual(len(captured), 1) self.assertTrue(captured[0]["gpu_ids_are_vulkan_ordinals"]) + self.assertEqual(captured[0]["vulkan_free_vram_gb"], {0: 2.0, 1: 4.0}) self.assertIsNone(captured[0]["single_device_gpu"]) + def test_auto_vulkan_uses_vulkan_probe_for_training_budget(self): + captured = [] + config = SimpleNamespace(is_gguf = True) + with ( + patch.object(self.route, "_classify_diffusion_gguf", return_value = False), + patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), + patch.object( + self.route.LlamaCppBackend, + "_find_llama_server_binary", + return_value = "/tmp/llama-server", + ), + patch.object(self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True), + patch.object( + self.route.LlamaCppBackend, + "_get_gpu_memory", + return_value = [(0, 3072, 8192)], + ), + ): + self._guard( + config = config, + captured = captured, + training_active = True, + decision = (True, {"mode": "gguf_vulkan"}), + ) + self.assertEqual(captured[0]["vulkan_free_vram_gb"], {0: 3.0}) + def test_refuses_with_headroom_number(self): info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"} with self.assertRaises(HTTPException) as exc: diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index ac16374e91..d38e636154 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -128,6 +128,29 @@ def test_explicit_vulkan_prebuilt_failure_does_not_change_backend(): assert "exit 1" in guarded +def test_explicit_vulkan_source_build_fails_closed(): + sh = _SETUP_SH.read_text(encoding = "utf-8") + local_branch = sh.index('if [ "$_LOCAL_LLAMA_CPP_LINKED" = true ]; then') + prebuilt_branch = sh.index('elif [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then', local_branch) + guarded = sh[local_branch:prebuilt_branch] + assert ( + 'elif [ "$_explicit_vulkan_source_build" = true ] && ' + '[ "$_NEED_LLAMA_SOURCE_BUILD" = true ]; then' + ) in guarded + assert "Vulkan source builds are not supported" in guarded + assert "exit 1" in guarded + + ps1 = _SETUP_PS1.read_text(encoding = "utf-8") + local_branch = ps1.index("if ($LocalLlamaCppLinked) {") + prebuilt_branch = ps1.index( + '} elseif ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {', local_branch + ) + guarded = ps1[local_branch:prebuilt_branch] + assert "} elseif ($explicitVulkanSourceBuild -and $NeedLlamaSourceBuild) {" in guarded + assert "Vulkan source builds are not supported" in guarded + assert "exit 1" in guarded + + def test_legacy_force_vulkan_gets_the_same_strict_fallback(): sh = _SETUP_SH.read_text(encoding = "utf-8") assert "_legacy_force_vulkan=" in sh diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 7c2641154b..0d2e2290a9 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3475,6 +3475,16 @@ $ResolvedSourceUrl = $LlamaSource $ResolvedSourceRef = $RequestedLlamaTag $ResolvedSourceRefKind = "tag" $ResolvedLlamaTag = $RequestedLlamaTag +$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() +$sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() +$explicitVulkanSourceBuild = ( + -not $IsMacOS -and + $sourceLlamaBackend -ne "cpu" -and + ( + $sourceLlamaBackend -eq "vulkan" -or + $sourceLegacyForceVulkan -in @("1", "true", "yes", "on") + ) +) if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { $NeedLlamaSourceBuild = $true @@ -3632,6 +3642,11 @@ if ($LocalLlamaCppSrc) { if ($LocalLlamaCppLinked) { # local directory linked above; skip prebuilt install +} elseif ($explicitVulkanSourceBuild -and $NeedLlamaSourceBuild) { + Write-Host "" + step "llama.cpp" "Vulkan was explicitly requested, but this installation requires a source build" "Red" + substep "Vulkan source builds are not supported by this installer; use the prebuilt Vulkan bundle or unset the Vulkan override" "Yellow" + exit 1 } elseif ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { Write-Host "" substep "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" "Yellow" diff --git a/studio/setup.sh b/studio/setup.sh index 3ee7d57959..3b6127c45a 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1226,6 +1226,18 @@ _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" _HOST_SYSTEM="$(uname -s 2>/dev/null || true)" _HOST_MACHINE="$(uname -m 2>/dev/null || true)" +_source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" +_source_legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" +_explicit_vulkan_source_build=false +if [ "$_HOST_SYSTEM" != "Darwin" ] && [ "$_source_backend_choice" != "cpu" ]; then + if [ "$_source_backend_choice" = "vulkan" ]; then + _explicit_vulkan_source_build=true + else + case "$_source_legacy_force_vulkan" in + 1|true|yes|on) _explicit_vulkan_source_build=true ;; + esac + fi +fi # Pick the release repo install_llama_prebuilt.py plans against. Every host this # installer supports now pulls its llama.cpp prebuilt from the unslothai fork: it @@ -1361,6 +1373,10 @@ fi if [ "$_LOCAL_LLAMA_CPP_LINKED" = true ]; then : # local directory linked above; skip prebuilt install +elif [ "$_explicit_vulkan_source_build" = true ] && [ "$_NEED_LLAMA_SOURCE_BUILD" = true ]; then + step "llama.cpp" "Vulkan was explicitly requested, but this installation requires a source build" "$C_ERR" + substep "Vulkan source builds are not supported by this installer; use the prebuilt Vulkan bundle or unset the Vulkan override" + exit 1 elif [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then step "llama.cpp" "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt" "$C_WARN" _NEED_LLAMA_SOURCE_BUILD=true From 976d9e4efb41e23f4a054fda201675a7f03620e7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:33:01 +0000 Subject: [PATCH 070/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/inference.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 5a81b87e32..517868bcb8 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4122,12 +4122,9 @@ def _guard_chat_load_against_training( if is_gguf: binary = LlamaCppBackend._find_llama_server_binary() is_vulkan_backend = bool( - gpu_ids_are_vulkan_ordinals - or (binary and LlamaCppBackend._is_vulkan_backend(binary)) + gpu_ids_are_vulkan_ordinals or (binary and LlamaCppBackend._is_vulkan_backend(binary)) ) - if is_vulkan_backend and ( - gpu_ids_are_vulkan_ordinals or diffusion_kind is False - ): + if is_vulkan_backend and (gpu_ids_are_vulkan_ordinals or diffusion_kind is False): vulkan_free_vram_gb = { index: free_mib / 1024.0 for index, free_mib, _total_mib in LlamaCppBackend._get_gpu_memory(binary) From 548169dc4b186569b04e595d3fe596300fdac96d Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:52:06 -0300 Subject: [PATCH 071/110] Align automatic Vulkan device pools --- studio/backend/core/inference/llama_cpp.py | 19 +++++++++++++---- studio/backend/routes/inference.py | 5 ++++- .../tests/test_chat_load_during_training.py | 4 ++-- .../backend/tests/test_llama_cpp_placement.py | 21 +++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index e6a54505dc..8af7fd6b7f 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3548,6 +3548,14 @@ class LlamaCppBackend: logger.debug(f"torch GPU probe failed: {e}") return [] + @staticmethod + def _vulkan_auto_gpu_memory( + gpus: list[tuple[int, int, int]], + ) -> list[tuple[int, int, int]]: + """Prefer discrete devices for automatic Vulkan placement.""" + discrete = [gpu for gpu in gpus if gpu[2] > 0] + return discrete or gpus + @staticmethod def _probe_vulkan_devices( binary: Optional[str] = None, @@ -6973,6 +6981,12 @@ class LlamaCppBackend: # per-GPU headroom (correct when the GPU is already partly used). # Pass binary so a Vulkan build probes ggml's Vulkan ordinals. _gpu_mem = self._get_gpu_memory(binary) + if ( + is_vulkan_backend + and not gpu_ids + and gpu_memory_mode != "manual" + ): + _gpu_mem = self._vulkan_auto_gpu_memory(_gpu_mem) gpus = [(idx, free) for idx, free, _t in _gpu_mem] # Restrict the fit (and thus the layer plan + pin env) to the # selected GPUs; fail-open if none match so a stale UI choice @@ -7846,10 +7860,7 @@ class LlamaCppBackend: and gpu_indices is None and _detected_gpus ): - discrete_ids = [ - idx for idx, _free in _detected_gpus if total_by_idx.get(idx, 0) > 0 - ] - gpu_indices = sorted(discrete_ids or [idx for idx, _free in _detected_gpus]) + gpu_indices = sorted(idx for idx, _free in _detected_gpus) # Status reports the explicit subset the launch actually uses, # while reload dedupe compares requested_gpu_ids so repeating a diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 517868bcb8..d3a77006c3 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4125,9 +4125,12 @@ def _guard_chat_load_against_training( gpu_ids_are_vulkan_ordinals or (binary and LlamaCppBackend._is_vulkan_backend(binary)) ) if is_vulkan_backend and (gpu_ids_are_vulkan_ordinals or diffusion_kind is False): + gpu_memory = LlamaCppBackend._get_gpu_memory(binary) + if not requested_gpu_ids: + gpu_memory = LlamaCppBackend._vulkan_auto_gpu_memory(gpu_memory) vulkan_free_vram_gb = { index: free_mib / 1024.0 - for index, free_mib, _total_mib in LlamaCppBackend._get_gpu_memory(binary) + for index, free_mib, _total_mib in gpu_memory } elif is_vulkan_backend and diffusion_kind is None: # Until the header is available, the model may use either the Vulkan diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 46a96166af..58f9552339 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -623,7 +623,7 @@ class TestChatLoadGuardRoute(unittest.TestCase): patch.object( self.route.LlamaCppBackend, "_get_gpu_memory", - return_value = [(0, 3072, 8192)], + return_value = [(0, 3072, 0), (1, 2048, 8192)], ), ): self._guard( @@ -632,7 +632,7 @@ class TestChatLoadGuardRoute(unittest.TestCase): training_active = True, decision = (True, {"mode": "gguf_vulkan"}), ) - self.assertEqual(captured[0]["vulkan_free_vram_gb"], {0: 3.0}) + self.assertEqual(captured[0]["vulkan_free_vram_gb"], {1: 2.0}) def test_refuses_with_headroom_number(self): info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"} diff --git a/studio/backend/tests/test_llama_cpp_placement.py b/studio/backend/tests/test_llama_cpp_placement.py index 6aeb771a76..70387ea83d 100644 --- a/studio/backend/tests/test_llama_cpp_placement.py +++ b/studio/backend/tests/test_llama_cpp_placement.py @@ -158,6 +158,27 @@ def test_vulkan_selection_uses_ordinals_and_owns_device_flags(tmp_path): assert backend.gpu_ids == [1] +def test_vulkan_auto_fit_and_launch_share_discrete_pool(tmp_path): + backend, gguf = _backend( + tmp_path, + vulkan = True, + memory = [(0, 24_000, 0), (1, 8_000, 16_000)], + ) + planned = [] + + def fallback(_model_size, gpus, *args, **kwargs): + planned.append(list(gpus)) + return None, True + + backend._select_gpus = fallback + result = _launch(backend, gguf) + + assert planned + assert all(gpus == [(1, 8_000)] for gpus in planned) + cmd = result["cmd"] + assert cmd[cmd.index("--device") + 1] == "Vulkan1" + + def test_cuda_selection_uses_visibility_and_removes_environment_placement(tmp_path, monkeypatch): monkeypatch.setenv("LLAMA_ARG_DEVICE", "CUDA0") monkeypatch.setenv("LLAMA_ARG_MAIN_GPU", "0") From 2c898e5fcb091a16ba303dd1d2a5bfedc13bb19e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:53:43 +0000 Subject: [PATCH 072/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 10 ++-------- studio/backend/routes/inference.py | 3 +-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 8af7fd6b7f..615c6ec956 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3549,9 +3549,7 @@ class LlamaCppBackend: return [] @staticmethod - def _vulkan_auto_gpu_memory( - gpus: list[tuple[int, int, int]], - ) -> list[tuple[int, int, int]]: + def _vulkan_auto_gpu_memory(gpus: list[tuple[int, int, int]]) -> list[tuple[int, int, int]]: """Prefer discrete devices for automatic Vulkan placement.""" discrete = [gpu for gpu in gpus if gpu[2] > 0] return discrete or gpus @@ -6981,11 +6979,7 @@ class LlamaCppBackend: # per-GPU headroom (correct when the GPU is already partly used). # Pass binary so a Vulkan build probes ggml's Vulkan ordinals. _gpu_mem = self._get_gpu_memory(binary) - if ( - is_vulkan_backend - and not gpu_ids - and gpu_memory_mode != "manual" - ): + if is_vulkan_backend and not gpu_ids and gpu_memory_mode != "manual": _gpu_mem = self._vulkan_auto_gpu_memory(_gpu_mem) gpus = [(idx, free) for idx, free, _t in _gpu_mem] # Restrict the fit (and thus the layer plan + pin env) to the diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index d3a77006c3..9cfe42fae2 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4129,8 +4129,7 @@ def _guard_chat_load_against_training( if not requested_gpu_ids: gpu_memory = LlamaCppBackend._vulkan_auto_gpu_memory(gpu_memory) vulkan_free_vram_gb = { - index: free_mib / 1024.0 - for index, free_mib, _total_mib in gpu_memory + index: free_mib / 1024.0 for index, free_mib, _total_mib in gpu_memory } elif is_vulkan_backend and diffusion_kind is None: # Until the header is available, the model may use either the Vulkan From 5c215cb844bd11614916481da82b1594666926c6 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 27 Jul 2026 09:34:51 +0000 Subject: [PATCH 073/110] Fail closed on explicit Vulkan no-space, refresh the GPU picker on probe recovery setup.sh and setup.ps1 both dispatch the prebuilt installer's exit status before checking whether Vulkan was explicitly requested, so status 4 (no disk space) took the no-space branch and never reached the strict-backend check in the else. With an older CUDA, ROCm or CPU llama-server already installed, _has_local_llama_server kept _LLAMA_CPP_DEGRADED false, so setup exited 0 and Studio carried on using the backend the user asked to replace. Driving the real dispatch block with a stubbed installer shows the same explicit request exiting 1 on a generic failure and 0 on a no-space failure. Apply the same check to the no-space path in both scripts. useGpuDevices fetched /api/system once from an effect with no dependencies and subscribed to nothing. When the Vulkan probe is unavailable, main.py answers gguf_devices as an empty list rather than omitting it, so the "?? devices" fallback does not apply and the picker starts hidden. useInferenceGpuInfo then retries every 3 seconds and refreshes the shared cache, but nothing told the mounted device hook, so the picker stayed hidden until it was remounted. Notify subscribers when a refetch replaces the cache and have useGpuDevices subscribe, comparing by value since each refresh builds a fresh array and an unconditional set would re-render on every retry tick. --- studio/frontend/src/hooks/use-gpu-info.ts | 22 +++++++++++++++++++--- studio/setup.ps1 | 7 +++++++ studio/setup.sh | 7 +++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 1cb3d24523..8a49688f63 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -48,6 +48,10 @@ const DEFAULT_GPU: GpuInfo = { // One module-level cache so every GPU hook shares a single /api/system fetch. let cachedSystem: SystemInfoResponse | null = null; let systemPromise: Promise | null = null; +// An unavailable Vulkan probe answers gguf_devices as an empty list, so the +// picker starts hidden and only useInferenceGpuInfo retries. Without this, +// hooks that fetch once never see the recovery and stay hidden until remount. +const systemSubscribers = new Set<(data: SystemInfoResponse | null) => void>(); async function fetchSystemOnce(force = false): Promise { if (!force && cachedSystem) return cachedSystem; @@ -57,6 +61,7 @@ async function fetchSystemOnce(force = false): Promise notify(cachedSystem)); return cachedSystem; } catch { return null; @@ -185,11 +190,22 @@ export function useGpuDevices(): SystemGpuDevice[] { // No early return on cachedSystem: a consumer mounting as the cache fills // (between render and effect) would otherwise stay stuck at the default. let cancelled = false; - fetchSystemOnce().then((d) => { - if (!cancelled) setDevices(toGpuDevices(d)); - }); + let lastSerialized: string | null = null; + const sync = (data: SystemInfoResponse | null) => { + if (cancelled) return; + const next = toGpuDevices(data); + // Every refresh builds a fresh array, so compare by value or a 3s Vulkan + // retry loop would re-render this hook forever. + const serialized = JSON.stringify(next); + if (serialized === lastSerialized) return; + lastSerialized = serialized; + setDevices(next); + }; + systemSubscribers.add(sync); + fetchSystemOnce().then(sync); return () => { cancelled = true; + systemSubscribers.delete(sync); }; }, []); return devices; diff --git a/studio/setup.ps1 b/studio/setup.ps1 index d2b9f35247..951357700e 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3802,6 +3802,13 @@ if ($LocalLlamaCppLinked) { if (Test-Path -LiteralPath $_cand) { $PreservedLlamaServerFound = $true; break } } if (-not $PreservedLlamaServerFound) { $script:LlamaCppDegraded = $true } + # A preserved CUDA/ROCm/CPU server does not satisfy an explicit Vulkan + # request, and it leaves LlamaCppDegraded false, so without this the + # run reports success on the backend the user asked to replace. + if ($explicitVulkanBackend) { + step "llama.cpp" "Vulkan was explicitly requested, so the installer will not keep the existing backend" "Red" + exit 1 + } } else { step "llama.cpp" "prebuilt install failed" "Yellow" Write-LlamaFailureLog -Output $prebuiltOutput diff --git a/studio/setup.sh b/studio/setup.sh index d1797d4799..236e31b9a9 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1496,6 +1496,13 @@ else substep "free up disk or move UNSLOTH_STUDIO_HOME/TMPDIR to a larger volume, then re-run" _LLAMA_CPP_NO_SPACE=true _has_local_llama_server "$LLAMA_CPP_DIR" || _LLAMA_CPP_DEGRADED=true + # A preserved CUDA/ROCm/CPU server does not satisfy an explicit Vulkan + # request, and it leaves _LLAMA_CPP_DEGRADED false, so without this the + # run reports success on the backend the user asked to replace. + if [ "$_explicit_vulkan_backend" = true ]; then + step "llama.cpp" "Vulkan was explicitly requested, so the installer will not keep the existing backend" "$C_ERR" + exit 1 + fi else step "llama.cpp" "prebuilt install failed" "$C_WARN" print_llama_error_log "$_PREBUILT_LOG" From 3ad94e4d8b9fac618813d1c4b2e0c16af0c4ddae Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:13:50 +0000 Subject: [PATCH 074/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_system_vulkan_gpu_info.py | 4 +--- studio/install_llama_prebuilt.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/studio/backend/tests/test_system_vulkan_gpu_info.py b/studio/backend/tests/test_system_vulkan_gpu_info.py index 5996718dee..cac2df19e0 100644 --- a/studio/backend/tests/test_system_vulkan_gpu_info.py +++ b/studio/backend/tests/test_system_vulkan_gpu_info.py @@ -72,9 +72,7 @@ def test_system_gpu_info_preserves_vulkan_visibility_metrics(monkeypatch): assert inference_gpu["devices"] == [vulkan_device] -def test_system_gpu_info_withholds_gguf_pin_when_the_vulkan_probe_enumerates_nothing( - monkeypatch, -): +def test_system_gpu_info_withholds_gguf_pin_when_the_vulkan_probe_enumerates_nothing(monkeypatch): """A Vulkan build whose probe returns no ordinals has nothing valid to pin, so the picker must be told pins are unsupported rather than offered an empty namespace it would 400 on.""" diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 7179b6beab..a9aafd98a8 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6616,9 +6616,7 @@ def install_prebuilt( ) # An explicit Vulkan choice only. The Windows-AMD auto-fallback is a rescue # from a missing HIP arch, so it must keep the CPU plans it can still use. - strict_vulkan = ( - force_vulkan_requested(llama_backend) and not force_cpu and not host.is_macos - ) + strict_vulkan = force_vulkan_requested(llama_backend) and not force_cpu and not host.is_macos host, published_repo, published_release_tag, persist_llama_backend = _route_to_vulkan_prebuilt( host, published_repo, From c27a0394e2e96dcab03c734621a855ea39a7dbed Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:12:02 -0300 Subject: [PATCH 075/110] Fix Vulkan placement state and installation --- studio/backend/routes/training_vram.py | 9 +- .../tests/test_chat_load_during_training.py | 27 +++- .../tests/test_setup_llama_cpp_backend.py | 14 +++ studio/frontend/src/features/chat/index.ts | 1 + .../src/features/chat/shared-composer.tsx | 68 +++++++---- .../chat/stores/chat-runtime-store.ts | 33 +++-- .../components/model-config-page.tsx | 115 +++++++++++------- .../model-config/apply-per-model-config.ts | 33 ++--- .../model-config/per-model-config.ts | 6 +- studio/frontend/src/hooks/use-gpu-info.ts | 114 +++++++++++++++-- studio/setup.ps1 | 13 ++ studio/setup.sh | 1 + tests/studio/test_model_picker_contracts.py | 45 +++++-- 13 files changed, 349 insertions(+), 130 deletions(-) diff --git a/studio/backend/routes/training_vram.py b/studio/backend/routes/training_vram.py index 325a545af7..fe5f9ac07a 100644 --- a/studio/backend/routes/training_vram.py +++ b/studio/backend/routes/training_vram.py @@ -347,14 +347,13 @@ def can_load_chat_during_training( needed_gb = required_gb * SAFETY_MARGIN + KEEP_FLOOR_GB aggregate_fits = usable_gb >= needed_gb - # Explicit HF and Vulkan placement shard across a known number of GPUs. - # An even-share floor stops one near-full GPU hiding behind aggregate capacity. + # Explicit HF placement uses balanced sharding across a known number of + # GPUs. GGUF pins are candidate pools: llama.cpp may narrow an uneven + # pool to the smallest fitting subset, so only their aggregate matters. min_free_gb = min(free_vals) per_gpu_fits = True per_gpu_needed_gb = None - if mode == "gguf_vulkan" and requested_gpu_ids: - per_gpu_needed_gb = needed_gb / len(requested_gpu_ids) - elif mode == "explicit" and len(free_vals) > 1: + if mode == "explicit" and len(free_vals) > 1: per_gpu_needed_gb = needed_gb / len(free_vals) if per_gpu_needed_gb is not None: per_gpu_fits = min_free_gb >= per_gpu_needed_gb diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 58f9552339..185a3b563a 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -262,8 +262,8 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): self.assertEqual(info["mode"], "gguf_vulkan") self.assertEqual(info["usable_gb"], 18.5) - def test_vulkan_multi_gpu_enforces_per_device_floor(self): - # Aggregate capacity clears 15.5 GB, but one shard has less than half. + def test_vulkan_uneven_pool_uses_fitting_subset(self): + # The loader may choose the 20 GB device alone from this candidate pool. ok, info, _ = self._run( devices = _devices((0, 80, 0), (1, 80, 0)), required_override = 10.0, @@ -271,9 +271,28 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase): gpu_ids_are_vulkan_ordinals = True, vulkan_free_vram_gb = {0: 20.0, 1: 2.0}, ) + self.assertTrue(ok) + self.assertNotIn("per_gpu_needed_gb", info) + + def test_vulkan_uneven_pool_is_order_independent(self): + ok, _, _ = self._run( + devices = _devices((0, 80, 0), (1, 80, 0)), + required_override = 10.0, + gpu_ids = [1, 0], + gpu_ids_are_vulkan_ordinals = True, + vulkan_free_vram_gb = {0: 20.0, 1: 2.0}, + ) + self.assertTrue(ok) + + def test_vulkan_pool_with_insufficient_aggregate_refuses(self): + ok, _, _ = self._run( + devices = _devices((0, 80, 0), (1, 80, 0)), + required_override = 10.0, + gpu_ids = [0, 1], + gpu_ids_are_vulkan_ordinals = True, + vulkan_free_vram_gb = {0: 8.0, 1: 2.0}, + ) self.assertFalse(ok) - self.assertEqual(info["per_gpu_needed_gb"], 7.75) - self.assertEqual(info["min_free_gb"], 2.0) def test_vulkan_auto_uses_full_vulkan_pool(self): ok, info, _ = self._run( diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index d38e636154..f915cfaa3d 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -85,6 +85,7 @@ def test_backend_auto_no_flag_no_warn(value): def test_backend_vulkan_is_accepted(value): args, stderr = _run(value) assert "--force-cpu" not in args + assert args[-2:] == ["--llama-backend", "vulkan"] assert "Ignoring" not in stderr @@ -213,9 +214,22 @@ def test_ps1_backend_auto_no_flag_no_warn(value): def test_ps1_backend_vulkan_is_accepted(value): out = _run_ps1(value) assert "--force-cpu" not in out + assert "--llama-backend,vulkan" in out assert "Ignoring" not in out +def test_ps1_forced_vulkan_fails_closed_on_windows_arm64(): + ps1 = _SETUP_PS1.read_text(encoding = "utf-8") + arm64_guard = ps1.index('elseif ($windowsArm64)') + vulkan_flag = ps1.index( + '$prebuiltArgs += @("--llama-backend", "vulkan")', + arm64_guard, + ) + assert arm64_guard < vulkan_flag + assert "upstream provides no Windows ARM64 Vulkan prebuilt" in ps1 + assert "Unset UNSLOTH_FORCE_VULKAN" in ps1 + + @_SKIP_NO_PWSH @pytest.mark.parametrize("value", ["gpu", "cuda"]) def test_ps1_backend_unknown_warns_and_no_flag(value): diff --git a/studio/frontend/src/features/chat/index.ts b/studio/frontend/src/features/chat/index.ts index 2c1bbcefad..20224a99a8 100644 --- a/studio/frontend/src/features/chat/index.ts +++ b/studio/frontend/src/features/chat/index.ts @@ -50,6 +50,7 @@ export { readPersistedSpeculativeType, readPersistedGpuMemoryMode, reconcilePersistedGpuIds, + reconcilePersistedGpuSelection, GPU_LAYERS_AUTO, } from "./stores/chat-runtime-store"; export { diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 33cce9a16d..5f96a0b52b 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -86,7 +86,12 @@ import { confirmTransformersUpgradeIfNeeded, useTransformersUpgradeDialogStore, } from "@/features/transformers-upgrade"; -import { loadModel, validateModel } from "./api/chat-api"; +import { prepareHfTokenForUse } from "@/features/hf-auth"; +import { + fetchGgufStagedMetadata, + loadModel, + validateModel, +} from "./api/chat-api"; import { resolveFitMaxSeqLength, resolveManualAutoCtxPin } from "./presets/preset-policy"; import { ensureGpuDeviceCache } from "@/hooks/use-gpu-info"; import { @@ -101,6 +106,7 @@ import { usePlusMenuPrefsStore, } from "./stores/plus-menu-prefs-store"; import { + GPU_LAYERS_AUTO, loadedGpuMemoryFields, type ReasoningEffort, reconcilePersistedGpuIds, @@ -1017,19 +1023,42 @@ export function SharedComposer({ : resolveInitialConfig(sel.id, sel.ggufVariant ?? null); const ownConfig = resolved.config; const ownRemembered = resolved.remembered; + const isAlreadyActive = + currentStore.params.checkpoint === sel.id && + (currentStore.activeGgufVariant ?? null) === + (sel.ggufVariant ?? null); + if (isAlreadyActive && !config && !loadedFromConfig) { + return "ready"; + } + const targetIsGguf = + (sel.ggufVariant ?? null) != null || + sel.id.toLowerCase().endsWith(".gguf"); + let resolvedIsDiffusion = sel.isDiffusion; + if (targetIsGguf && resolvedIsDiffusion === undefined) { + const preparedToken = await prepareHfTokenForUse( + currentStore.hfToken, + ); + if (!preparedToken.proceed) { + throw new Error("Model load cancelled."); + } + resolvedIsDiffusion = ( + await fetchGgufStagedMetadata({ + model_path: sel.id, + gguf_variant: sel.ggufVariant ?? null, + hf_token: preparedToken.token, + }) + ).isDiffusion; + } // Mirror single-view resolveLoadMaxSeqLength: a GGUF pane with no explicit // context loads at native (0 -> n_ctx_train), not the session maxSeqLength, // which would silently shrink the shown context. - const isGgufLoad = - (sel.ggufVariant ?? null) != null || - sel.id.toLowerCase().endsWith(".gguf"); // A non-GGUF pane with no saved maxSeqLength falls back to the app default, // not the active model's shared runtime snapshot: else comparing a saved // 128K model against an unconfigured one loads the latter at 128K and OOMs. const effectiveMaxSeqLength = ownConfig.customContextLength ?? normalizeMaxSeqLength(ownConfig.maxSeqLength) ?? - (isGgufLoad ? 0 : DEFAULT_MAX_SEQ_LENGTH); + (targetIsGguf ? 0 : DEFAULT_MAX_SEQ_LENGTH); const effectiveChatTemplateOverride = cleanCompareChatTemplate( ownConfig.chatTemplateOverride, ); @@ -1048,22 +1077,28 @@ export function SharedComposer({ await ensureGpuDeviceCache(); } const effectiveGpuMemoryMode = - ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode; + resolvedIsDiffusion + ? "auto" + : (ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode); const effectiveGpuLayers = - ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers; + resolvedIsDiffusion + ? GPU_LAYERS_AUTO + : (ownConfig.gpuLayers ?? compareLoadKnobs.gpuLayers); const effectiveNCpuMoe = - ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe; + resolvedIsDiffusion + ? 0 + : (ownConfig.nCpuMoe ?? compareLoadKnobs.nCpuMoe); const effectiveSelectedGpuIds = ownConfig.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( ownConfig.selectedGpuIds, ownConfig.selectedGpuIndexKind, - sel.isDiffusion === true, + resolvedIsDiffusion === true, ) : reconcilePersistedGpuIds( compareLoadKnobs.selectedGpuIds, compareLoadKnobs.selectedGpuIndexKind, - sel.isDiffusion === true, + resolvedIsDiffusion === true, ); // A pane's context comes from its own config only: a saved pin, or null // (Auto/native). It must not inherit the active model's shared snapshot -- @@ -1072,15 +1107,6 @@ export function SharedComposer({ const effectiveCustomContextLength = ownConfig.customContextLength; let loadTrustRemoteCode = trustRemoteCode; let approvedRemoteCodeFingerprint: string | null = null; - const isAlreadyActive = - currentStore.params.checkpoint === sel.id && - (currentStore.activeGgufVariant ?? null) === - (sel.ggufVariant ?? null); - if (isAlreadyActive && !config && !loadedFromConfig) { - return "ready"; - } - const targetIsGguf = - sel.id.toLowerCase().endsWith(".gguf") || sel.ggufVariant != null; // Size validation exactly as the load below, so the training-guard // preflight checks the footprint that actually loads (under Manual + Auto // layers the load sends 0 / the pinned context, not raw maxSeqLength). @@ -1235,7 +1261,7 @@ export function SharedComposer({ // Record the context this pane loaded with (like the single-model path) // so when it becomes the active model, the UI and later reload/save use // its context, not the previous/default one. - customContextLength: isGgufLoad + customContextLength: targetIsGguf ? (ownConfig.customContextLength ?? keepCustomCtx) : null, ggufContextLength: resp.is_gguf ? (resp.context_length ?? null) : null, @@ -1252,7 +1278,7 @@ export function SharedComposer({ activeNativePathExpiresAtMs: null, ...resolveLoadedSpeculativeSettings(resp), }); - if (!isGgufLoad) { + if (!targetIsGguf) { // Non-GGUF panes carry their context in params.maxSeqLength. store.setParams({ ...useChatRuntimeStore.getState().params, diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 67e1e4febe..c843ed72f2 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -4,7 +4,8 @@ import { mirrorHfTokenInto, useHfTokenStore } from "@/features/hub"; import { cachedPinnableGpuIndexKind, - cachedPinnableGpuIndices, + reconcileCachedGpuSelection, + type ReconciledGpuSelection, type GpuIndexKind, } from "@/hooks/use-gpu-info"; import { toast } from "@/lib/toast"; @@ -639,23 +640,19 @@ export function reconcilePersistedGpuIds( savedIndexKind?: GpuIndexKind | null, forDiffusion = false, ): number[] | null { - if (ids == null) return ids; - if (arguments.length >= 2) { - const currentIndexKind = cachedPinnableGpuIndexKind(forDiffusion); - const expectedIndexKind = - savedIndexKind === undefined ? "physical" : savedIndexKind; - if ( - currentIndexKind !== undefined && - savedIndexKind !== null && - currentIndexKind !== expectedIndexKind - ) { - return null; - } - } - const pinnable = cachedPinnableGpuIndices(forDiffusion); - if (pinnable === null) return ids; // cache not ready: can't validate, keep it - const kept = ids.filter((i) => pinnable.includes(i)); - return kept.length > 0 ? kept : null; + return reconcilePersistedGpuSelection( + ids, + savedIndexKind, + forDiffusion, + ).ids; +} + +export function reconcilePersistedGpuSelection( + ids: number[] | null, + savedIndexKind?: GpuIndexKind | null, + forDiffusion = false, +): ReconciledGpuSelection { + return reconcileCachedGpuSelection(ids, savedIndexKind, forDiffusion); } export function requestedGpuIdsFromResponse(resp: { diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 971be7ee4e..ae2ecdced4 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -22,6 +22,9 @@ import { import { type GpuIndexKind, type SystemGpuDevice, + gpuDeviceCacheReady, + pinnableGpuContext, + reconcileGpuSelection, useGpuDevices, } from "@/hooks/use-gpu-info"; import { ChevronDownStandardIcon } from "@/lib/chevron-icons"; @@ -130,14 +133,38 @@ function withoutUnsupportedDiffusionSettings( }; } -function gpuIndexKindOf( +function reconcileConfigGpuSelection( + config: PerModelConfig, gpuDevices: SystemGpuDevice[], -): GpuIndexKind | null { - return gpuDevices.length > 0 && - gpuDevices[0].indexKind !== null && - gpuDevices.every((device) => device.indexKind === gpuDevices[0].indexKind) - ? gpuDevices[0].indexKind - : null; + isDiffusion: boolean, + devicesReady: boolean, +): PerModelConfig { + const context = pinnableGpuContext( + devicesReady ? gpuDevices : null, + isDiffusion, + ); + const supported = isDiffusion + ? withoutUnsupportedDiffusionSettings( + config, + context.indexKind ?? null, + ) + : config; + if (supported.selectedGpuIds == null) { + return supported; + } + const reconciled = reconcileGpuSelection( + supported.selectedGpuIds, + supported.selectedGpuIndexKind, + context.indexKind, + context.ids, + ); + const next = { + ...supported, + selectedGpuIds: reconciled.ids ?? undefined, + selectedGpuIndexKind: + reconciled.ids === null ? undefined : reconciled.indexKind, + }; + return perModelConfigsEqual(next, supported) ? supported : next; } function ChatTemplateSetting({ @@ -317,29 +344,25 @@ function GpuMemorySettings({ const moeLayersMax = moeLayerCount ?? 0; const showMoeSlider = isManual && !autoLayers && moeLayersMax > 0; const selectedGpuIds = config.selectedGpuIds ?? null; - const gpuIndexKind = gpuIndexKindOf(gpuDevices); + const gpuContext = pinnableGpuContext(gpuDevices, isDiffusion); + const pinnableDevices = gpuContext.devices ?? []; + const gpuIndexKind = gpuContext.indexKind ?? null; const singleGpuInUse = - (selectedGpuIds ?? gpuDevices.map((device) => device.index)).length <= 1; - // Multi-GPU only, with one backend-declared index namespace. null = all. - const showGpuPicker = - gpuDevices.length > 1 && - gpuIndexKind !== null && - gpuDevices.every((d) => - isDiffusion ? d.diffusionPinnable : d.pinnable, - ); + (selectedGpuIds ?? gpuContext.ids ?? []).length <= 1; + // Multi-GPU only, with one backend-declared index namespace. null = automatic. + const showGpuPicker = (gpuContext.ids?.length ?? 0) > 1; const isGpuChecked = (index: number) => selectedGpuIds === null || selectedGpuIds.includes(index); const toggleGpu = (index: number) => { - const all = gpuDevices.map((d) => d.index); + const all = gpuContext.ids ?? []; const current = selectedGpuIds ?? all; const next = current.includes(index) ? current.filter((i) => i !== index) : [...current, index].sort((a, b) => a - b); if (next.length === 0) return; // keep at least one GPU selected - const selectsAll = next.length === all.length; update({ - selectedGpuIds: selectsAll ? null : next, - selectedGpuIndexKind: selectsAll ? null : gpuIndexKind, + selectedGpuIds: next, + selectedGpuIndexKind: gpuIndexKind, }); }; return ( @@ -434,13 +457,13 @@ function GpuMemorySettings({
GPUs - Which GPUs this model may use. Unsloth applies the selection with - the active CUDA, ROCm, or Vulkan backend. Leave all checked to use - every GPU. At least one GPU must stay selected. + By default, Unsloth chooses GPUs automatically. Editing this list + makes the checked GPUs the explicit candidate pool. At least one + GPU must stay selected.
- {gpuDevices.map((d) => ( + {pinnableDevices.map((d) => (
void; - onRun: (config: PerModelConfig, isDiffusion: boolean) => void; + onRun: (config: PerModelConfig, isDiffusion?: boolean) => void; loadedConfig?: PerModelConfig | null; loadedContextLength?: number | null; initialConfig?: PerModelConfig | null; @@ -663,7 +686,7 @@ export function ModelConfigPage({ (s) => s.ggufMaxContextLength, ); const gpuDevices = useGpuDevices(); - const gpuIndexKind = gpuIndexKindOf(gpuDevices); + const gpuDevicesReady = gpuDeviceCacheReady(); const resolveInitial = () => { const resolved = resolveInitialConfig(target.id, target.ggufVariant); if (loadedConfig) { @@ -681,9 +704,12 @@ export function ModelConfigPage({ }; const [initial] = useState(resolveInitial); const [config, setConfig] = useState(() => - isDiffusion - ? withoutUnsupportedDiffusionSettings(initial.config, gpuIndexKind) - : initial.config, + reconcileConfigGpuSelection( + initial.config, + gpuDevices, + isDiffusion, + gpuDevicesReady, + ), ); const [remember, setRemember] = useState(() => initial.remembered); const [savedRemember, setSavedRemember] = useState(() => initial.remembered); @@ -731,7 +757,7 @@ export function ModelConfigPage({ contextLength: number | null; layerCount: number | null; moeLayerCount: number | null; - isDiffusion: boolean; + isDiffusion?: boolean; } | null>(null); useEffect(() => { if (contextFetchKey == null) { @@ -747,9 +773,6 @@ export function ModelConfigPage({ .then((dims) => { if (!cancelled) { setFetchedStagedDims({ key: contextFetchKey, ...dims }); - if (dims.isDiffusion) { - setConfig(withoutUnsupportedDiffusionSettings); - } } }) .catch(() => { @@ -759,7 +782,7 @@ export function ModelConfigPage({ contextLength: null, layerCount: null, moeLayerCount: null, - isDiffusion: false, + isDiffusion: undefined, }); } }); @@ -780,15 +803,21 @@ export function ModelConfigPage({ stagedDims == null && (config.gpuMemoryMode === "manual" || config.selectedGpuIds != null); - const resolvedIsDiffusion = - isDiffusion || stagedDims?.isDiffusion === true; + const classifiedIsDiffusion = + isDiffusion ? true : stagedDims?.isDiffusion; + const resolvedIsDiffusion = classifiedIsDiffusion === true; + const gpuIndexKind = + pinnableGpuContext(gpuDevices, resolvedIsDiffusion).indexKind ?? null; useEffect(() => { - if (resolvedIsDiffusion && gpuIndexKind === "vulkan") { - setConfig((current) => - withoutUnsupportedDiffusionSettings(current, gpuIndexKind), - ); - } - }, [resolvedIsDiffusion, gpuIndexKind]); + setConfig((current) => + reconcileConfigGpuSelection( + current, + gpuDevices, + resolvedIsDiffusion, + gpuDevicesReady, + ), + ); + }, [gpuDevices, gpuDevicesReady, resolvedIsDiffusion]); const isMtp = config.speculativeType != null && @@ -988,7 +1017,7 @@ export function ModelConfigPage({ const effectiveLoadConfig = target.isGguf ? effectiveRuntimeConfig : { ...effectiveRuntimeConfig, maxSeqLength: effectiveMaxSeqLengthValue }; - onRun(effectiveLoadConfig, resolvedIsDiffusion); + onRun(effectiveLoadConfig, classifiedIsDiffusion); }; return ( diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index bab257e80d..7cff5bd7ee 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -7,7 +7,7 @@ import { normalizeSpeculativeType, readPersistedGpuMemoryMode, readPersistedSpeculativeType, - reconcilePersistedGpuIds, + reconcilePersistedGpuSelection, useChatRuntimeStore, } from "@/features/chat"; import { @@ -35,14 +35,14 @@ export function applyPerModelConfigToRuntime( if (maxSeqLength !== store.params.maxSeqLength) { store.setParams({ ...store.params, maxSeqLength }); } - const selectedGpuIds = + const gpuSelection = config.selectedGpuIds !== undefined - ? reconcilePersistedGpuIds( + ? reconcilePersistedGpuSelection( config.selectedGpuIds, config.selectedGpuIndexKind, options.isDiffusion, ) - : null; + : { ids: null, indexKind: null }; useChatRuntimeStore.setState({ customContextLength: config.customContextLength ?? null, kvCacheDtype: config.kvCacheDtype ?? null, @@ -61,13 +61,8 @@ export function applyPerModelConfigToRuntime( gpuLayers: config.gpuLayers ?? GPU_LAYERS_AUTO, nCpuMoe: config.nCpuMoe ?? 0, splitRatio: null, - selectedGpuIds, - selectedGpuIndexKind: - selectedGpuIds == null - ? null - : config.selectedGpuIndexKind === undefined - ? "physical" - : config.selectedGpuIndexKind, + selectedGpuIds: gpuSelection.ids, + selectedGpuIndexKind: gpuSelection.indexKind, }); } @@ -126,16 +121,22 @@ export function perModelConfigsEqual( // Serialize the per-model GPU knobs with the same "absent == default" // coalescing the store applies: mode auto/absent, gpuLayers Auto (< 0) / -// absent, nCpuMoe 0 / absent, and the GPU pick (null / absent = all GPUs). +// absent, nCpuMoe 0 / absent, and null / absent GPU picks as automatic. export function gpuFieldsSignature(config: PerModelConfig): string { + const gpuSelection = + config.selectedGpuIds == null + ? "automatic" + : [ + [...config.selectedGpuIds].sort((a, b) => a - b).join(","), + config.selectedGpuIndexKind === undefined + ? "physical" + : (config.selectedGpuIndexKind ?? "deferred"), + ].join("@"); return [ config.gpuMemoryMode ?? "auto", config.gpuLayers == null || config.gpuLayers < 0 ? -1 : config.gpuLayers, config.nCpuMoe ?? 0, - config.selectedGpuIds == null - ? "all" - : [...config.selectedGpuIds].sort((a, b) => a - b).join(","), - config.selectedGpuIndexKind ?? "untagged", + gpuSelection, ].join("|"); } diff --git a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts index 839d22df8d..1b8ecf9996 100644 --- a/studio/frontend/src/features/model-picker/model-config/per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/per-model-config.ts @@ -19,9 +19,9 @@ export interface PerModelConfig { tensorParallel: boolean; chatTemplateOverride: string | null; // GPU Memory controls (per-model, GGUF-only), optional so older blobs still - // parse. null selectedGpuIds (all GPUs) is distinct from absent. The --tensor-split - // ratio is deliberately not remembered: it is positionally bound to the exact - // GPU set/order and unvalidated. + // parse. null or absent selectedGpuIds means automatic placement; an array is + // an explicit candidate pool. The --tensor-split ratio is deliberately not + // remembered because it is bound to the exact GPU set and order. gpuMemoryMode?: "auto" | "manual"; gpuLayers?: number; nCpuMoe?: number; diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index efa999089f..c438a0db62 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -34,6 +34,70 @@ export interface SystemGpuDevice { export type GpuIndexKind = "physical" | "vulkan"; +export interface ReconciledGpuSelection { + ids: number[] | null; + indexKind: GpuIndexKind | null; +} + +export interface PinnableGpuContext { + devices: SystemGpuDevice[] | null; + ids: number[] | null; + indexKind: GpuIndexKind | null | undefined; +} + +export function pinnableGpuContext( + devices: SystemGpuDevice[] | null, + forDiffusion = false, +): PinnableGpuContext { + if (devices === null) { + return { devices: null, ids: null, indexKind: undefined }; + } + const pinnable = devices.filter((device) => + forDiffusion ? device.diffusionPinnable : device.pinnable, + ); + const indexKind = pinnable[0]?.indexKind ?? null; + if ( + pinnable.length <= 1 || + indexKind === null || + !pinnable.every((device) => device.indexKind === indexKind) + ) { + return { devices: pinnable, ids: [], indexKind: null }; + } + return { + devices: pinnable, + ids: pinnable.map((device) => device.index), + indexKind, + }; +} + +export function reconcileGpuSelection( + ids: number[] | null, + savedIndexKind: GpuIndexKind | null | undefined, + currentIndexKind: GpuIndexKind | null | undefined, + pinnableIds: number[] | null, +): ReconciledGpuSelection { + if (ids == null) return { ids: null, indexKind: null }; + if (currentIndexKind === undefined || pinnableIds === null) { + return { + ids, + indexKind: + savedIndexKind === undefined ? "physical" : savedIndexKind, + }; + } + const expectedIndexKind = + savedIndexKind === undefined ? "physical" : savedIndexKind; + if ( + currentIndexKind === null || + (expectedIndexKind !== null && expectedIndexKind !== currentIndexKind) + ) { + return { ids: null, indexKind: null }; + } + const kept = ids.filter((id) => pinnableIds.includes(id)); + return kept.length + ? { ids: kept, indexKind: currentIndexKind } + : { ids: null, indexKind: null }; +} + const DEFAULT_GPU: GpuInfo = { available: false, budgetKnown: false, @@ -241,6 +305,18 @@ export function useGpuDevices(): SystemGpuDevice[] { return devices; } +/** Whether device discovery is settled enough to rewrite remembered UI state. */ +export function gpuDeviceCacheReady(): boolean { + if (cachedSystem === null) { + return false; + } + const inferenceGpu = cachedSystem.inference_gpu; + return !( + inferenceGpu?.backend === "vulkan" && + !inferenceGpu.available + ); +} + /** Warm the shared system cache before validating persisted GPU IDs. */ export async function ensureGpuDeviceCache(): Promise { await fetchSystemOnce(); @@ -250,23 +326,35 @@ export async function ensureGpuDeviceCache(): Promise { export function cachedPinnableGpuIndices( forDiffusion = false, ): number[] | null { - if (!cachedSystem) return null; - const pinnable = toGpuDevices(cachedSystem).filter((d) => - forDiffusion ? d.diffusionPinnable : d.pinnable, - ); - return pinnable.length > 1 ? pinnable.map((d) => d.index) : []; + return pinnableGpuContext( + cachedSystem ? toGpuDevices(cachedSystem) : null, + forDiffusion, + ).ids; } /** Cached index namespace, undefined before fetch and null when unavailable. */ export function cachedPinnableGpuIndexKind( forDiffusion = false, ): GpuIndexKind | null | undefined { - if (!cachedSystem) return undefined; - const pinnable = toGpuDevices(cachedSystem).filter((d) => - forDiffusion ? d.diffusionPinnable : d.pinnable, - ); - const kinds = new Set(pinnable.map((d) => d.indexKind).filter((k) => k)); - return pinnable.length > 1 && kinds.size === 1 - ? ([...kinds][0] as GpuIndexKind) - : null; + return pinnableGpuContext( + cachedSystem ? toGpuDevices(cachedSystem) : null, + forDiffusion, + ).indexKind; +} + +export function reconcileCachedGpuSelection( + ids: number[] | null, + savedIndexKind?: GpuIndexKind | null, + forDiffusion = false, +): ReconciledGpuSelection { + const context = pinnableGpuContext( + cachedSystem ? toGpuDevices(cachedSystem) : null, + forDiffusion, + ); + return reconcileGpuSelection( + ids, + savedIndexKind, + context.indexKind, + context.ids, + ); } diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 951357700e..739370abbb 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3725,13 +3725,23 @@ if ($LocalLlamaCppLinked) { # by install_llama_prebuilt.py and does not change the torch backend. $llamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() $legacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() + $windowsArm64 = ( + $env:OS -eq "Windows_NT" -and + ( + "$($env:PROCESSOR_ARCHITECTURE)".ToUpperInvariant() -eq "ARM64" -or + "$($env:PROCESSOR_ARCHITEW6432)".ToUpperInvariant() -eq "ARM64" + ) + ) $explicitVulkanBackend = $false if ($llamaBackend -eq "cpu") { $prebuiltArgs += "--force-cpu" } elseif ($llamaBackend -eq "vulkan") { if ($IsMacOS) { Write-Host "[WARN] Vulkan has no effect on macOS; the universal build uses Metal" -ForegroundColor Yellow + } elseif ($windowsArm64) { + throw "Vulkan was requested, but upstream provides no Windows ARM64 Vulkan prebuilt. Unset UNSLOTH_LLAMA_CPP_BACKEND or compile llama.cpp from source." } else { + $prebuiltArgs += @("--llama-backend", "vulkan") $explicitVulkanBackend = $true Write-Host " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan } @@ -3739,6 +3749,9 @@ if ($LocalLlamaCppLinked) { Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$($env:UNSLOTH_LLAMA_CPP_BACKEND)' (expected 'auto', 'cpu', or 'vulkan')" -ForegroundColor Yellow } if (-not $IsMacOS -and $llamaBackend -ne "cpu" -and $legacyForceVulkan -in @("1", "true", "yes", "on")) { + if ($windowsArm64) { + throw "Vulkan was requested, but upstream provides no Windows ARM64 Vulkan prebuilt. Unset UNSLOTH_FORCE_VULKAN or compile llama.cpp from source." + } $explicitVulkanBackend = $true } $prevEAPPrebuilt = $ErrorActionPreference diff --git a/studio/setup.sh b/studio/setup.sh index 236e31b9a9..84d5d8b3c8 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1445,6 +1445,7 @@ else if [ "$_HOST_SYSTEM" = "Darwin" ]; then step "llama.cpp" "Vulkan has no effect on macOS; the universal build uses Metal" "$C_WARN" >&2 else + _PREBUILT_CMD+=(--llama-backend vulkan) _explicit_vulkan_backend=true step "llama.cpp" "Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" "$C_OK" fi diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 835cc5d187..cc2c1a3635 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -199,7 +199,7 @@ def test_compare_load_uses_each_models_gpu_config(): assert "if (ownConfig.selectedGpuIds != null)" in src assert "ownConfig.selectedGpuIndexKind," in src assert "compareLoadKnobs.selectedGpuIndexKind," in src - assert src.count("sel.isDiffusion === true") >= 2 + assert src.count("resolvedIsDiffusion === true") >= 2 for field in ( "gpu_memory_mode: effectiveGpuMemoryMode", "gpu_layers: effectiveGpuLayers", @@ -451,7 +451,7 @@ def test_reset_persists_null_max_length_and_substitutes_only_for_load(): assert "const effectiveLoadConfig" in src # The persisted record is saved from effectiveRuntimeConfig; the load request # carries effectiveLoadConfig (with any committed context input). - assert "onRun(effectiveLoadConfig, resolvedIsDiffusion)" in src + assert "onRun(effectiveLoadConfig, classifiedIsDiffusion)" in src assert "savePerModelConfig(" in src @@ -563,7 +563,7 @@ def test_compare_pane_non_gguf_falls_back_to_app_default(): assert ( "const effectiveMaxSeqLength = ownConfig.customContextLength ?? " "normalizeMaxSeqLength(ownConfig.maxSeqLength) ?? " - "(isGgufLoad ? 0 : DEFAULT_MAX_SEQ_LENGTH);" in src + "(targetIsGguf ? 0 : DEFAULT_MAX_SEQ_LENGTH);" in src ) # The buggy fallback to the active model's shared runtime value must not return. assert "(isGgufLoad ? 0 : maxSeqLength)" not in src @@ -585,15 +585,45 @@ def test_default_gpu_mode_clears_manual_knobs(): def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): store = _read("features/chat/stores/chat-runtime-store.ts") assert "requestedGpuIdsFromResponse(resp)" in store - assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in store - assert "savedIndexKind !== null" in store - assert "currentIndexKind !== expectedIndexKind" in store gpu_info = _read("hooks/use-gpu-info.ts") + assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in gpu_info + assert "expectedIndexKind !== null" in gpu_info + assert "expectedIndexKind !== currentIndexKind" in gpu_info assert "cachedPinnableGpuIndexKind" in gpu_info config = _read("features/model-picker/model-config/per-model-config.ts") assert '"selectedGpuIndexKind"' in config +def test_staged_gpu_pick_reconciles_with_async_namespace_discovery(): + page = _read("features/model-picker/components/model-config-page.tsx") + assert "reconcileGpuSelection(" in page + assert "setConfig((current)" in page + assert "reconcileConfigGpuSelection(" in page + assert "gpuIndexKind" in page + assert "pinnableDevices" in page + assert "reconciled.ids === null ? undefined : reconciled.indexKind" in " ".join( + page.split() + ) + assert "selectsAll ? null : next" not in page + gpu_info = _read("hooks/use-gpu-info.ts") + assert 'inferenceGpu?.backend === "vulkan"' in gpu_info + assert "!inferenceGpu.available" in gpu_info + + +def test_compare_classifies_gguf_before_reconciling_gpu_ids(): + compare = _read("features/chat/shared-composer.tsx") + metadata = compare.index("fetchGgufStagedMetadata(") + reconcile = compare.index("reconcilePersistedGpuIds(", metadata) + validate = compare.index("const validation = await validateModel(", reconcile) + load = compare.index("const resp = await loadModel(", validate) + assert metadata < reconcile < validate < load + assert compare.count("resolvedIsDiffusion") >= 3 + assert "prepareHfTokenForUse(" in compare + page = _read("features/model-picker/components/model-config-page.tsx") + assert "isDiffusion?: boolean" in page + assert "onRun(effectiveLoadConfig, classifiedIsDiffusion)" in page + + def test_cpu_only_llama_build_hides_gpu_picker(): src = (WORKDIR / "studio" / "backend" / "main.py").read_text() assert "and not LlamaCppBackend._backend_lacks_gpu_lib()" in src @@ -615,7 +645,8 @@ def test_diffusion_picker_hides_and_clears_unsupported_memory_modes(): page = _read("features/model-picker/components/model-config-page.tsx") assert 'className={isDiffusion ? "hidden" : ROW_CLASS}' in page assert "withoutUnsupportedDiffusionSettings(config, gpuIndexKind)" in page - assert 'resolvedIsDiffusion && gpuIndexKind === "vulkan"' in page + assert "reconcileConfigGpuSelection(" in page + assert "resolvedIsDiffusion" in page assert "stagedMetadataPending ||" in page assert "config.selectedGpuIds != null" in page for field in ( From df56ec8a71b24145ba228a97428a3f8a1f5e1493 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:45:39 -0300 Subject: [PATCH 076/110] Use published Vulkan bundles --- .../tests/test_install_resolve_prebuilt.py | 159 +++++++++++++++--- studio/install_llama_prebuilt.py | 77 ++++++--- 2 files changed, 187 insertions(+), 49 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index a38daffcf6..6deb337086 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -110,11 +110,12 @@ def test_macos_upstream_pin_only_for_explicit_pre26_upstream(): assert ilp.pinned_macos_release_tag(tahoe, UPSTREAM) is None -def _run_resolve(monkeypatch, capsys, plans_or_exc): +def _run_resolve(monkeypatch, capsys, plans_or_exc, *, host = None, extra_args = ()): monkeypatch.setattr( ilp, "detect_host", - lambda: _host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64"), + lambda: host + or _host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64"), ) def _resolver(tag, host, repo, published_release_tag): @@ -126,7 +127,14 @@ def _run_resolve(monkeypatch, capsys, plans_or_exc): monkeypatch.setattr( sys, "argv", - ["install_llama_prebuilt.py", "--resolve-prebuilt", "latest", "--output-format", "json"], + [ + "install_llama_prebuilt.py", + "--resolve-prebuilt", + "latest", + "--output-format", + "json", + *extra_args, + ], ) rc = ilp.main() assert rc == ilp.EXIT_SUCCESS @@ -155,6 +163,33 @@ def test_resolve_prebuilt_unavailable(monkeypatch, capsys): assert out["repo"] == FORK +def test_resolve_prebuilt_backend_flag_filters_cpu_fallback(monkeypatch, capsys): + cpu_plan = ilp.InstallReleasePlan( + requested_tag = "latest", + llama_tag = "b9585", + release_tag = "release", + attempts = [ + ilp.AssetChoice( + repo = FORK, + tag = "release", + name = "app-release-linux-x64-cpu", + url = "https://example/cpu", + source_label = "published", + install_kind = "linux-cpu", + ) + ], + approved_checksums = SimpleNamespace(), + ) + out = _run_resolve( + monkeypatch, + capsys, + [cpu_plan], + host = _host(is_linux = True, is_x86_64 = True), + extra_args = ("--llama-backend", "vulkan"), + ) + assert out["prebuilt_available"] is False + + def _run_resolve_capture_host(monkeypatch, capsys): """Drive --resolve-prebuilt and return the host the resolver was handed.""" seen = {} @@ -401,6 +436,86 @@ def test_direct_upstream_x86_intel_prefers_vulkan(): assert "linux-cpu" in kinds +def _published_vulkan_bundle(*install_kinds): + artifacts = [ + ilp.PublishedLlamaArtifact( + asset_name = f"app-release-{install_kind}", + install_kind = install_kind, + runtime_line = None, + coverage_class = None, + supported_sms = [], + min_sm = None, + max_sm = None, + bundle_profile = None, + rank = 60, + ) + for install_kind in install_kinds + ] + return ilp.PublishedReleaseBundle( + repo = FORK, + release_tag = "release", + upstream_tag = "b9925", + assets = { + artifact.asset_name: f"https://example/{artifact.asset_name}" + for artifact in artifacts + }, + artifacts = artifacts, + ) + + +def test_fork_linux_intel_prefers_published_vulkan_bundle(): + bundle = _published_vulkan_bundle("linux-vulkan", "linux-cpu") + attempts = ilp._linux_published_attempts( + _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True), + bundle, + ) + assert [attempt.install_kind for attempt in attempts] == [ + "linux-vulkan", + "linux-cpu", + ] + assert attempts[0].source_label == "published" + assert ["llama-diffusion-gemma-visual-server"] in ilp.runtime_payload_health_groups( + attempts[0] + ) + + +def test_fork_windows_intel_prefers_published_vulkan_bundle(): + bundle = _published_vulkan_bundle("windows-vulkan", "windows-cpu") + checksums = ilp.ApprovedReleaseChecksums( + repo = FORK, + release_tag = bundle.release_tag, + upstream_tag = bundle.upstream_tag, + artifacts = { + artifact.asset_name: ilp.ApprovedArtifactHash( + asset_name = artifact.asset_name, + sha256 = "a" * 64, + repo = FORK, + kind = artifact.install_kind, + ) + for artifact in bundle.artifacts + }, + ) + attempts = ilp.resolve_release_asset_choice( + _host( + system = "Windows", + is_windows = True, + is_x86_64 = True, + has_intel_gpu = True, + ), + bundle.upstream_tag, + bundle, + checksums, + ) + assert [attempt.install_kind for attempt in attempts] == [ + "windows-vulkan", + "windows-cpu", + ] + assert attempts[0].source_label == "published" + assert [ + "llama-diffusion-gemma-visual-server.exe" + ] in ilp.runtime_payload_health_groups(attempts[0]) + + def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): # The widened glob must cover both arch-suffixed (x64) and bare (arm64) CPU # libs so a valid Vulkan install is not re-flagged unhealthy every check. @@ -493,15 +608,15 @@ def test_forced_vulkan_skips_cpu_only_release_plans(): assert filtered[0].attempts == [vulkan] -def test_route_to_vulkan_prebuilt_auto_intel_goes_upstream_and_drops_fork_pin(): - # Routing fork -> upstream also drops the fork release pin, which is in a - # different tag namespace and would make the upstream resolver miss. +def test_route_to_vulkan_prebuilt_auto_intel_keeps_fork_release(): + # The fork now publishes a verified Vulkan app bundle that includes the + # DiffusionGemma runner, so keep its release namespace and pin. host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) routed, repo, tag, _persist = ilp._route_to_vulkan_prebuilt( host, FORK, "b9596-mix-abc", force_cpu = False ) - assert repo == UPSTREAM - assert tag == "" + assert repo == FORK + assert tag == "b9596-mix-abc" assert routed.has_intel_gpu is True @@ -650,15 +765,15 @@ def test_route_to_vulkan_prebuilt_non_intel_unchanged(): assert routed is host -def test_resolve_prebuilt_intel_host_routes_to_upstream(monkeypatch, capsys): +def test_resolve_prebuilt_intel_host_keeps_fork_manifest(monkeypatch, capsys): # The --resolve-prebuilt probe must agree with the install path: an - # auto-detected Intel host resolves against upstream (Vulkan), not the fork. + # auto-detected Intel host resolves the fork's Vulkan app bundle. monkeypatch.setattr( ilp, "detect_host", lambda: _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) ) seen, out = _run_resolve_capture_host(monkeypatch, capsys) - assert seen["repo"] == UPSTREAM - assert out["repo"] == UPSTREAM + assert seen["repo"] == FORK + assert out["repo"] == FORK # --------------------------------------------------------------------------- @@ -920,7 +1035,7 @@ def _windows_amd_host(**overrides): def test_route_to_vulkan_prebuilt_auto_fallback_for_legacy_amd_gfx(): host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"]) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" assert routed.has_intel_gpu is True assert routed.has_rocm is False @@ -959,7 +1074,7 @@ def test_route_to_vulkan_prebuilt_auto_fallback_when_no_amd_gpu_reaches_floor(): rocm_gfx_targets = ["gfx803", "gfx900"], ) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" assert routed.has_rocm is False @@ -1027,7 +1142,7 @@ def test_masked_probe_suppression_does_not_touch_non_amd_auto_paths(monkeypatch) _routed, repo, _tag, _persist = ilp._route_to_vulkan_prebuilt( host, FORK, "pin", force_cpu = False ) - assert repo == UPSTREAM + assert repo == FORK def test_route_to_vulkan_prebuilt_hip_masked_host_still_honours_explicit_optin(monkeypatch): @@ -1041,7 +1156,7 @@ def test_route_to_vulkan_prebuilt_hip_masked_host_still_honours_explicit_optin(m _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt( host, FORK, "pin", force_cpu = False, llama_backend = "vulkan" ) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" @@ -1142,7 +1257,7 @@ def test_route_to_vulkan_prebuilt_explicit_opt_in_on_mixed_amd(monkeypatch): rocm_gfx_targets = ["gfx1201", "gfx803"], ) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" assert routed.has_rocm is False @@ -1205,7 +1320,7 @@ def test_route_to_vulkan_prebuilt_explicit_opt_in_overrides_hidden_nvidia(monkey has_usable_nvidia = False, ) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" @@ -1367,7 +1482,7 @@ def test_forwarded_gfx_absent_from_probe_still_allows_explicit_vulkan(monkeypatc host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) host = ilp._apply_host_overrides(host, override_rocm_gfx = "gfx803") _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" @@ -1383,7 +1498,7 @@ def test_forwarded_gfx_on_unprobed_host_still_auto_vulkans(monkeypatch): assert host.rocm_gfx_targets == ["gfx803"] assert ilp._should_auto_vulkan_for_amd_windows(host, FORK) is True _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" @@ -1442,7 +1557,7 @@ def test_llama_backend_flag_beats_conflicting_env(monkeypatch): _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt( host, FORK, "pin", force_cpu = False, llama_backend = "vulkan" ) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" @@ -1495,7 +1610,7 @@ def test_vulkan_opt_in_still_routes_on_windows_x64(monkeypatch): monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) - assert repo == UPSTREAM + assert repo == FORK assert persist == "vulkan" diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index a9aafd98a8..791a14264c 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -3466,6 +3466,17 @@ def resolve_release_asset_choice( published_choice: AssetChoice | None = None if host.is_windows and host.is_x86_64: + if host.has_intel_gpu and not host.has_physical_nvidia and not host.has_rocm: + choices = [ + choice + for choice in ( + published_asset_choice_for_kind(release, "windows-vulkan"), + published_asset_choice_for_kind(release, "windows-cpu"), + ) + if choice is not None + ] + if choices: + return apply_approved_hashes(choices, checksums) # AMD Windows hosts prefer the fork's per-gfx windows-rocm bundle when one # covers the GPU; otherwise fall through to resolve_asset_choice(). Note # that on the fork repo the upstream win-hip archive has no approved hash, @@ -5413,9 +5424,9 @@ def resolve_install_attempts( def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) -> list[AssetChoice]: """Build the install attempts for a fork Linux host from a manifest-described - bundle: CUDA, per-gfx ROCm, or (non-GPU) CPU. Same selection the upstream - filename path used, just sourced from the manifest instead of reconstructed - from asset names.""" + bundle: CUDA, per-gfx ROCm, Vulkan, or CPU. Same selection the upstream filename + path used, just sourced from the manifest instead of reconstructed from asset + names.""" attempts: list[AssetChoice] = [] if host.has_usable_nvidia: # Prefer the cudart major Unsloth loads at runtime (torch's bundled @@ -5440,6 +5451,10 @@ def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) -> if published_rocm is not None: attempts.append(published_rocm) else: + if host.has_intel_gpu and not host.has_physical_nvidia: + vulkan_choice = published_asset_choice_for_kind(bundle, "linux-vulkan") + if vulkan_choice is not None: + attempts.append(vulkan_choice) # CPU-only host. A usable-NVIDIA host never reaches here -- if its CUDA # selection produced nothing we want an empty attempt list so the caller # source-builds with CUDA, not a CPU-only binary silently installed on a @@ -5790,7 +5805,7 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]: ["libggml-hip.so*"], ] if choice.install_kind == "linux-vulkan": - return [ + groups = [ ["libllama-common.so*"], ["libllama.so*"], ["libggml.so*"], @@ -5803,6 +5818,9 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]: ["libmtmd.so*"], ["libggml-vulkan.so*"], ] + if choice.source_label == "published": + groups.append(["llama-diffusion-gemma-visual-server"]) + return groups if choice.install_kind in {"windows-cpu", "windows-arm64"}: return [["llama.dll"]] if choice.install_kind == "windows-cuda": @@ -5823,7 +5841,10 @@ def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]: if choice.install_kind in {"windows-hip", "windows-rocm"}: return [["llama.dll"], ["*hip*.dll"]] if choice.install_kind == "windows-vulkan": - return [["llama.dll"], ["ggml-vulkan.dll"]] + groups = [["llama.dll"], ["ggml-vulkan.dll"]] + if choice.source_label == "published": + groups.append(["llama-diffusion-gemma-visual-server.exe"]) + return groups return [] @@ -5979,6 +6000,12 @@ def validate_prebuilt_choice( ) log(f"overlaying prebuilt bundle {choice.name} into {install_dir}") server_path, quantize_path = install_from_archives(choice, host, install_dir, work_dir) + if choice.install_kind in VULKAN_INSTALL_KINDS and not runtime_payload_is_healthy( + install_dir, host, choice + ): + raise PrebuiltFallback( + f"Vulkan bundle {choice.name} omitted a required runtime component" + ) preflight_linux_installed_binaries((server_path, quantize_path), install_dir, host) preflight_macos_installed_binaries((server_path, quantize_path), install_dir, host) ensure_repo_shape(install_dir) @@ -6371,16 +6398,16 @@ def _route_to_vulkan_prebuilt( force_cpu: bool, llama_backend: str | None = None, ) -> tuple[HostInfo, str, str, str | None]: - """Point a Vulkan-capable host at the upstream ggml-org Vulkan prebuilt. + """Point a Vulkan-capable host at the selected repository's Vulkan prebuilt. - The unsloth published repo ships only CUDA/ROCm/CPU assets, so Vulkan comes from - UPSTREAM_REPO. Three triggers route here, all suppressed when a CPU flag (--cpu-fallback - or --force-cpu, folded into force_cpu) wins: + The default Unsloth release manifest includes Vulkan app bundles, including the + DiffusionGemma visual server. Three triggers route here, all suppressed when a CPU + flag (--cpu-fallback or --force-cpu, folded into force_cpu) wins: * ``UNSLOTH_LLAMA_BACKEND=vulkan`` / ``UNSLOTH_FORCE_VULKAN`` / ``--llama-backend vulkan`` forces Vulkan over the detected CUDA/ROCm backend; * Windows AMD with no HIP-prebuilt gfx arch auto-falls back to Vulkan (#7357); * an auto-detected Intel GPU with NO physical NVIDIA/ROCm, the purpose of the - has_intel_gpu probe, since the fork manifest ships no Vulkan asset. + has_intel_gpu probe. Applied by BOTH the install path and the --resolve-prebuilt probe so the "is a prebuilt available" answer matches what actually gets installed. @@ -6416,29 +6443,21 @@ def _route_to_vulkan_prebuilt( active = _active_rocm_gfx_target(host) or "unknown" log( "Active AMD GPU arch is not supported by the Windows HIP prebuilt " - f"({active}); installing the upstream Vulkan llama.cpp prebuilt instead" + f"({active}); installing the Vulkan llama.cpp prebuilt instead" ) host = _vulkan_only_host(host) persist_backend = "vulkan" elif forced: log( - "Vulkan llama.cpp backend requested; installing the upstream Vulkan " + "Vulkan llama.cpp backend requested; installing the Vulkan " "prebuilt instead of the detected GPU backend" ) host = _vulkan_only_host(host) persist_backend = "vulkan" else: - log("Intel GPU detected; installing the upstream Vulkan llama.cpp prebuilt") + log("Intel GPU detected; installing the Vulkan llama.cpp prebuilt") persist_backend = None - # Swapping the fork for upstream invalidates a fork release pin: the two use - # different tag namespaces (fork b9596-mix- vs upstream b9596), so a - # pinned fork tag would make the upstream resolver query a nonexistent - # release and fall back to source. Drop it and let the upstream resolver - # pick by the requested llama tag. A pin already on an explicit upstream repo - # (repo unchanged here) is preserved. - if published_repo != UPSTREAM_REPO: - published_release_tag = "" - return host, UPSTREAM_REPO, published_release_tag, persist_backend + return host, published_repo, published_release_tag, persist_backend def diffusion_visual_server_backfill_needed( @@ -6644,8 +6663,8 @@ def install_prebuilt( ) # Single resolver: every fork host selects from the release manifest; # an explicit ggml-org override selects by asset filename instead. A - # forced-Vulkan host already has published_repo pointed at - # UPSTREAM_REPO above, so the resolver takes the Vulkan asset branch. + # Vulkan host is rewritten above so either resolver takes its Vulkan + # asset branch. requested_tag, release_plans = resolve_simple_install_release_plans( llama_tag, host, @@ -6847,7 +6866,7 @@ def parse_args() -> argparse.Namespace: "--llama-backend", choices = ("vulkan",), help = ( - "Force the llama.cpp prebuilt backend. vulkan installs the upstream Vulkan " + "Force the llama.cpp prebuilt backend. vulkan installs the Vulkan " "bundle and records the choice so Studio updates keep it; ignored on hosts " "with no Vulkan prebuilt (macOS, Windows arm64). " "Same effect as UNSLOTH_LLAMA_BACKEND=vulkan / UNSLOTH_FORCE_VULKAN=1." @@ -7016,7 +7035,7 @@ def main() -> int: force_cpu = _cpu_mechanism, ) # Same Vulkan routing the install path applies, so the probe's answer - # matches what would install (an Intel/forced-Vulkan host -> upstream). + # matches what would install. host, repo, release_tag, _persist_llama_backend = _route_to_vulkan_prebuilt( host, args.published_repo, @@ -7028,7 +7047,11 @@ def main() -> int: _requested, plans = resolve_simple_install_release_plans( args.resolve_prebuilt, host, repo, release_tag ) - if force_vulkan_requested() and not _cpu_mechanism and not host.is_macos: + if ( + force_vulkan_requested(args.llama_backend) + and not _cpu_mechanism + and not host.is_macos + ): plans = _vulkan_only_release_plans(plans) choice = plans[0].attempts[0] if plans and plans[0].attempts else None if choice is None: From 1f783ff056e22b873da5f87f73d2665282db90a4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:50:03 +0000 Subject: [PATCH 077/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_install_resolve_prebuilt.py | 35 +++++++++---------- .../tests/test_setup_llama_cpp_backend.py | 2 +- studio/install_llama_prebuilt.py | 4 +-- tests/studio/test_model_picker_contracts.py | 4 +-- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 6deb337086..aea9658e4c 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -110,12 +110,18 @@ def test_macos_upstream_pin_only_for_explicit_pre26_upstream(): assert ilp.pinned_macos_release_tag(tahoe, UPSTREAM) is None -def _run_resolve(monkeypatch, capsys, plans_or_exc, *, host = None, extra_args = ()): +def _run_resolve( + monkeypatch, + capsys, + plans_or_exc, + *, + host = None, + extra_args = (), +): monkeypatch.setattr( ilp, "detect_host", - lambda: host - or _host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64"), + lambda: host or _host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64"), ) def _resolver(tag, host, repo, published_release_tag): @@ -456,8 +462,7 @@ def _published_vulkan_bundle(*install_kinds): release_tag = "release", upstream_tag = "b9925", assets = { - artifact.asset_name: f"https://example/{artifact.asset_name}" - for artifact in artifacts + artifact.asset_name: f"https://example/{artifact.asset_name}" for artifact in artifacts }, artifacts = artifacts, ) @@ -469,14 +474,9 @@ def test_fork_linux_intel_prefers_published_vulkan_bundle(): _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True), bundle, ) - assert [attempt.install_kind for attempt in attempts] == [ - "linux-vulkan", - "linux-cpu", - ] + assert [attempt.install_kind for attempt in attempts] == ["linux-vulkan", "linux-cpu"] assert attempts[0].source_label == "published" - assert ["llama-diffusion-gemma-visual-server"] in ilp.runtime_payload_health_groups( - attempts[0] - ) + assert ["llama-diffusion-gemma-visual-server"] in ilp.runtime_payload_health_groups(attempts[0]) def test_fork_windows_intel_prefers_published_vulkan_bundle(): @@ -506,14 +506,11 @@ def test_fork_windows_intel_prefers_published_vulkan_bundle(): bundle, checksums, ) - assert [attempt.install_kind for attempt in attempts] == [ - "windows-vulkan", - "windows-cpu", - ] + assert [attempt.install_kind for attempt in attempts] == ["windows-vulkan", "windows-cpu"] assert attempts[0].source_label == "published" - assert [ - "llama-diffusion-gemma-visual-server.exe" - ] in ilp.runtime_payload_health_groups(attempts[0]) + assert ["llama-diffusion-gemma-visual-server.exe"] in ilp.runtime_payload_health_groups( + attempts[0] + ) def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index f915cfaa3d..f7262a0787 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -220,7 +220,7 @@ def test_ps1_backend_vulkan_is_accepted(value): def test_ps1_forced_vulkan_fails_closed_on_windows_arm64(): ps1 = _SETUP_PS1.read_text(encoding = "utf-8") - arm64_guard = ps1.index('elseif ($windowsArm64)') + arm64_guard = ps1.index("elseif ($windowsArm64)") vulkan_flag = ps1.index( '$prebuiltArgs += @("--llama-backend", "vulkan")', arm64_guard, diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 791a14264c..977623da19 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6003,9 +6003,7 @@ def validate_prebuilt_choice( if choice.install_kind in VULKAN_INSTALL_KINDS and not runtime_payload_is_healthy( install_dir, host, choice ): - raise PrebuiltFallback( - f"Vulkan bundle {choice.name} omitted a required runtime component" - ) + raise PrebuiltFallback(f"Vulkan bundle {choice.name} omitted a required runtime component") preflight_linux_installed_binaries((server_path, quantize_path), install_dir, host) preflight_macos_installed_binaries((server_path, quantize_path), install_dir, host) ensure_repo_shape(install_dir) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index cc2c1a3635..3af2fb3eae 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -601,9 +601,7 @@ def test_staged_gpu_pick_reconciles_with_async_namespace_discovery(): assert "reconcileConfigGpuSelection(" in page assert "gpuIndexKind" in page assert "pinnableDevices" in page - assert "reconciled.ids === null ? undefined : reconciled.indexKind" in " ".join( - page.split() - ) + assert "reconciled.ids === null ? undefined : reconciled.indexKind" in " ".join(page.split()) assert "selectsAll ? null : next" not in page gpu_info = _read("hooks/use-gpu-info.ts") assert 'inferenceGpu?.backend === "vulkan"' in gpu_info From c90ceb2b96784017c761d3bc6f4f9e14a98064fe Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:51:14 -0300 Subject: [PATCH 078/110] Consolidate llama.cpp backend selector --- .../tests/test_install_resolve_prebuilt.py | 70 +++++++++---------- studio/backend/tests/test_llama_cpp_update.py | 2 +- studio/backend/utils/llama_cpp_update.py | 2 +- studio/install_llama_prebuilt.py | 29 ++++---- 4 files changed, 48 insertions(+), 55 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index aea9658e4c..867906eeec 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -544,10 +544,10 @@ def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): def test_force_vulkan_requested_accepts_public_selector_and_legacy_alias( monkeypatch, backend, legacy, expected ): - # UNSLOTH_LLAMA_BACKEND is the public selector; a recognized non-vulkan value - # is authoritative, so it opts out even against a stale legacy alias. + # UNSLOTH_LLAMA_CPP_BACKEND is the public selector; a recognized non-vulkan + # value is authoritative, so it opts out even against a stale legacy alias. for name, value in ( - ("UNSLOTH_LLAMA_BACKEND", backend), + ("UNSLOTH_LLAMA_CPP_BACKEND", backend), ("UNSLOTH_FORCE_VULKAN", legacy), ): if value is None: @@ -1248,7 +1248,7 @@ def test_route_to_vulkan_prebuilt_gfx1034_keeps_rocm(): def test_route_to_vulkan_prebuilt_explicit_opt_in_on_mixed_amd(monkeypatch): - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") host = _windows_amd_host( rocm_gfx_target = "gfx1201", rocm_gfx_targets = ["gfx1201", "gfx803"], @@ -1275,19 +1275,16 @@ def test_direct_upstream_windows_amd_legacy_gfx_routes_to_vulkan(): assert plan.attempts[0].install_kind == "windows-vulkan" -def test_llama_backend_env_requests_vulkan(monkeypatch): +def test_llama_cpp_backend_env_requests_vulkan(monkeypatch): assert ilp.llama_backend_from_env() is None - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") assert ilp.llama_backend_from_env() == "vulkan" assert ilp.force_vulkan_requested() is True -def test_llama_cpp_backend_env_does_not_trigger_vulkan(monkeypatch): - # UNSLOTH_LLAMA_CPP_BACKEND is a separate setup variable (auto/cpu) whose other values - # setup warns about and ignores, so reading it here would opt in behind that warning. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) +def test_llama_cpp_backend_auto_does_not_trigger_vulkan(monkeypatch): monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "auto") assert ilp.llama_backend_from_env() is None assert ilp.force_vulkan_requested() is False @@ -1309,7 +1306,7 @@ def test_route_to_vulkan_prebuilt_hidden_physical_nvidia_amd_not_rerouted(): def test_route_to_vulkan_prebuilt_explicit_opt_in_overrides_hidden_nvidia(monkeypatch): # The physical-NVIDIA guard only gates the AMD auto path; an explicit opt-in wins. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") host = _windows_amd_host( rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"], @@ -1409,7 +1406,7 @@ def test_windows_hip_gfx_floor_covers_every_fork_windows_rocm_bundle(): @pytest.mark.parametrize("gfx", _FORK_WINDOWS_ROCM_GFX) def test_route_to_vulkan_prebuilt_keeps_every_fork_windows_rocm_arch(gfx, monkeypatch): # No ambient opt-in: this asserts the AUTO path leaves covered archs alone. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) host = _windows_amd_host(rocm_gfx_target = gfx, rocm_gfx_targets = [gfx]) routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) @@ -1423,7 +1420,7 @@ def test_forwarded_gfx_does_not_undo_visible_device_auto_vulkan(monkeypatch): # Under CUDA_VISIBLE_DEVICES=1 setup.ps1 still resolves GPU 0 and forwards gfx1100, but # detect_host() resolved the visible gfx1010, so folding the forward in must not # reinstate gfx1100 and install a HIP bundle the visible GPU cannot run. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1010", rocm_gfx_targets = ["gfx1100", "gfx1010"]) @@ -1446,7 +1443,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_the_physical_hip_card(monkeypatch # the HIP target but must not delete the probe's inventory, or the floor check concludes # no AMD GPU here reaches HIP and auto-routes to Vulkan, which ignores the HIP mask and # enumerates the reserved gfx1100. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx1100", "gfx803"]) @@ -1462,7 +1459,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_the_physical_hip_card(monkeypatch def test_forwarded_gfx_absent_from_probe_keeps_a_single_probed_hip_card(monkeypatch): # Same rule on a single-GPU box: a stale below-floor forward over a probe-confirmed # gfx1100 must not auto-route that machine to Vulkan. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) @@ -1474,7 +1471,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_a_single_probed_hip_card(monkeypa def test_forwarded_gfx_absent_from_probe_still_allows_explicit_vulkan(monkeypatch): # The physical-inventory rule gates the AUTO path only; naming the backend wins. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) host = ilp._apply_host_overrides(host, override_rocm_gfx = "gfx803") @@ -1487,7 +1484,7 @@ def test_forwarded_gfx_on_unprobed_host_still_auto_vulkans(monkeypatch): # Negative control: a driver-only AMD host runs no successful probe (no hipinfo, amd-smi # suppressed), so --rocm-gfx is the ONLY source of the arch and there is no inventory to # preserve. This is the #7357 path the feature exists for; it must still reach Vulkan. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = None, rocm_gfx_targets = []) @@ -1502,7 +1499,7 @@ def test_forwarded_gfx_on_unprobed_host_still_auto_vulkans(monkeypatch): def test_forwarded_gfx_still_fills_an_unprobed_arch(monkeypatch): # Negative control: on an amd-smi-only host detect_host() reports no arch, so the # forward is the only source and must still apply. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = None, rocm_gfx_targets = []) @@ -1514,9 +1511,9 @@ def test_forwarded_gfx_still_fills_an_unprobed_arch(monkeypatch): assert persist is None -def test_llama_backend_hip_opts_out_of_auto_vulkan(monkeypatch): - # hip names a backend, so it keeps the fork path even on an auto-fallback arch. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "hip") +def test_llama_cpp_backend_cpu_opts_out_of_auto_vulkan(monkeypatch): + # cpu is an explicit non-Vulkan choice, so it suppresses the auto-fallback. + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"]) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) assert routed is host @@ -1526,10 +1523,10 @@ def test_llama_backend_hip_opts_out_of_auto_vulkan(monkeypatch): def test_explicit_backend_beats_legacy_force_vulkan(monkeypatch): - # A stale UNSLOTH_FORCE_VULKAN must not overrule UNSLOTH_LLAMA_BACKEND=rocm (== hip). + # A stale UNSLOTH_FORCE_VULKAN must not overrule the canonical CPU choice. monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "rocm") - assert ilp.resolved_llama_backend() == "hip" + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + assert ilp.resolved_llama_backend() == "cpu" assert ilp.force_vulkan_requested() is False host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) @@ -1539,7 +1536,7 @@ def test_explicit_backend_beats_legacy_force_vulkan(monkeypatch): def test_unknown_llama_backend_value_falls_through_to_legacy_flag(monkeypatch): # An unrecognised value is ignored, not an error, so the legacy flag still works. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "banana") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "banana") assert ilp.resolved_llama_backend() is None assert ilp.force_vulkan_requested() is False monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") @@ -1548,7 +1545,7 @@ def test_unknown_llama_backend_value_falls_through_to_legacy_flag(monkeypatch): def test_llama_backend_flag_beats_conflicting_env(monkeypatch): # --llama-backend is the caller's explicit request and outranks the env. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "hip") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") assert ilp.force_vulkan_requested("vulkan") is True host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt( @@ -1583,7 +1580,7 @@ def _windows_arm64_host(**overrides): @pytest.mark.parametrize( "env, flag", [ - ({"UNSLOTH_LLAMA_BACKEND": "vulkan"}, None), + ({"UNSLOTH_LLAMA_CPP_BACKEND": "vulkan"}, None), ({"UNSLOTH_FORCE_VULKAN": "1"}, None), ({}, "vulkan"), ], @@ -1604,7 +1601,7 @@ def test_vulkan_opt_in_ignored_on_windows_arm64(monkeypatch, env, flag): def test_vulkan_opt_in_still_routes_on_windows_x64(monkeypatch): # Negative control for the arm64 guard: x64 keeps its Vulkan routing. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) assert repo == FORK @@ -1678,12 +1675,9 @@ def test_marker_records_no_backend_when_vulkan_fell_back_to_cpu(tmp_path): assert marker["llama_backend"] == "vulkan" -# UNSLOTH_LLAMA_CPP_BACKEND (setup.sh/setup.ps1, "auto"|"cpu") and -# UNSLOTH_LLAMA_BACKEND (this module, a backend name) are different variables at -# different layers, and both accept "cpu". setup translates its own =cpu into -# --force-cpu to pin the CPU-only bundle on a GPU host, which is what keeps Intel -# iGPU Vulkan crashes away (#7213). Vulkan is opt-in here, so no trigger it adds -# may outrank that flag on any host. +# setup translates UNSLOTH_LLAMA_CPP_BACKEND=cpu into --force-cpu to pin the +# CPU-only bundle on a GPU host, which is what keeps Intel iGPU Vulkan crashes +# away (#7213). Vulkan is opt-in, so no trigger may outrank that flag on any host. _SIM_PLATFORMS = { # WSL presents as Linux to this resolver, so it rides the Linux row. "Linux": dict( @@ -1765,16 +1759,16 @@ def _sim_host(platform_name, gpu_name): @pytest.mark.parametrize("platform_name", sorted(_SIM_PLATFORMS)) @pytest.mark.parametrize("gpu_name", sorted(_SIM_GPUS)) -@pytest.mark.parametrize("backend_env", [None, "vulkan", "hip", "rocm", "cpu"]) +@pytest.mark.parametrize("backend_env", [None, "auto", "vulkan", "cpu"]) def test_forced_cpu_outranks_every_vulkan_trigger( monkeypatch, platform_name, gpu_name, backend_env ): """A deliberate CPU install stays CPU on every host, whatever asks for Vulkan.""" monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) if backend_env is None: - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) else: - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", backend_env) + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend_env) # The legacy switch too, so a stale one cannot smuggle Vulkan past --force-cpu. monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 8579e6bffb..01a1b2ac3d 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -499,7 +499,7 @@ def test_start_update_preserves_vulkan_via_env(monkeypatch, tmp_path): time.sleep(0.05) assert job["state"] == "success", job assert popen_kwargs["env"]["UNSLOTH_FORCE_VULKAN"] == "1" - assert popen_kwargs["env"]["UNSLOTH_LLAMA_BACKEND"] == "vulkan" + assert popen_kwargs["env"]["UNSLOTH_LLAMA_CPP_BACKEND"] == "vulkan" assert "--llama-backend" in captured["cmd"] and "vulkan" in captured["cmd"] diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index dffcddb452..78a46a91e4 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -463,7 +463,7 @@ def _run_llama_phase( # otherwise re-route and silently replace it. Re-assert via setup's env/CLI flags. if llama_backend == "vulkan" or (asset and "vulkan" in asset.lower()): env["UNSLOTH_FORCE_VULKAN"] = "1" - env["UNSLOTH_LLAMA_BACKEND"] = "vulkan" + env["UNSLOTH_LLAMA_CPP_BACKEND"] = "vulkan" _flow.stream_installer( cmd, env, diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 977623da19..5709ed216b 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6196,19 +6196,16 @@ def _normalized_llama_backend(value: str | None) -> str | None: if not value: return None backend = value.strip().lower() - if backend in {"vulkan", "hip", "rocm", "cpu"}: - return "hip" if backend == "rocm" else backend - return None + return backend if backend in {"vulkan", "cpu"} else None def llama_backend_from_env() -> str | None: """Read an explicit llama.cpp backend preference from the environment. - Only ``UNSLOTH_LLAMA_BACKEND`` is honored. ``UNSLOTH_LLAMA_CPP_BACKEND`` is a separate - setup variable meaning ``auto``/``cpu`` (not a backend name) that setup warns about and - otherwise ignores, so reading it here would force Vulkan behind that warning. + ``UNSLOTH_LLAMA_CPP_BACKEND`` is shared with setup.sh/setup.ps1. ``auto`` and + unknown values leave selection automatic; ``cpu`` and ``vulkan`` are explicit. """ - return _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_BACKEND")) + return _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_CPP_BACKEND")) def resolved_llama_backend(llama_backend: str | None = None) -> str | None: @@ -6218,11 +6215,12 @@ def resolved_llama_backend(llama_backend: str | None = None) -> str | None: def force_vulkan_requested(llama_backend: str | None = None) -> bool: - """Whether this run should install the upstream Vulkan llama.cpp prebuilt. + """Whether this run should install the Vulkan llama.cpp prebuilt. - Triggered by ``UNSLOTH_LLAMA_BACKEND=vulkan``, legacy ``UNSLOTH_FORCE_VULKAN``, or - ``--llama-backend vulkan``. Scoped to the llama.cpp backend; the torch/training stack - installs separately and still sees the real GPU. + Triggered by ``UNSLOTH_LLAMA_CPP_BACKEND=vulkan``, legacy + ``UNSLOTH_FORCE_VULKAN``, or ``--llama-backend vulkan``. Scoped to the + llama.cpp backend; the torch/training stack installs separately and still sees + the real GPU. """ backend = resolved_llama_backend(llama_backend) if backend is not None: @@ -6401,8 +6399,8 @@ def _route_to_vulkan_prebuilt( The default Unsloth release manifest includes Vulkan app bundles, including the DiffusionGemma visual server. Three triggers route here, all suppressed when a CPU flag (--cpu-fallback or --force-cpu, folded into force_cpu) wins: - * ``UNSLOTH_LLAMA_BACKEND=vulkan`` / ``UNSLOTH_FORCE_VULKAN`` / ``--llama-backend - vulkan`` forces Vulkan over the detected CUDA/ROCm backend; + * ``UNSLOTH_LLAMA_CPP_BACKEND=vulkan`` / ``UNSLOTH_FORCE_VULKAN`` / + ``--llama-backend vulkan`` forces Vulkan over the detected CUDA/ROCm backend; * Windows AMD with no HIP-prebuilt gfx arch auto-falls back to Vulkan (#7357); * an auto-detected Intel GPU with NO physical NVIDIA/ROCm, the purpose of the has_intel_gpu probe. @@ -6426,7 +6424,7 @@ def _route_to_vulkan_prebuilt( if host.is_macos: if forced: log( - "UNSLOTH_LLAMA_BACKEND=vulkan is set but ignored on macOS " + "UNSLOTH_LLAMA_CPP_BACKEND=vulkan is set but ignored on macOS " "(Metal is used; there is no Vulkan prebuilt)" ) return host, published_repo, published_release_tag, None @@ -6867,7 +6865,8 @@ def parse_args() -> argparse.Namespace: "Force the llama.cpp prebuilt backend. vulkan installs the Vulkan " "bundle and records the choice so Studio updates keep it; ignored on hosts " "with no Vulkan prebuilt (macOS, Windows arm64). " - "Same effect as UNSLOTH_LLAMA_BACKEND=vulkan / UNSLOTH_FORCE_VULKAN=1." + "Same effect as UNSLOTH_LLAMA_CPP_BACKEND=vulkan / " + "UNSLOTH_FORCE_VULKAN=1." ), ) resolve_group = parser.add_mutually_exclusive_group() From 9638ad9b4567b816445ebc6121d397c7613bd776 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:25:52 -0300 Subject: [PATCH 079/110] Specify UTF-8 for file operations --- studio/install_llama_prebuilt.py | 4 ++-- tests/studio/test_model_picker_contracts.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 5709ed216b..bf9649ce08 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -5659,7 +5659,7 @@ def sync_marker_llama_backend(install_dir: Path, llama_backend: str | None) -> N """Sync the persisted llama.cpp backend when the bundle is reused unchanged.""" marker_path = install_dir / "UNSLOTH_PREBUILT_INFO.json" try: - marker = json.loads(marker_path.read_text()) + marker = json.loads(marker_path.read_text(encoding = "utf-8")) except (OSError, ValueError): return if not isinstance(marker, dict) or marker.get("llama_backend") == llama_backend: @@ -5668,7 +5668,7 @@ def sync_marker_llama_backend(install_dir: Path, llama_backend: str | None) -> N marker.pop("llama_backend", None) else: marker["llama_backend"] = llama_backend - marker_path.write_text(json.dumps(marker, indent = 2) + "\n") + marker_path.write_text(json.dumps(marker, indent = 2) + "\n", encoding = "utf-8") log(f"existing install reused; recorded llama_backend={llama_backend!r} from this run") diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 3af2fb3eae..df27fe3846 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -623,12 +623,12 @@ def test_compare_classifies_gguf_before_reconciling_gpu_ids(): def test_cpu_only_llama_build_hides_gpu_picker(): - src = (WORKDIR / "studio" / "backend" / "main.py").read_text() + src = (WORKDIR / "studio" / "backend" / "main.py").read_text(encoding = "utf-8") assert "and not LlamaCppBackend._backend_lacks_gpu_lib()" in src def test_vulkan_gguf_devices_do_not_replace_global_gpu_info(): - backend = (WORKDIR / "studio" / "backend" / "main.py").read_text() + backend = (WORKDIR / "studio" / "backend" / "main.py").read_text(encoding = "utf-8") assert '"gguf_devices": gguf_devices' in backend assert "gguf_devices = LlamaCppBackend._get_vulkan_gpu_info()" in backend assert "enriched_devices = LlamaCppBackend._get_vulkan_gpu_info()" not in backend From cae788aba548973850241145d55427ff2516d5b7 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:03:16 -0300 Subject: [PATCH 080/110] fix(studio): harden GGUF GPU placement --- studio/backend/core/inference/llama_cpp.py | 55 ++-------- studio/backend/main.py | 19 ++-- studio/backend/routes/inference.py | 28 ++++- studio/backend/tests/test_gpu_selection.py | 42 ++++++-- .../tests/test_install_resolve_prebuilt.py | 30 +++++- .../backend/tests/test_llama_cpp_placement.py | 13 ++- .../tests/test_llama_cpp_vulkan_probe.py | 59 +--------- .../tests/test_setup_llama_cpp_backend.py | 17 ++- .../tests/test_system_vulkan_gpu_info.py | 47 ++++---- studio/backend/utils/hardware/hardware.py | 28 ++--- .../src/features/chat/api/chat-adapter.ts | 12 +++ .../frontend/src/features/chat/chat-page.tsx | 8 +- .../chat/hooks/use-chat-model-runtime.ts | 26 +++-- studio/frontend/src/hooks/use-gpu-info.ts | 102 ++++++++---------- studio/frontend/src/hooks/use-system.ts | 61 +++++++++-- studio/install_llama_prebuilt.py | 31 ++++-- studio/setup.ps1 | 8 +- studio/setup.sh | 4 +- tests/studio/test_model_picker_contracts.py | 10 +- 19 files changed, 329 insertions(+), 271 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 3ead4402b6..ceb8705bb6 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1927,10 +1927,6 @@ def _backfill_usage_from_timings(usage, timings): return out -def _vulkan_lib_filename() -> str: - return "ggml-vulkan.dll" if sys.platform == "win32" else "libggml-vulkan.so" - - # Host RAM to leave free on an integrated GPU, matching llama.cpp's own --fit # margin (default 1024 MiB per device). ggml reports an iGPU's "VRAM" as shared # system RAM, so hold back the same margin rather than inventing a larger one. @@ -3657,42 +3653,6 @@ class LlamaCppBackend: row["name"] = f"Vulkan{row['index']}" return rows - @staticmethod - def _get_vulkan_gpu_info(binary: Optional[str] = None) -> list[dict]: - """Vulkan-ordinal device records suitable for ``/api/system``. - - Unlike CUDA/HIP physical IDs, these indices are explicitly tagged - ``vulkan`` so the frontend can offer them only to the GGUF picker. - """ - devices = [] - for row in LlamaCppBackend.vulkan_device_inventory(binary): - idx, free_mib, is_igpu = row["index"], row["free_mib"], row["is_igpu"] - # An iGPU's heap is shared system RAM, not a VRAM budget: report no - # total and no free, so the UI does not offer it twice. - total_mib = 0 if is_igpu else row["total_mib"] - capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu) - total_gb = round(total_mib / 1024, 2) if total_mib > 0 else 0 - free_gb = round(capped / 1024, 2) if not is_igpu else 0 - used_gb = round(max(0, total_mib - free_mib) / 1024, 2) if total_mib > 0 else None - devices.append( - { - "index": idx, - "visible_ordinal": idx, - "index_kind": "vulkan", - "name": row["name"], - "memory_total_gb": total_gb, - "vram_total_gb": total_gb, - "vram_free_gb": free_gb, - "vram_used_gb": used_gb, - "vram_utilization_pct": ( - round((used_gb / total_gb) * 100, 1) - if used_gb is not None and total_gb > 0 - else None - ), - } - ) - return devices - @staticmethod def _get_gpu_free_memory_vulkan(binary: Optional[str] = None) -> list[tuple[int, int, int]]: """Query free (and total) VRAM per device via the bundled ggml Vulkan backend. @@ -8170,6 +8130,9 @@ class LlamaCppBackend: # Speculative decoding. See _build_speculative_flags for the # mode resolution, benchmarks, and llama.cpp references. + _vulkan_pin_ids = ( + gpu_indices if gpu_indices is not None else (gpu_ids or None) + ) launch_mtp_draft_path = self._resolve_launch_mtp_path( mtp_draft_path = mtp_draft_path, ) @@ -8182,6 +8145,11 @@ class LlamaCppBackend: gpus = bool(_detected_gpus), binary = binary, mtp_draft_path = launch_mtp_draft_path, + draft_device = ( + ",".join(f"Vulkan{i}" for i in _vulkan_pin_ids) + if is_vulkan_backend and _vulkan_pin_ids + else None + ), ) # Remember where the spec block sits so a drafter-load failure # can be retried with these flags swapped out (see below). @@ -8277,10 +8245,6 @@ class LlamaCppBackend: ", ".join(unsupported_cache_flags), ) - # Vulkan pins via --device. Fall back to the requested IDs when - # the fit did not narrow the selection. - _vulkan_pin_ids = gpu_indices if gpu_indices is not None else (gpu_ids or None) - # Record the pin actually applied (fit-narrowed gpu_indices, else the raw # request) for the keep-warm loop, dedupe, and /status, so an explicit # [0, 1] narrowed to [0] records [0] and /status never echoes an ordinal @@ -8964,6 +8928,7 @@ class LlamaCppBackend: gpus: bool, binary: Optional[str], mtp_draft_path: Optional[str] = None, + draft_device: Optional[str] = None, ) -> List[str]: """Return the llama-server flag list for the requested spec mode. @@ -9106,6 +9071,8 @@ class LlamaCppBackend: # main GGUF. if mtp_draft_path: flags.extend(["--model-draft", mtp_draft_path]) + if draft_device: + flags.extend(["--spec-draft-device", draft_device]) logger.info(f"Using separate MTP drafter: {mtp_draft_path}") spec_value = mtp_token ngram_knobs: list[str] = [] diff --git a/studio/backend/main.py b/studio/backend/main.py index a0f20d6f3e..4472c805fb 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1249,20 +1249,16 @@ def _get_cached_system_gpu_info(logger) -> tuple[dict[str, Any], dict[str, Any]] ) enriched_devices.append(enriched_dev) - # Vulkan has its own compact ordinal space, independent of torch and - # CUDA_VISIBLE_DEVICES. Keep torch's view global and expose the ggml - # records separately for the GGUF picker. - gguf_devices = enriched_devices try: from core.inference.llama_cpp import LlamaCppBackend from utils.hardware import DeviceType, get_device - # A Vulkan build is pinnable even on an XPU host, since its picks are - # ggml ordinals rather than torch ones, but only once the probe has - # enumerated some: an empty namespace has nothing valid to offer. - if LlamaCppBackend._is_vulkan_backend(): - gguf_devices = LlamaCppBackend._get_vulkan_gpu_info() - gpu_ids_supported = bool(gguf_devices) + llama_uses_vulkan = LlamaCppBackend._is_vulkan_backend() + if llama_uses_vulkan: + # The separate inference inventory below owns Vulkan ordinals. + # Keep this false so a failed Vulkan probe cannot expose torch + # indices that llama.cpp would interpret in another namespace. + gpu_ids_supported = False else: # XPU indices cannot yet be applied safely across Level Zero's # FLAT and COMPOSITE hierarchy modes. A proven CPU-only @@ -1272,6 +1268,7 @@ def _get_cached_system_gpu_info(logger) -> tuple[dict[str, Any], dict[str, Any]] ) except Exception as e: logger.debug(f"Could not resolve gpu_ids support: {e}") + llama_uses_vulkan = False gpu_ids_supported = True # Preserve backend/index metadata from the visibility probe. In # particular, a CPU training host can expose a Vulkan inference GPU and @@ -1282,7 +1279,6 @@ def _get_cached_system_gpu_info(logger) -> tuple[dict[str, Any], dict[str, Any]] "available": visibility_info.get("available", False), "devices": enriched_devices, "backend": visibility_info.get("backend"), - "gguf_devices": gguf_devices, "gguf_gpu_ids_supported": gpu_ids_supported, } @@ -1291,6 +1287,7 @@ def _get_cached_system_gpu_info(logger) -> tuple[dict[str, Any], dict[str, Any]] # If Vulkan is installed but its probe fails, retain the unavailable # Vulkan shape instead of budgeting training GPUs that llama.cpp cannot use. if visibility_info.get("backend") == "vulkan": + gpu_info["gguf_gpu_ids_supported"] = bool(enriched_devices) inference_gpu_info = gpu_info else: vulkan_info = get_vulkan_inference_gpu_info() diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 4a7e6b430e..840018fd2e 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4507,8 +4507,14 @@ def _reject_draft_device_with_gpu_ids( ) +_DIFFUSION_KIND_UNSET = object() + + async def _resolve_gguf_gpu_ids_for_request( - config: ModelConfig, gpu_ids: Optional[List[int]] + config: ModelConfig, + gpu_ids: Optional[List[int]], + *, + diffusion_kind: Optional[bool] | object = _DIFFUSION_KIND_UNSET, ) -> tuple[Optional[List[int]], bool]: """Validate GGUF GPU IDs and report whether they are Vulkan ordinals.""" if not gpu_ids: @@ -4519,7 +4525,8 @@ async def _resolve_gguf_gpu_ids_for_request( llama_backend = get_llama_cpp_backend() is_vulkan_build = await asyncio.to_thread(llama_backend.is_vulkan_build) - diffusion_kind = _classify_diffusion_gguf(config) + if diffusion_kind is _DIFFUSION_KIND_UNSET: + diffusion_kind = _classify_diffusion_gguf(config) confirmed_diffusion = diffusion_kind is True definitively_non_diffusion = diffusion_kind is False device = get_device() @@ -4611,6 +4618,7 @@ def _guard_chat_load_against_training( n_parallel: int = 1, gpu_memory_mode: Literal["auto", "manual"] = "auto", gpu_ids_are_vulkan_ordinals: bool = False, + diffusion_kind: Optional[bool] | object = _DIFFUSION_KIND_UNSET, ) -> None: """Protect active training from automatically placed chat-model loads. @@ -4634,7 +4642,8 @@ def _guard_chat_load_against_training( return is_gguf = bool(getattr(config, "is_gguf", False)) - diffusion_kind = _classify_diffusion_gguf(config) if is_gguf else False + if diffusion_kind is _DIFFUSION_KIND_UNSET: + diffusion_kind = _classify_diffusion_gguf(config) if is_gguf else False if is_gguf and gpu_memory_mode == "manual" and diffusion_kind is False: return @@ -5134,6 +5143,7 @@ async def _load_model_impl( # Normalize gpu_ids: empty list means auto-selection, same as None effective_gpu_ids = request.gpu_ids if request.gpu_ids else None gpu_ids_are_vulkan_ordinals = False + diffusion_kind = _classify_diffusion_gguf(config) if config.is_gguf else False # Invalid GPU IDs must fail before the training coexistence guard. gguf_gpu_ids: Optional[List[int]] = None @@ -5141,7 +5151,11 @@ async def _load_model_impl( ( gguf_gpu_ids, gpu_ids_are_vulkan_ordinals, - ) = await _resolve_gguf_gpu_ids_for_request(config, effective_gpu_ids) + ) = await _resolve_gguf_gpu_ids_for_request( + config, + effective_gpu_ids, + diffusion_kind = diffusion_kind, + ) _reject_draft_device_with_gpu_ids( gguf_gpu_ids, extra_llama_args, @@ -5190,6 +5204,7 @@ async def _load_model_impl( n_parallel = getattr(fastapi_request.app.state, "llama_parallel_slots", 1), gpu_memory_mode = request.gpu_memory_mode, gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, + diffusion_kind = diffusion_kind, ) # ── GGUF path: load via llama-server ────────────────────── @@ -5745,6 +5760,7 @@ async def validate_model( # unloads the current model. effective_gpu_ids = request.gpu_ids if request.gpu_ids else None gpu_ids_are_vulkan_ordinals = False + diffusion_kind = _classify_diffusion_gguf(config) if config.is_gguf else False if config.is_gguf: ( validated_gpu_ids, @@ -5752,6 +5768,7 @@ async def validate_model( ) = await _resolve_gguf_gpu_ids_for_request( config, effective_gpu_ids, + diffusion_kind = diffusion_kind, ) _reject_draft_device_with_gpu_ids( validated_gpu_ids, @@ -5842,6 +5859,7 @@ async def validate_model( ), gpu_memory_mode = request.gpu_memory_mode, gpu_ids_are_vulkan_ordinals = gpu_ids_are_vulkan_ordinals, + diffusion_kind = diffusion_kind, ) # A selected GGUF loads via llama.cpp: auto_map Python and root pickle weights in a @@ -5915,7 +5933,7 @@ async def validate_model( if native_grant_backed else getattr(config, "display_name", config.identifier), is_gguf = is_gguf, - is_diffusion = is_gguf and _classify_diffusion_gguf(config) is True, + is_diffusion = is_gguf and diffusion_kind is True, is_lora = getattr(config, "is_lora", False), is_vision = getattr(config, "is_vision", False), requires_trust_remote_code = requires_trust_remote_code, diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 124358e78b..3816129933 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -419,8 +419,16 @@ class TestVisibleGpuUtilization(_GpuCacheResetMixin, unittest.TestCase): return_value = True, ), patch( - "core.inference.llama_cpp.LlamaCppBackend._get_gpu_memory", - return_value = [(0, 7402, 8192)], + "core.inference.llama_cpp.LlamaCppBackend.vulkan_device_inventory", + return_value = [ + { + "index": 0, + "name": "Vulkan0", + "free_mib": 7402, + "total_mib": 8192, + "is_igpu": False, + } + ], ), ): result = get_vulkan_inference_gpu_info() @@ -455,8 +463,20 @@ class TestVisibleGpuUtilization(_GpuCacheResetMixin, unittest.TestCase): return_value = True, ), patch( - "core.inference.llama_cpp.LlamaCppBackend._get_gpu_memory", - return_value = [(0, 12288, 0)], + "core.inference.llama_cpp.LlamaCppBackend.vulkan_device_inventory", + return_value = [ + { + "index": 0, + "name": "Vulkan0", + "free_mib": 12288, + "total_mib": 32768, + "is_igpu": True, + } + ], + ), + patch( + "core.inference.llama_cpp._apply_igpu_host_reserve_mib", + return_value = 12288, ), ): result = get_vulkan_inference_gpu_info() @@ -476,8 +496,16 @@ class TestVisibleGpuUtilization(_GpuCacheResetMixin, unittest.TestCase): return_value = True, ), patch( - "core.inference.llama_cpp.LlamaCppBackend._get_gpu_memory", - return_value = [(1, 6144, 8192)], + "core.inference.llama_cpp.LlamaCppBackend.vulkan_device_inventory", + return_value = [ + { + "index": 1, + "name": "Vulkan1", + "free_mib": 6144, + "total_mib": 8192, + "is_igpu": False, + } + ], ), patch( "utils.hardware.nvidia.get_backend_visible_gpu_info", @@ -506,7 +534,7 @@ class TestVisibleGpuUtilization(_GpuCacheResetMixin, unittest.TestCase): return_value = True, ), patch( - "core.inference.llama_cpp.LlamaCppBackend._get_gpu_memory", + "core.inference.llama_cpp.LlamaCppBackend.vulkan_device_inventory", return_value = [], ), ): diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 867906eeec..8464a34cc1 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -443,6 +443,13 @@ def test_direct_upstream_x86_intel_prefers_vulkan(): def _published_vulkan_bundle(*install_kinds): + profiles = { + "linux-vulkan": "linux-vulkan-x64", + "windows-vulkan": "windows-vulkan-x64", + "linux-cpu": "linux-cpu-x64", + "linux-arm64": "linux-cpu-arm64", + "windows-cpu": "windows-cpu-x64", + } artifacts = [ ilp.PublishedLlamaArtifact( asset_name = f"app-release-{install_kind}", @@ -452,7 +459,7 @@ def _published_vulkan_bundle(*install_kinds): supported_sms = [], min_sm = None, max_sm = None, - bundle_profile = None, + bundle_profile = profiles.get(install_kind), rank = 60, ) for install_kind in install_kinds @@ -479,6 +486,21 @@ def test_fork_linux_intel_prefers_published_vulkan_bundle(): assert ["llama-diffusion-gemma-visual-server"] in ilp.runtime_payload_health_groups(attempts[0]) +def test_fork_linux_arm64_does_not_select_x64_vulkan_bundle(): + bundle = _published_vulkan_bundle("linux-vulkan", "linux-arm64") + attempts = ilp._linux_published_attempts( + _host( + machine = "aarch64", + is_linux = True, + is_arm64 = True, + has_intel_gpu = True, + ), + bundle, + ) + + assert [attempt.install_kind for attempt in attempts] == ["linux-arm64"] + + def test_fork_windows_intel_prefers_published_vulkan_bundle(): bundle = _published_vulkan_bundle("windows-vulkan", "windows-cpu") checksums = ilp.ApprovedReleaseChecksums( @@ -1585,9 +1607,9 @@ def _windows_arm64_host(**overrides): ({}, "vulkan"), ], ) -def test_vulkan_opt_in_ignored_on_windows_arm64(monkeypatch, env, flag): - # Upstream builds win-vulkan for x64 only (arm64 gets CPU + opencl-adreno), so rewriting - # the host would only swap the published arm64 bundle for the upstream CPU one. +def test_vulkan_opt_in_keeps_windows_arm64_host_for_strict_rejection(monkeypatch, env, flag): + # Windows arm64 has no compatible Vulkan bundle. Keep the real host shape so + # strict filtering rejects the CPU plan instead of routing through x64. for name, value in env.items(): monkeypatch.setenv(name, value) host = _windows_arm64_host() diff --git a/studio/backend/tests/test_llama_cpp_placement.py b/studio/backend/tests/test_llama_cpp_placement.py index 70387ea83d..052d489e3f 100644 --- a/studio/backend/tests/test_llama_cpp_placement.py +++ b/studio/backend/tests/test_llama_cpp_placement.py @@ -171,12 +171,23 @@ def test_vulkan_auto_fit_and_launch_share_discrete_pool(tmp_path): return None, True backend._select_gpus = fallback - result = _launch(backend, gguf) + backend.probe_server_capabilities = lambda _binary = None: { + "mtp_token": "draft-mtp", + "spec_draft_n_max_flag": "--spec-draft-n-max", + } + backend._resolve_launch_mtp_path = lambda **_kwargs: "/fake/mtp.gguf" + result = _launch( + backend, + gguf, + mtp_draft_path = "/fake/mtp.gguf", + speculative_type = "mtp", + ) assert planned assert all(gpus == [(1, 8_000)] for gpus in planned) cmd = result["cmd"] assert cmd[cmd.index("--device") + 1] == "Vulkan1" + assert cmd[cmd.index("--spec-draft-device") + 1] == "Vulkan1" def test_cuda_selection_uses_visibility_and_removes_environment_placement(tmp_path, monkeypatch): diff --git a/studio/backend/tests/test_llama_cpp_vulkan_probe.py b/studio/backend/tests/test_llama_cpp_vulkan_probe.py index 625247ae13..bf0327de0e 100644 --- a/studio/backend/tests/test_llama_cpp_vulkan_probe.py +++ b/studio/backend/tests/test_llama_cpp_vulkan_probe.py @@ -57,7 +57,6 @@ from core.inference._vulkan_probe import _igpu_flags_and_names # noqa: E402 from core.inference.llama_cpp import ( # noqa: E402 LlamaCppBackend, _llama_lib_dir, - _vulkan_lib_filename, ) MIB = 1024 * 1024 @@ -94,7 +93,8 @@ def _make_vulkan_install(tmp_path: Path) -> str: bindir.mkdir(parents = True) binary = bindir / ("llama-server.exe" if sys.platform == "win32" else "llama-server") binary.write_bytes(b"stub") - (bindir / _vulkan_lib_filename()).write_bytes(b"stub") + vulkan_lib = "ggml-vulkan.dll" if sys.platform == "win32" else "libggml-vulkan.so" + (bindir / vulkan_lib).write_bytes(b"stub") return str(binary) @@ -148,59 +148,6 @@ def test_discrete_gpu_free_is_untouched_and_total_passed_through(tmp_path): assert gpus == [(0, 6 * 1024, 24 * 1024)], gpus -def test_vulkan_device_info_exposes_names_and_pinnable_ordinals(tmp_path): - binary = _make_vulkan_install(tmp_path) - rows = [ - _row(0, 6 * GIB, is_igpu = 0, total_bytes = 24 * GIB, name = "Radeon W7900"), - _row(1, 7 * GIB, is_igpu = 0, total_bytes = 8 * GIB, name = "Radeon W7500"), - ] - with _mock_probe(rows): - devices = LlamaCppBackend._get_vulkan_gpu_info(binary) - - assert [(d["index"], d["index_kind"], d["name"]) for d in devices] == [ - (0, "vulkan", "Radeon W7900"), - (1, "vulkan", "Radeon W7500"), - ] - assert devices[0]["memory_total_gb"] == 24 - assert devices[0]["vram_total_gb"] == 24 - assert devices[0]["vram_free_gb"] == 6 - assert devices[0]["vram_used_gb"] == 18 - assert devices[0]["vram_utilization_pct"] == 75.0 - - -def test_vulkan_device_info_keeps_two_decimals_on_a_non_round_card(tmp_path): - """Whole-GiB fixtures cannot tell 1dp from 2dp, nor a x100 from a x1000.""" - binary = _make_vulkan_install(tmp_path) - rows = [_row(0, 3 * GIB // 2, is_igpu = 0, total_bytes = 13 * GIB // 2, name = "Arc A770")] - with _mock_probe(rows): - [device] = LlamaCppBackend._get_vulkan_gpu_info(binary) - - assert device["memory_total_gb"] == 6.5 - assert device["vram_free_gb"] == 1.5 - assert device["vram_used_gb"] == 5.0 - assert device["vram_utilization_pct"] == 76.9 - - -def test_vulkan_device_info_accepts_legacy_four_column_probe(tmp_path): - binary = _make_vulkan_install(tmp_path) - with _mock_probe([_row(2, 7 * GIB, is_igpu = 0, total_bytes = 8 * GIB)]): - devices = LlamaCppBackend._get_vulkan_gpu_info(binary) - assert devices[0]["name"] == "Vulkan2" - - -def test_vulkan_device_info_does_not_advertise_shared_ram_as_vram(tmp_path): - binary = _make_vulkan_install(tmp_path) - rows = [_row(0, 30 * GIB, is_igpu = 1, total_bytes = 32 * GIB, name = "Integrated GPU")] - with _mock_probe(rows): - [device] = LlamaCppBackend._get_vulkan_gpu_info(binary) - - assert device["memory_total_gb"] == 0 - assert device["vram_total_gb"] == 0 - assert device["vram_free_gb"] == 0 - assert device["vram_used_gb"] is None - assert device["vram_utilization_pct"] is None - - def test_large_discrete_gpu_is_untouched(tmp_path): binary = _make_vulkan_install(tmp_path) # A 48 GiB discrete card stays untouched regardless of size; only the @@ -277,7 +224,7 @@ def test_versioned_only_vulkan_soname_is_probed(tmp_path): bindir.mkdir(parents = True) binary = bindir / "llama-server" binary.write_bytes(b"stub") - (bindir / (_vulkan_lib_filename() + ".0")).write_bytes(b"stub") + (bindir / "libggml-vulkan.so.0").write_bytes(b"stub") rows = [_row(0, 23 * GIB, is_igpu = 0, total_bytes = 24 * GIB)] with _mock_probe(rows): gpus = LlamaCppBackend._get_gpu_free_memory_vulkan(str(binary)) diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index f7262a0787..5aa8bcc5a4 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -40,6 +40,10 @@ def _run(value: str | None, system: str = "Linux") -> tuple[list[str], str]: env["UNSLOTH_LLAMA_CPP_BACKEND"] = value harness = ( f'set -u\n_PREBUILT_CMD=()\nC_WARN=""\nC_OK=""\n_HOST_SYSTEM="{system}"\n' + '_source_backend_choice="$(printf \'%s\' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" ' + '| awk \'{$1=$1; print tolower($0)}\')"\n' + '_source_legacy_force_vulkan="$(printf \'%s\' "${UNSLOTH_FORCE_VULKAN:-}" ' + '| awk \'{$1=$1; print tolower($0)}\')"\n' 'step() { printf "STEP: %s\\n" "$*" >&2; }\n' f"{_backend_block()}\n" 'printf "%s\\n" "${_PREBUILT_CMD[@]}"' @@ -158,7 +162,7 @@ def test_legacy_force_vulkan_gets_the_same_strict_fallback(): assert "1|true|yes|on) _explicit_vulkan_backend=true" in sh ps1 = _SETUP_PS1.read_text(encoding = "utf-8") - assert '$legacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)"' in ps1 + assert "$legacyForceVulkan = $sourceLegacyForceVulkan" in ps1 assert '$legacyForceVulkan -in @("1", "true", "yes", "on")' in ps1 @@ -172,7 +176,7 @@ def _run_ps1(value: str | None) -> str: # The override is normalized (assign + warn) at the top of the prebuilt block and # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( - r'\$llamaBackend = "\$\(\$env:UNSLOTH_LLAMA_CPP_BACKEND\)".*?' + r'\$llamaBackend = \$sourceLlamaBackend.*?' r"Ignoring UNSLOTH_LLAMA_CPP_BACKEND=.*?\n\s*\}", re.DOTALL, ) @@ -182,7 +186,12 @@ def _run_ps1(value: str | None) -> str: env = {k: v for k, v in os.environ.items() if k != "UNSLOTH_LLAMA_CPP_BACKEND"} if value is not None: env["UNSLOTH_LLAMA_CPP_BACKEND"] = value - harness = f'$prebuiltArgs = @()\n{normalize}\n{apply_flag}\n"ARGS:" + ($prebuiltArgs -join ",")' + harness = ( + '$prebuiltArgs = @()\n' + '$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant()\n' + '$sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant()\n' + f'{normalize}\n{apply_flag}\n"ARGS:" + ($prebuiltArgs -join ",")' + ) out = subprocess.run( ["pwsh", "-NoProfile", "-Command", harness], capture_output = True, @@ -226,7 +235,7 @@ def test_ps1_forced_vulkan_fails_closed_on_windows_arm64(): arm64_guard, ) assert arm64_guard < vulkan_flag - assert "upstream provides no Windows ARM64 Vulkan prebuilt" in ps1 + assert "no Windows ARM64 Vulkan bundle is published" in ps1 assert "Unset UNSLOTH_FORCE_VULKAN" in ps1 diff --git a/studio/backend/tests/test_system_vulkan_gpu_info.py b/studio/backend/tests/test_system_vulkan_gpu_info.py index cac2df19e0..7e9dc78d1f 100644 --- a/studio/backend/tests/test_system_vulkan_gpu_info.py +++ b/studio/backend/tests/test_system_vulkan_gpu_info.py @@ -48,13 +48,7 @@ def test_system_gpu_info_preserves_vulkan_visibility_metrics(monkeypatch): from core.inference.llama_cpp import LlamaCppBackend - # _get_vulkan_gpu_info only ever emits ggml ordinals, so tag the stub the way - # production does rather than reusing the torch-side "relative" record. - gguf_device = {**vulkan_device, "index_kind": "vulkan"} monkeypatch.setattr(LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda: True)) - monkeypatch.setattr( - LlamaCppBackend, "_get_vulkan_gpu_info", staticmethod(lambda: [gguf_device]) - ) monkeypatch.setattr(main, "_system_gpu_cache", None) gpu, inference_gpu = main._get_cached_system_gpu_info(SimpleNamespace(debug = lambda *args: None)) @@ -62,12 +56,11 @@ def test_system_gpu_info_preserves_vulkan_visibility_metrics(monkeypatch): assert gpu["available"] is False assert gpu["backend"] == "cpu" assert gpu["index_kind"] == "relative" - # A Vulkan llama.cpp build accepts gpu_ids even when torch training is - # CPU-only: the pick is a ggml ordinal, not a torch device index. - assert gpu["gguf_gpu_ids_supported"] is True - # Torch's view stays empty; the ggml ordinals ride in their own list. + # The training inventory must not advertise physical pins for a Vulkan + # llama.cpp build. Its ordinals live in inference_gpu below. + assert gpu["gguf_gpu_ids_supported"] is False + # Torch's view stays empty; the ggml ordinals stay in inference_gpu. assert gpu["devices"] == [] - assert gpu["gguf_devices"] == [gguf_device] assert inference_gpu["backend"] == "vulkan" assert inference_gpu["devices"] == [vulkan_device] @@ -93,12 +86,10 @@ def test_system_gpu_info_withholds_gguf_pin_when_the_vulkan_probe_enumerates_not from core.inference.llama_cpp import LlamaCppBackend monkeypatch.setattr(LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda: True)) - monkeypatch.setattr(LlamaCppBackend, "_get_vulkan_gpu_info", staticmethod(lambda: [])) monkeypatch.setattr(main, "_system_gpu_cache", None) gpu, _ = main._get_cached_system_gpu_info(SimpleNamespace(debug = lambda *args: None)) - assert gpu["gguf_devices"] == [] assert gpu["gguf_gpu_ids_supported"] is False @@ -219,17 +210,17 @@ def test_vulkan_inference_gpu_uses_real_device_names_and_igpu_flag(monkeypatch): reserve is applied; budgeting off the raw shared total would hand out the whole machine's RAM with no OS headroom. """ + from core.inference import llama_cpp from core.inference.llama_cpp import LlamaCppBackend from utils.hardware.hardware import get_vulkan_inference_gpu_info monkeypatch.setattr( LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda binary = None: True) ) - # Fit view: discrete card keeps its total, iGPU reports 0 with capped free. monkeypatch.setattr( - LlamaCppBackend, - "_get_gpu_memory", - staticmethod(lambda binary = None: [(0, 15 * 1024, 16 * 1024), (1, 12 * 1024, 0)]), + llama_cpp, + "_apply_igpu_host_reserve_mib", + lambda free_mib, is_igpu: 12 * 1024 if is_igpu else free_mib, ) monkeypatch.setattr( LlamaCppBackend, @@ -269,24 +260,28 @@ def test_vulkan_inference_gpu_uses_real_device_names_and_igpu_flag(monkeypatch): assert igpu["memory_total_gb"] == 12.0 -def test_vulkan_inference_gpu_falls_back_to_ordinal_names(monkeypatch): - """A probe that cannot resolve descriptions must not lose the device list: - names degrade to Vulkan and the memory readings still get through.""" +def test_vulkan_inference_gpu_uses_inventory_fallback_names(monkeypatch): + """The inventory's fallback name must flow through unchanged.""" from core.inference.llama_cpp import LlamaCppBackend from utils.hardware.hardware import get_vulkan_inference_gpu_info monkeypatch.setattr( LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda binary = None: True) ) - monkeypatch.setattr( - LlamaCppBackend, - "_get_gpu_memory", - staticmethod(lambda binary = None: [(0, 15 * 1024, 16 * 1024)]), - ) monkeypatch.setattr( LlamaCppBackend, "vulkan_device_inventory", - staticmethod(lambda binary = None: (_ for _ in ()).throw(RuntimeError("probe failed"))), + staticmethod( + lambda binary = None: [ + { + "index": 0, + "name": "Vulkan0", + "free_mib": 15 * 1024, + "total_mib": 16 * 1024, + "is_igpu": False, + } + ] + ), ) info = get_vulkan_inference_gpu_info() diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py index 48ba375ec5..719087c671 100644 --- a/studio/backend/utils/hardware/hardware.py +++ b/studio/backend/utils/hardware/hardware.py @@ -2582,7 +2582,10 @@ def get_vulkan_inference_gpu_info() -> Optional[Dict[str, Any]]: # Vulkan is a llama.cpp inference backend, not a PyTorch training device, so # keep it separate from the PyTorch/MLX training-device report. try: - from core.inference.llama_cpp import LlamaCppBackend + from core.inference.llama_cpp import ( + LlamaCppBackend, + _apply_igpu_host_reserve_mib, + ) except Exception as e: logger.debug("Could not inspect the llama.cpp Vulkan backend: %s", e) return None @@ -2602,23 +2605,12 @@ def get_vulkan_inference_gpu_info() -> Optional[Dict[str, Any]]: "devices": [], "index_kind": "vulkan", } - # Identity (real device description, explicit iGPU flag) comes from the - # inventory; the memory numbers stay on _get_gpu_memory, which applies the - # iGPU host reserve and zeroes a shared total. Budgeting an APU off the raw - # shared total instead would hand out the whole machine's RAM with no OS - # headroom. Join by ordinal; a probe failure just leaves names unresolved. - identity: Dict[int, Dict[str, Any]] = {} try: - identity = {row["index"]: row for row in LlamaCppBackend.vulkan_device_inventory()} - except Exception as e: - logger.debug("Vulkan device inventory failed, falling back to ordinals: %s", e) - - try: - for ordinal, free_mib, total_mib in LlamaCppBackend._get_gpu_memory(): - info = identity.get(ordinal, {}) - # _get_gpu_memory reports total 0 for a shared pool; prefer the - # explicit flag when the inventory resolved this ordinal. - shared_memory = bool(info["is_igpu"]) if "is_igpu" in info else total_mib == 0 + for row in LlamaCppBackend.vulkan_device_inventory(): + ordinal = row["index"] + shared_memory = bool(row["is_igpu"]) + free_mib = _apply_igpu_host_reserve_mib(row["free_mib"], shared_memory) + total_mib = 0 if shared_memory else row["total_mib"] budget_mib = total_mib or free_mib used_mib = max(0, total_mib - free_mib) if total_mib else None result["devices"].append( @@ -2628,7 +2620,7 @@ def get_vulkan_inference_gpu_info() -> Optional[Dict[str, Any]]: # so unlike a torch-xpu relative ordinal these are selectable. "index_kind": "vulkan", "visible_ordinal": ordinal, - "name": info.get("name") or f"Vulkan{ordinal}", + "name": row["name"], "memory_total_gb": round(budget_mib / 1024, 2), "vram_used_gb": round(used_mib / 1024, 2) if used_mib is not None else None, "vram_free_gb": round(free_mib / 1024, 2), diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 8f90f4b2c6..dde172aff7 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -92,6 +92,7 @@ import { resolveLoadMaxSeqLength } from "../presets/preset-policy"; import { generateAudio, GenerationLengthError, + fetchGgufStagedMetadata, listCachedGguf, listCachedModels, listGgufVariants, @@ -1596,11 +1597,22 @@ async function autoLoadSmallestModel(): Promise<{ // the load with the picker hidden. await ensureGpuDeviceCache(); } + const isDiffusion = + candidate.kind === "gguf" && config.selectedGpuIds != null + ? ( + await fetchGgufStagedMetadata({ + model_path: modelPath, + gguf_variant: candidate.ggufVariant, + hf_token: hfToken, + }) + ).isDiffusion + : false; const effectiveGpuIds = config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( config.selectedGpuIds, config.selectedGpuIndexKind, + isDiffusion, ) : null; // Under Manual GPU memory + Auto layers, llama.cpp's --fit owns context diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 13f7f5bde4..bcbba06bea 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -2423,13 +2423,11 @@ export function ChatPage({ const previousConfig = currentRuntimePerModelConfig({ includeMaxSeqLength: true, }); - const hasAppliedConfig = applyModelLoadConfigToRuntime( - selection.config ?? rememberedConfigFor(selection), - { isDiffusion: selection.isDiffusion }, - ); + const loadConfig = + selection.config ?? rememberedConfigFor(selection); await selectModel({ ...selection, - ...(hasAppliedConfig ? { keepSpeculative: true } : {}), + ...(loadConfig ? { config: loadConfig, keepSpeculative: true } : {}), previousConfig, }); }, diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 8f2a707fe9..5633fd6669 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -21,6 +21,7 @@ import { getGgufDownloadProgress, getInferenceStatus, getLoadProgress, + fetchGgufStagedMetadata, listLoras, listModels, loadModel, @@ -490,8 +491,8 @@ export function useChatModelRuntime() { : selection.nativePathExpiresAtMs ?? null; const explicitIsGguf = typeof selection === "string" ? undefined : selection.isGguf; - const isDiffusion = - typeof selection === "string" ? false : selection.isDiffusion === true; + let isDiffusion = + typeof selection === "string" ? undefined : selection.isDiffusion; const restorePreviousConfig = () => { if (typeof selection !== "string" && selection.previousConfig) { applyPerModelConfigToRuntime(selection.previousConfig, { @@ -615,8 +616,21 @@ export function useChatModelRuntime() { let previousWasUnloaded = false; const pendingLoadConfig = typeof selection !== "string" ? selection.config : undefined; + if (isGguf && isDiffusion === undefined) { + isDiffusion = ( + await fetchGgufStagedMetadata({ + model_path: modelId, + gguf_variant: ggufVariant ?? null, + hf_token: useChatRuntimeStore.getState().hfToken || null, + nativePathToken: nativePathToken ?? null, + }) + ).isDiffusion; + } + const targetIsDiffusion = isDiffusion === true; if (pendingLoadConfig) { - applyPerModelConfigToRuntime(pendingLoadConfig); + applyPerModelConfigToRuntime(pendingLoadConfig, { + isDiffusion: targetIsDiffusion, + }); } const currentCheckpoint = useChatRuntimeStore.getState().params.checkpoint; @@ -702,12 +716,12 @@ export function useChatModelRuntime() { ? reconcilePersistedGpuIds( pendingLoadConfig.selectedGpuIds, pendingLoadConfig.selectedGpuIndexKind, - isDiffusion, + targetIsDiffusion, ) : reconcilePersistedGpuIds( stateBeforeUnload.selectedGpuIds, stateBeforeUnload.selectedGpuIndexKind, - isDiffusion, + targetIsDiffusion, ); let loadSpeculativeType = pendingLoadConfig?.speculativeType != null @@ -871,7 +885,7 @@ export function useChatModelRuntime() { ? reconcilePersistedGpuIds( pendingLoadConfig.selectedGpuIds, pendingLoadConfig.selectedGpuIndexKind, - isDiffusion, + targetIsDiffusion, ) : null; loadGpuLayers = pendingLoadConfig?.gpuLayers ?? GPU_LAYERS_AUTO; diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index c438a0db62..fddc50e678 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -1,10 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { authFetch } from "@/features/auth"; import { useEffect, useState } from "react"; import { aggregateGpuMemoryTotalGb, + fetchSystemInfo, + getCachedSystemInfo, + subscribeSystemInfo, type SystemInfoResponse, } from "./use-system"; @@ -109,33 +111,6 @@ const DEFAULT_GPU: GpuInfo = { systemRamTotalGb: 0 }; -// One module-level cache so every GPU hook shares a single /api/system fetch. -let cachedSystem: SystemInfoResponse | null = null; -let systemPromise: Promise | null = null; -// An unavailable Vulkan probe answers gguf_devices as an empty list, so the -// picker starts hidden and only useInferenceGpuInfo retries. Without this, -// hooks that fetch once never see the recovery and stay hidden until remount. -const systemSubscribers = new Set<(data: SystemInfoResponse | null) => void>(); - -async function fetchSystemOnce(force = false): Promise { - if (!force && cachedSystem) return cachedSystem; - if (systemPromise) return systemPromise; - systemPromise = (async () => { - try { - const res = await authFetch("/api/system"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - cachedSystem = (await res.json()) as SystemInfoResponse; - systemSubscribers.forEach((notify) => notify(cachedSystem)); - return cachedSystem; - } catch { - return null; - } finally { - systemPromise = null; - } - })(); - return systemPromise; -} - function toGpuInfo( data: SystemInfoResponse | null, source: "gpu" | "inference_gpu" = "gpu", @@ -195,13 +170,11 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { } // Otherwise the torch view is the pickable set. Unpinnable configurations // must hide every pick surface: the backend reports gguf_gpu_ids_supported, - // and absent support info defaults to pinnable (older backend). GGUF - // placement may use a different namespace from the global torch view, so - // prefer the gguf list when the backend sends one. + // and absent support info defaults to pinnable (older backend). const pinnableBackend = data?.gpu?.gguf_gpu_ids_supported !== false; const diffusionBackend = data?.device_backend === "cuda" || data?.device_backend === "rocm"; - return (data?.gpu?.gguf_devices ?? data?.gpu?.devices ?? []) + return (data?.gpu?.devices ?? []) .filter((d) => typeof d.index === "number") .map((d) => ({ index: d.index as number, @@ -226,6 +199,7 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { /** Aggregate GPU info from /api/system; shares one module-level fetch across all GPU hooks. */ function useGpuInfoSource(source: "gpu" | "inference_gpu"): GpuInfo { + const cachedSystem = getCachedSystemInfo(); const [gpu, setGpu] = useState( cachedSystem ? toGpuInfo(cachedSystem, source) : DEFAULT_GPU, ); @@ -233,33 +207,29 @@ function useGpuInfoSource(source: "gpu" | "inference_gpu"): GpuInfo { // No early return on cachedSystem: a consumer mounting as the cache fills // (between render and effect) would otherwise stay stuck at the default. let cancelled = false; - let retryId: number | undefined; - const update = (force = false, retryVulkan = false) => { - fetchSystemOnce(force).then((d) => { + const sync = (data: SystemInfoResponse) => { + if (cancelled) return; + const next = toGpuInfo(data, source); + setGpu((current) => + JSON.stringify(current) === JSON.stringify(next) ? current : next, + ); + }; + const update = () => { + fetchSystemInfo().then((d) => { if (cancelled) return; - if (!d) { - // Once an unavailable Vulkan backend starts polling, a transient API - // failure must preserve the current state and continue the same loop. - if (retryVulkan) { - retryId = window.setTimeout(() => update(true, true), 3000); - } - return; - } - setGpu(toGpuInfo(d, source)); - const inferenceGpu = d.inference_gpu; - if ( - source === "inference_gpu" && - inferenceGpu?.backend === "vulkan" && - !inferenceGpu.available - ) { - retryId = window.setTimeout(() => update(true, true), 3000); - } + if (!d) return; + // A cache hit does not publish a new snapshot. Sync it here so a + // consumer mounting while the initial request finishes cannot miss it. + sync(d); }); }; + const unsubscribe = subscribeSystemInfo(sync, { + retryUnavailableVulkan: source === "inference_gpu", + }); update(); return () => { cancelled = true; - if (retryId !== undefined) window.clearTimeout(retryId); + unsubscribe(); }; }, [source]); return gpu; @@ -277,6 +247,7 @@ export function useInferenceGpuInfo(): GpuInfo { /** All backend-visible GPUs (index, name, total VRAM); shares the same fetch. */ export function useGpuDevices(): SystemGpuDevice[] { + const cachedSystem = getCachedSystemInfo(); const [devices, setDevices] = useState( cachedSystem ? toGpuDevices(cachedSystem) : [], ); @@ -295,11 +266,13 @@ export function useGpuDevices(): SystemGpuDevice[] { lastSerialized = serialized; setDevices(next); }; - systemSubscribers.add(sync); - fetchSystemOnce().then(sync); + const unsubscribe = subscribeSystemInfo(sync, { + retryUnavailableVulkan: true, + }); + fetchSystemInfo().then(sync); return () => { cancelled = true; - systemSubscribers.delete(sync); + unsubscribe(); }; }, []); return devices; @@ -307,6 +280,7 @@ export function useGpuDevices(): SystemGpuDevice[] { /** Whether device discovery is settled enough to rewrite remembered UI state. */ export function gpuDeviceCacheReady(): boolean { + const cachedSystem = getCachedSystemInfo(); if (cachedSystem === null) { return false; } @@ -319,13 +293,14 @@ export function gpuDeviceCacheReady(): boolean { /** Warm the shared system cache before validating persisted GPU IDs. */ export async function ensureGpuDeviceCache(): Promise { - await fetchSystemOnce(); + await fetchSystemInfo(); } /** Cached pinnable IDs, null before fetch, or [] when pinning is unavailable. */ export function cachedPinnableGpuIndices( forDiffusion = false, ): number[] | null { + const cachedSystem = getCachedSystemInfo(); return pinnableGpuContext( cachedSystem ? toGpuDevices(cachedSystem) : null, forDiffusion, @@ -336,6 +311,7 @@ export function cachedPinnableGpuIndices( export function cachedPinnableGpuIndexKind( forDiffusion = false, ): GpuIndexKind | null | undefined { + const cachedSystem = getCachedSystemInfo(); return pinnableGpuContext( cachedSystem ? toGpuDevices(cachedSystem) : null, forDiffusion, @@ -347,6 +323,18 @@ export function reconcileCachedGpuSelection( savedIndexKind?: GpuIndexKind | null, forDiffusion = false, ): ReconciledGpuSelection { + const cachedSystem = getCachedSystemInfo(); + if (!gpuDeviceCacheReady()) { + return { + ids, + indexKind: + ids == null + ? null + : savedIndexKind === undefined + ? "physical" + : savedIndexKind, + }; + } const context = pinnableGpuContext( cachedSystem ? toGpuDevices(cachedSystem) : null, forDiffusion, diff --git a/studio/frontend/src/hooks/use-system.ts b/studio/frontend/src/hooks/use-system.ts index 468763b026..d5f84f2ef8 100644 --- a/studio/frontend/src/hooks/use-system.ts +++ b/studio/frontend/src/hooks/use-system.ts @@ -27,8 +27,6 @@ export interface SystemGpuInfo { parent_visible_gpu_ids?: number[]; index_kind?: string; devices: GpuDevice[]; - /** GGUF placement devices, using Vulkan ordinals when applicable. */ - gguf_devices?: GpuDevice[]; } /** Sum dedicated VRAM while counting a shared host-memory pool only once. */ @@ -77,7 +75,10 @@ export interface SystemInfoResponse { } let cachedSystem: SystemInfoResponse | null = null; -let systemFetchPromise: Promise | null = null; +let systemFetchPromise: Promise | null = null; +const systemSubscribers = new Set<(data: SystemInfoResponse) => void>(); +let vulkanRetrySubscribers = 0; +let vulkanRetryId: number | null = null; const DEFAULT_SYSTEM: SystemInfoResponse = { platform: "Unknown", @@ -91,9 +92,50 @@ const DEFAULT_SYSTEM: SystemInfoResponse = { ml_packages: {} }; -async function fetchSystemOnce({ +export function getCachedSystemInfo(): SystemInfoResponse | null { + return cachedSystem; +} + +export function subscribeSystemInfo( + subscriber: (data: SystemInfoResponse) => void, + options: { retryUnavailableVulkan?: boolean } = {}, +): () => void { + systemSubscribers.add(subscriber); + if (options.retryUnavailableVulkan) { + vulkanRetrySubscribers += 1; + scheduleVulkanRetry(); + } + return () => { + systemSubscribers.delete(subscriber); + if (options.retryUnavailableVulkan) { + vulkanRetrySubscribers = Math.max(0, vulkanRetrySubscribers - 1); + if (vulkanRetrySubscribers === 0 && vulkanRetryId !== null) { + window.clearTimeout(vulkanRetryId); + vulkanRetryId = null; + } + } + }; +} + +function scheduleVulkanRetry(): void { + const inferenceGpu = cachedSystem?.inference_gpu; + if ( + vulkanRetrySubscribers === 0 || + vulkanRetryId !== null || + inferenceGpu?.backend !== "vulkan" || + inferenceGpu.available + ) { + return; + } + vulkanRetryId = window.setTimeout(() => { + vulkanRetryId = null; + void fetchSystemInfo({ force: true }); + }, 3000); +} + +export async function fetchSystemInfo({ force = false, -}: { force?: boolean } = {}): Promise { +}: { force?: boolean } = {}): Promise { if (systemFetchPromise) return systemFetchPromise; if (!force && cachedSystem) return cachedSystem; @@ -104,12 +146,13 @@ async function fetchSystemOnce({ const data = await res.json(); cachedSystem = data as SystemInfoResponse; + systemSubscribers.forEach((subscriber) => subscriber(cachedSystem!)); return cachedSystem; } catch { - cachedSystem = null; - return DEFAULT_SYSTEM; + return null; } finally { systemFetchPromise = null; + scheduleVulkanRetry(); } })(); @@ -134,9 +177,9 @@ export function useSystemInfo({ let timeoutId: number | null = null; const update = (force: boolean) => { - void fetchSystemOnce({ force }) + void fetchSystemInfo({ force }) .then((info) => { - if (!cancelled) setSystemInfo(info); + if (!cancelled && info) setSystemInfo(info); }) .finally(() => { if (cancelled || !pollMs) return; diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index bf9649ce08..1db7955040 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -3087,13 +3087,26 @@ def resolve_linux_cuda_choice( def published_asset_choice_for_kind( - release: PublishedReleaseBundle, install_kind: str + release: PublishedReleaseBundle, + install_kind: str, + *, + host: HostInfo | None = None, ) -> AssetChoice | None: candidates = sorted( (artifact for artifact in release.artifacts if artifact.install_kind == install_kind), key = lambda artifact: (artifact.rank, artifact.asset_name), ) for artifact in candidates: + if host is not None and install_kind in VULKAN_INSTALL_KINDS: + identity = f"{artifact.bundle_profile or ''} {artifact.asset_name}".lower() + if host.is_arm64: + if not any(token in identity for token in ("arm64", "aarch64")): + continue + elif host.is_x86_64: + if not any(token in identity for token in ("x64", "x86_64", "amd64")): + continue + else: + continue asset_url = release.assets.get(artifact.asset_name) if not asset_url: continue @@ -3104,6 +3117,7 @@ def published_asset_choice_for_kind( url = asset_url, source_label = "published", install_kind = install_kind, + bundle_profile = artifact.bundle_profile, runtime_line = artifact.runtime_line, selection_log = list(release.selection_log) + [f"published_selection: selected {artifact.asset_name} install_kind={install_kind}"], @@ -3470,7 +3484,7 @@ def resolve_release_asset_choice( choices = [ choice for choice in ( - published_asset_choice_for_kind(release, "windows-vulkan"), + published_asset_choice_for_kind(release, "windows-vulkan", host = host), published_asset_choice_for_kind(release, "windows-cpu"), ) if choice is not None @@ -5452,7 +5466,9 @@ def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) -> attempts.append(published_rocm) else: if host.has_intel_gpu and not host.has_physical_nvidia: - vulkan_choice = published_asset_choice_for_kind(bundle, "linux-vulkan") + vulkan_choice = published_asset_choice_for_kind( + bundle, "linux-vulkan", host = host + ) if vulkan_choice is not None: attempts.append(vulkan_choice) # CPU-only host. A usable-NVIDIA host never reaches here -- if its CUDA @@ -6431,8 +6447,8 @@ def _route_to_vulkan_prebuilt( if _has_no_vulkan_prebuilt(host): if forced: log( - "Vulkan llama.cpp backend requested but ignored on Windows arm64 " - "(upstream ships no Vulkan arm64 prebuilt); keeping the published bundle" + "Vulkan llama.cpp backend requested on Windows arm64, but no " + "compatible Vulkan bundle is published" ) return host, published_repo, published_release_tag, None if auto_no_hip: @@ -6863,8 +6879,9 @@ def parse_args() -> argparse.Namespace: choices = ("vulkan",), help = ( "Force the llama.cpp prebuilt backend. vulkan installs the Vulkan " - "bundle and records the choice so Studio updates keep it; ignored on hosts " - "with no Vulkan prebuilt (macOS, Windows arm64). " + "bundle and records the choice so Studio updates keep it. It is ignored " + "on macOS, which uses Metal, and fails closed on Windows arm64 when no " + "compatible Vulkan bundle is published. " "Same effect as UNSLOTH_LLAMA_CPP_BACKEND=vulkan / " "UNSLOTH_FORCE_VULKAN=1." ), diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 739370abbb..cf9c309b22 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3723,8 +3723,8 @@ if ($LocalLlamaCppLinked) { # The backend override is case-insensitive and whitespace-trimmed. cpu # maps to the persisted --force-cpu choice. vulkan is consumed directly # by install_llama_prebuilt.py and does not change the torch backend. - $llamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() - $legacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() + $llamaBackend = $sourceLlamaBackend + $legacyForceVulkan = $sourceLegacyForceVulkan $windowsArm64 = ( $env:OS -eq "Windows_NT" -and ( @@ -3739,7 +3739,7 @@ if ($LocalLlamaCppLinked) { if ($IsMacOS) { Write-Host "[WARN] Vulkan has no effect on macOS; the universal build uses Metal" -ForegroundColor Yellow } elseif ($windowsArm64) { - throw "Vulkan was requested, but upstream provides no Windows ARM64 Vulkan prebuilt. Unset UNSLOTH_LLAMA_CPP_BACKEND or compile llama.cpp from source." + throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_LLAMA_CPP_BACKEND or compile llama.cpp from source." } else { $prebuiltArgs += @("--llama-backend", "vulkan") $explicitVulkanBackend = $true @@ -3750,7 +3750,7 @@ if ($LocalLlamaCppLinked) { } if (-not $IsMacOS -and $llamaBackend -ne "cpu" -and $legacyForceVulkan -in @("1", "true", "yes", "on")) { if ($windowsArm64) { - throw "Vulkan was requested, but upstream provides no Windows ARM64 Vulkan prebuilt. Unset UNSLOTH_FORCE_VULKAN or compile llama.cpp from source." + throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_FORCE_VULKAN or compile llama.cpp from source." } $explicitVulkanBackend = $true } diff --git a/studio/setup.sh b/studio/setup.sh index 84d5d8b3c8..0a872c390d 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1430,8 +1430,8 @@ else _PREBUILT_CMD+=(--has-rocm) fi # The normalized override affects llama.cpp only, not the training backend. - _llama_backend="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" - _legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" + _llama_backend="$_source_backend_choice" + _legacy_force_vulkan="$_source_legacy_force_vulkan" _explicit_vulkan_backend=false case "$_llama_backend" in cpu) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index df27fe3846..74e6d27b24 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -627,13 +627,13 @@ def test_cpu_only_llama_build_hides_gpu_picker(): assert "and not LlamaCppBackend._backend_lacks_gpu_lib()" in src -def test_vulkan_gguf_devices_do_not_replace_global_gpu_info(): +def test_vulkan_inference_devices_do_not_replace_global_gpu_info(): backend = (WORKDIR / "studio" / "backend" / "main.py").read_text(encoding = "utf-8") - assert '"gguf_devices": gguf_devices' in backend - assert "gguf_devices = LlamaCppBackend._get_vulkan_gpu_info()" in backend - assert "enriched_devices = LlamaCppBackend._get_vulkan_gpu_info()" not in backend + assert '"gguf_gpu_ids_supported": gpu_ids_supported' in backend + assert '"inference_gpu": inference_gpu_info' in backend frontend = _read("hooks/use-gpu-info.ts") - assert "data?.gpu?.gguf_devices ?? data?.gpu?.devices" in frontend + assert 'inference?.backend === "vulkan"' in frontend + assert "data?.gpu?.devices ?? []" in frontend def test_diffusion_picker_hides_and_clears_unsupported_memory_modes(): From 80b05b6026f474a13ed71dffbbaa18a22a539a7a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:04:05 +0000 Subject: [PATCH 081/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/llama_cpp.py | 4 +--- studio/backend/tests/test_setup_llama_cpp_backend.py | 8 ++++---- studio/install_llama_prebuilt.py | 4 +--- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index ceb8705bb6..7cd129fd3e 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -8130,9 +8130,7 @@ class LlamaCppBackend: # Speculative decoding. See _build_speculative_flags for the # mode resolution, benchmarks, and llama.cpp references. - _vulkan_pin_ids = ( - gpu_indices if gpu_indices is not None else (gpu_ids or None) - ) + _vulkan_pin_ids = gpu_indices if gpu_indices is not None else (gpu_ids or None) launch_mtp_draft_path = self._resolve_launch_mtp_path( mtp_draft_path = mtp_draft_path, ) diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 5aa8bcc5a4..6fe3a25422 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -41,9 +41,9 @@ def _run(value: str | None, system: str = "Linux") -> tuple[list[str], str]: harness = ( f'set -u\n_PREBUILT_CMD=()\nC_WARN=""\nC_OK=""\n_HOST_SYSTEM="{system}"\n' '_source_backend_choice="$(printf \'%s\' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" ' - '| awk \'{$1=$1; print tolower($0)}\')"\n' + "| awk '{$1=$1; print tolower($0)}')\"\n" '_source_legacy_force_vulkan="$(printf \'%s\' "${UNSLOTH_FORCE_VULKAN:-}" ' - '| awk \'{$1=$1; print tolower($0)}\')"\n' + "| awk '{$1=$1; print tolower($0)}')\"\n" 'step() { printf "STEP: %s\\n" "$*" >&2; }\n' f"{_backend_block()}\n" 'printf "%s\\n" "${_PREBUILT_CMD[@]}"' @@ -176,7 +176,7 @@ def _run_ps1(value: str | None) -> str: # The override is normalized (assign + warn) at the top of the prebuilt block and # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( - r'\$llamaBackend = \$sourceLlamaBackend.*?' + r"\$llamaBackend = \$sourceLlamaBackend.*?" r"Ignoring UNSLOTH_LLAMA_CPP_BACKEND=.*?\n\s*\}", re.DOTALL, ) @@ -187,7 +187,7 @@ def _run_ps1(value: str | None) -> str: if value is not None: env["UNSLOTH_LLAMA_CPP_BACKEND"] = value harness = ( - '$prebuiltArgs = @()\n' + "$prebuiltArgs = @()\n" '$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant()\n' '$sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant()\n' f'{normalize}\n{apply_flag}\n"ARGS:" + ($prebuiltArgs -join ",")' diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 1db7955040..fe02081a14 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -5466,9 +5466,7 @@ def _linux_published_attempts(host: HostInfo, bundle: PublishedReleaseBundle) -> attempts.append(published_rocm) else: if host.has_intel_gpu and not host.has_physical_nvidia: - vulkan_choice = published_asset_choice_for_kind( - bundle, "linux-vulkan", host = host - ) + vulkan_choice = published_asset_choice_for_kind(bundle, "linux-vulkan", host = host) if vulkan_choice is not None: attempts.append(vulkan_choice) # CPU-only host. A usable-NVIDIA host never reaches here -- if its CUDA From 06cf4c624e6cd38235977db981dc36c05d78ad2b Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:37:19 -0300 Subject: [PATCH 082/110] fix(studio): align backend selection contracts --- .../tests/test_install_resolve_prebuilt.py | 41 +++++++++++++++++++ .../components/model-config-page.tsx | 2 + studio/frontend/src/hooks/use-gpu-info.ts | 3 +- studio/install_llama_prebuilt.py | 18 ++++---- tests/studio/test_model_picker_contracts.py | 4 +- 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 8464a34cc1..ba53a804c1 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -695,6 +695,47 @@ def test_resolve_prebuilt_cpu_fallback_overrides_intel_vulkan(monkeypatch, capsy assert seen["repo"] == FORK +def test_cpu_backend_env_forces_cpu_in_resolver(monkeypatch, capsys): + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setattr( + ilp, + "detect_host", + lambda: _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True), + ) + seen, _ = _run_resolve_capture_host(monkeypatch, capsys) + assert seen["host"].has_intel_gpu is False + assert seen["repo"] == FORK + + +def test_cpu_backend_env_forces_cpu_in_direct_install(monkeypatch, tmp_path): + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setattr( + ilp, + "detect_host", + lambda: _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True), + ) + seen = {} + + def _route( + host, + repo, + tag, + *, + force_cpu, + llama_backend = None, + ): + seen["host"] = host + seen["force_cpu"] = force_cpu + raise RuntimeError("stop after routing") + + monkeypatch.setattr(ilp, "_route_to_vulkan_prebuilt", _route) + with pytest.raises(RuntimeError, match = "stop after routing"): + ilp.install_prebuilt(tmp_path, "latest", FORK, "") + + assert seen["host"].has_intel_gpu is False + assert seen["force_cpu"] is True + + @pytest.mark.parametrize( "flags, expect_force, expect_persist", [ diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index ae2ecdced4..aa00a62f47 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -115,6 +115,7 @@ function withoutUnsupportedDiffusionSettings( (config.gpuMemoryMode ?? "auto") === "auto" && config.gpuLayers == null && config.nCpuMoe == null && + !config.tensorParallel && !hasUnsupportedGpuPick ) { return config; @@ -124,6 +125,7 @@ function withoutUnsupportedDiffusionSettings( gpuMemoryMode: "auto", gpuLayers: undefined, nCpuMoe: undefined, + tensorParallel: false, ...(hasUnsupportedGpuPick ? { selectedGpuIds: undefined, diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index fddc50e678..f9e0e156d8 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -172,8 +172,7 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { // must hide every pick surface: the backend reports gguf_gpu_ids_supported, // and absent support info defaults to pinnable (older backend). const pinnableBackend = data?.gpu?.gguf_gpu_ids_supported !== false; - const diffusionBackend = - data?.device_backend === "cuda" || data?.device_backend === "rocm"; + const diffusionBackend = data?.device_backend === "cuda"; return (data?.gpu?.devices ?? []) .filter((d) => typeof d.index === "number") .map((d) => ({ diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index fe02081a14..878d9d8e77 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6636,6 +6636,9 @@ def install_prebuilt( ) -> None: # force_cpu drops GPU detection (mechanism, both --cpu-fallback and --force-cpu); # persist_force_cpu records the deliberate choice so the updater re-asserts it. + if resolved_llama_backend(llama_backend) == "cpu": + force_cpu = True + persist_force_cpu = True host = detect_host() host = _apply_host_overrides( host, @@ -6970,6 +6973,7 @@ def emit_resolver_output(payload: dict[str, Any], *, output_format: str) -> None def main() -> int: args = parse_args() + cpu_backend_requested = resolved_llama_backend(args.llama_backend) == "cpu" if args.validate_install is not None: try: validate_existing_install( @@ -7037,9 +7041,9 @@ def main() -> int: # Host-aware "is a prebuilt available" probe, no download. Every host now # plans against the fork (args.published_repo defaults to it); an explicit # --published-repo overrides. PrebuiltFallback == source build. - # Both flags drop GPU detection; --force-cpu additionally persists (install - # path only). The probe only needs the mechanism, so OR them. - _cpu_mechanism = args.cpu_fallback or args.force_cpu + # CPU fallback and explicit CPU choices drop GPU detection. The probe only + # needs the mechanism; persistence belongs to the install path. + _cpu_mechanism = args.cpu_fallback or args.force_cpu or cpu_backend_requested host = _apply_host_overrides( detect_host(), override_has_rocm = args.has_rocm, @@ -7097,10 +7101,10 @@ def main() -> int: published_release_tag = args.published_release_tag or "", override_has_rocm = args.has_rocm, override_rocm_gfx = args.rocm_gfx, - # Both drop GPU detection; only --force-cpu (deliberate) is recorded so the - # updater re-asserts it. --cpu-fallback stays transient and heals to GPU. - force_cpu = args.cpu_fallback or args.force_cpu, - persist_force_cpu = args.force_cpu, + # Explicit CPU choices persist so the updater re-asserts them. + # --cpu-fallback stays transient and heals to GPU. + force_cpu = args.cpu_fallback or args.force_cpu or cpu_backend_requested, + persist_force_cpu = args.force_cpu or cpu_backend_requested, llama_backend = args.llama_backend, instruction_cleanup_root = install_arg.absolute(), ) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 74e6d27b24..f4572ab8ee 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -651,6 +651,7 @@ def test_diffusion_picker_hides_and_clears_unsupported_memory_modes(): 'gpuMemoryMode: "auto"', "gpuLayers: undefined", "nCpuMoe: undefined", + "tensorParallel: false", "selectedGpuIds: undefined", "selectedGpuIndexKind: undefined", ): @@ -715,5 +716,6 @@ def test_vulkan_inference_devices_are_the_pickable_set(): '(d.index_kind === "vulkan" || ' '(data?.device_backend !== "xpu" && d.index_kind === "physical")),' in src ) - # Only a physical CUDA/ROCm index is ever handed to the diffusion runner. + # Only a physical CUDA index is ever handed to the diffusion runner. + assert 'const diffusionBackend = data?.device_backend === "cuda";' in src assert 'diffusionPinnable: diffusionBackend && d.index_kind === "physical",' in src From 415fd350d6d1f9b7234031fecc32c9ecf6599611 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:29:30 -0300 Subject: [PATCH 083/110] fix(studio): align MTP draft device placement --- studio/backend/core/inference/llama_cpp.py | 34 ++++++++++++------- .../backend/tests/test_llama_cpp_placement.py | 17 ++++++++-- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 7cd129fd3e..5cbbbd9cb7 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1762,16 +1762,27 @@ def _extra_args_draft_offloaded_to_cpu( return False -def _extra_args_draft_device(extra_args: Optional[Iterable[str]]) -> Optional[str]: - """Return the last explicit draft-device value, if any.""" - dev_flags = {"--spec-draft-device", "-devd", "--device-draft"} +def _extra_args_device( + extra_args: Optional[Iterable[str]], flags: Collection[str] +) -> Optional[str]: + """Return the last value for any device flag in ``flags``.""" args = [str(a) for a in extra_args] if extra_args else [] - last_dev: Optional[str] = None + value: Optional[str] = None for i, raw in enumerate(args): flag, eq, inline = raw.partition("=") - if flag in dev_flags: - last_dev = inline if eq else (args[i + 1] if i + 1 < len(args) else "") - return last_dev + if flag in flags: + value = inline if eq else (args[i + 1] if i + 1 < len(args) else "") + return value + + +def _extra_args_main_device(extra_args: Optional[Iterable[str]]) -> Optional[str]: + """Return the last explicit main-device value, if any.""" + return _extra_args_device(extra_args, {"--device", "-dev"}) + + +def _extra_args_draft_device(extra_args: Optional[Iterable[str]]) -> Optional[str]: + """Return the last explicit draft-device value, if any.""" + return _extra_args_device(extra_args, {"--spec-draft-device", "-devd", "--device-draft"}) def _extra_args_draft_device_pin(extra_args: Optional[Iterable[str]]) -> Optional[str]: @@ -8131,6 +8142,9 @@ class LlamaCppBackend: # Speculative decoding. See _build_speculative_flags for the # mode resolution, benchmarks, and llama.cpp references. _vulkan_pin_ids = gpu_indices if gpu_indices is not None else (gpu_ids or None) + _draft_device = _extra_args_main_device(extra_args) if gpu_ids is None else None + if _draft_device is None and is_vulkan_backend and _vulkan_pin_ids: + _draft_device = ",".join(f"Vulkan{i}" for i in _vulkan_pin_ids) launch_mtp_draft_path = self._resolve_launch_mtp_path( mtp_draft_path = mtp_draft_path, ) @@ -8143,11 +8157,7 @@ class LlamaCppBackend: gpus = bool(_detected_gpus), binary = binary, mtp_draft_path = launch_mtp_draft_path, - draft_device = ( - ",".join(f"Vulkan{i}" for i in _vulkan_pin_ids) - if is_vulkan_backend and _vulkan_pin_ids - else None - ), + draft_device = _draft_device, ) # Remember where the spec block sits so a drafter-load failure # can be retried with these flags swapped out (see below). diff --git a/studio/backend/tests/test_llama_cpp_placement.py b/studio/backend/tests/test_llama_cpp_placement.py index 052d489e3f..c63f931047 100644 --- a/studio/backend/tests/test_llama_cpp_placement.py +++ b/studio/backend/tests/test_llama_cpp_placement.py @@ -158,7 +158,17 @@ def test_vulkan_selection_uses_ordinals_and_owns_device_flags(tmp_path): assert backend.gpu_ids == [1] -def test_vulkan_auto_fit_and_launch_share_discrete_pool(tmp_path): +@pytest.mark.parametrize( + "gpu_ids,extra_args,expected_draft,user_device_survives", + [ + (None, None, "Vulkan1", False), + (None, ["--device", "Vulkan1", "-dev=Vulkan0"], "Vulkan0", True), + ([1], ["--device", "Vulkan1", "-dev=Vulkan0"], "Vulkan1", False), + ], +) +def test_vulkan_fit_and_mtp_drafter_follow_placement_owner( + tmp_path, gpu_ids, extra_args, expected_draft, user_device_survives +): backend, gguf = _backend( tmp_path, vulkan = True, @@ -181,13 +191,16 @@ def test_vulkan_auto_fit_and_launch_share_discrete_pool(tmp_path): gguf, mtp_draft_path = "/fake/mtp.gguf", speculative_type = "mtp", + gpu_ids = gpu_ids, + extra_args = extra_args, ) assert planned assert all(gpus == [(1, 8_000)] for gpus in planned) cmd = result["cmd"] assert cmd[cmd.index("--device") + 1] == "Vulkan1" - assert cmd[cmd.index("--spec-draft-device") + 1] == "Vulkan1" + assert cmd[cmd.index("--spec-draft-device") + 1] == expected_draft + assert ("-dev=Vulkan0" in cmd) is user_device_survives def test_cuda_selection_uses_visibility_and_removes_environment_placement(tmp_path, monkeypatch): From b54ebfd937159b419006a3f46f1378301ae7c196 Mon Sep 17 00:00:00 2001 From: InfoSage05 Date: Tue, 28 Jul 2026 12:23:27 +0530 Subject: [PATCH 084/110] fix(studio): address Codex P2s on legacy Vulkan env var and cold-cache GPU pins - llama_backend_from_env() (and setup.sh/setup.ps1's shell-level mirror) falls back to the legacy UNSLOTH_LLAMA_BACKEND when UNSLOTH_LLAMA_CPP_BACKEND is unset, so an existing UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing Vulkan across the setup.sh/setup.ps1 consolidation instead of silently reinstalling CUDA/ROCm/CPU. - loadedGpuMemoryFields() no longer drops a just-applied gpu_ids pin to null when cachedPinnableGpuIndexKind() returns undefined (cache cold / Vulkan probe not ready yet). That state is deferred, not rejected: the pin is kept so a reload/rollback during that window still sends gpu_ids instead of letting llama.cpp fall back to every device. The third P2 (force-compile skipping the Vulkan-source-build rejection) was already fixed by an earlier commit on this branch -- verified _NEED_LLAMA_SOURCE_BUILD is set before the guard runs in both setup.sh and setup.ps1, and added a regression test locking in the ordering. Co-Authored-By: Claude Sonnet 5 --- .../tests/test_install_resolve_prebuilt.py | 27 ++++++++ .../tests/test_setup_llama_cpp_backend.py | 68 ++++++++++++++++++- .../chat/stores/chat-runtime-store.ts | 17 +++-- studio/install_llama_prebuilt.py | 9 ++- studio/setup.ps1 | 8 ++- studio/setup.sh | 12 +++- tests/studio/test_model_picker_contracts.py | 4 +- 7 files changed, 134 insertions(+), 11 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index ba53a804c1..d87ea2f0be 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -1352,6 +1352,33 @@ def test_llama_cpp_backend_auto_does_not_trigger_vulkan(monkeypatch): assert ilp.force_vulkan_requested() is False +def test_legacy_llama_backend_env_still_forces_vulkan(monkeypatch): + # UNSLOTH_LLAMA_BACKEND=vulkan was the documented override before the + # consolidation onto UNSLOTH_LLAMA_CPP_BACKEND; an environment that still + # sets only the legacy name must keep forcing Vulkan, not silently fall + # back to auto-detected CUDA/ROCm/CPU. + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + assert ilp.llama_backend_from_env() == "vulkan" + assert ilp.force_vulkan_requested() is True + + +def test_new_llama_cpp_backend_env_takes_precedence_over_legacy(monkeypatch): + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + assert ilp.llama_backend_from_env() == "cpu" + assert ilp.force_vulkan_requested() is False + + +def test_legacy_llama_backend_env_ignored_when_new_var_unrecognized(monkeypatch): + # An unrecognized UNSLOTH_LLAMA_CPP_BACKEND value (e.g. a typo) still + # normalizes to None, so the legacy var is consulted as a fallback rather + # than the invalid value silently winning. + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "banana") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + assert ilp.llama_backend_from_env() == "vulkan" + + def test_route_to_vulkan_prebuilt_hidden_physical_nvidia_amd_not_rerouted(): # Vulkan ignores CUDA_VISIBLE_DEVICES, so a CUDA-masked NVIDIA card next to a legacy # AMD gfx must not auto-route: Vulkan could grab the reserved NVIDIA GPU. diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 6fe3a25422..0f84872026 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -156,6 +156,25 @@ def test_explicit_vulkan_source_build_fails_closed(): assert "exit 1" in guarded +def test_force_compile_sets_need_source_build_before_vulkan_guard(): + # UNSLOTH_LLAMA_FORCE_COMPILE=1 combined with an explicit Vulkan backend must + # hit the "Vulkan source builds are not supported" rejection, not silently + # fall through to a CUDA/ROCm/CPU source build. That only holds if + # _NEED_LLAMA_SOURCE_BUILD/$NeedLlamaSourceBuild is already true by the time + # the explicit-Vulkan elif guard runs, i.e. set earlier in the script. + sh = _SETUP_SH.read_text(encoding = "utf-8") + force_compile_set = sh.index('if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then\n _NEED_LLAMA_SOURCE_BUILD=true') + vulkan_guard = sh.index('elif [ "$_explicit_vulkan_source_build" = true ] && ') + assert force_compile_set < vulkan_guard + + ps1 = _SETUP_PS1.read_text(encoding = "utf-8") + force_compile_set = ps1.index( + 'if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {\n $NeedLlamaSourceBuild = $true' + ) + vulkan_guard = ps1.index("} elseif ($explicitVulkanSourceBuild -and $NeedLlamaSourceBuild) {") + assert force_compile_set < vulkan_guard + + def test_legacy_force_vulkan_gets_the_same_strict_fallback(): sh = _SETUP_SH.read_text(encoding = "utf-8") assert "_legacy_force_vulkan=" in sh @@ -166,6 +185,53 @@ def test_legacy_force_vulkan_gets_the_same_strict_fallback(): assert '$legacyForceVulkan -in @("1", "true", "yes", "on")' in ps1 +def _source_backend_choice_block() -> str: + text = _SETUP_SH.read_text(encoding = "utf-8") + m = re.search( + r'_source_backend_choice="\$\(printf.*?\n(?:.*?\n)*?fi\n', + text, + ) + assert m, "_source_backend_choice fallback block not found in setup.sh" + return m.group(0) + + +@_SKIP_NO_BASH +@pytest.mark.parametrize( + "cpp_backend, legacy_backend, expected", + [ + (None, "vulkan", "vulkan"), + (None, "VULKAN", "vulkan"), + (None, None, "auto"), + ("cpu", "vulkan", "cpu"), + ("vulkan", None, "vulkan"), + ], +) +def test_legacy_llama_backend_env_falls_back_in_setup_sh( + cpp_backend, legacy_backend, expected +): + # UNSLOTH_LLAMA_BACKEND was the documented override before setup.sh moved to + # UNSLOTH_LLAMA_CPP_BACKEND; an environment that still sets only the legacy + # name must resolve the same as if the new var had been set directly. + env = { + k: v + for k, v in os.environ.items() + if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_LLAMA_BACKEND") + } + if cpp_backend is not None: + env["UNSLOTH_LLAMA_CPP_BACKEND"] = cpp_backend + if legacy_backend is not None: + env["UNSLOTH_LLAMA_BACKEND"] = legacy_backend + harness = ( + "set -u\n" + f"{_source_backend_choice_block()}\n" + 'printf "%s" "$_source_backend_choice"' + ) + out = subprocess.run( + ["bash", "-c", harness], capture_output = True, text = True, env = env, check = True + ) + assert out.stdout == expected + + def _ps1_search(pattern: str, flags = 0) -> str: m = re.search(pattern, _SETUP_PS1.read_text(encoding = "utf-8"), flags) assert m, f"setup.ps1 block not found: {pattern}" @@ -177,7 +243,7 @@ def _run_ps1(value: str | None) -> str: # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( r"\$llamaBackend = \$sourceLlamaBackend.*?" - r"Ignoring UNSLOTH_LLAMA_CPP_BACKEND=.*?\n\s*\}", + r"Ignoring UNSLOTH_LLAMA_CPP_BACKEND.*?\n\s*\}", re.DOTALL, ) apply_flag = _ps1_search( diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index c843ed72f2..1a321607ce 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -711,11 +711,15 @@ export function loadedGpuMemoryFields(resp: { reportedGpuIds == null ? null : cachedPinnableGpuIndexKind(resp.is_diffusion === true); - // A numeric ID is unsafe to adopt or persist until discovery says whether it - // is a physical CUDA/ROCm ID or a Vulkan ordinal. A later status refresh can - // restore the requested pool after the shared system cache is warm. + // A numeric ID is unsafe to adopt or persist once discovery says it is NOT a + // physical CUDA/ROCm ID or a Vulkan ordinal (gpuIndexKind === null, cache + // warm). But while discovery is still cold (gpuIndexKind === undefined) the + // namespace is merely deferred, not rejected -- keep the just-applied pin so + // a reload/rollback in that window doesn't omit gpu_ids and let llama.cpp + // fall back to every device. A later status refresh resolves the namespace + // once the shared system cache warms. const gpuIds = - reportedGpuIds != null && gpuIndexKind != null ? reportedGpuIds : null; + reportedGpuIds != null && gpuIndexKind !== null ? reportedGpuIds : null; // Layer/MoE/split knobs apply (and are reported) only in manual mode; in auto // the server ignores them, so don't seed the loaded baseline or the editable // knobs with values it never applied. In manual, the server reports gpu_layers @@ -755,7 +759,10 @@ export function loadedGpuMemoryFields(resp: { moeLayerCount: resp.n_moe_layers ?? null, // The picker reflects the requested placement pool, not a fitted subset. selectedGpuIds: gpuIds, - selectedGpuIndexKind: gpuIds == null ? null : gpuIndexKind, + // gpuIndexKind is `undefined` only in the deferred (cache-cold) case here, + // since a `null` kind already forced gpuIds to null above. Normalize to the + // explicit-null-namespace convention (discovery not complete yet). + selectedGpuIndexKind: gpuIds == null ? null : (gpuIndexKind ?? null), loadedGpuIds: gpuIds, ...manualKnobs, }; diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 878d9d8e77..96ca92877d 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6218,8 +6218,15 @@ def llama_backend_from_env() -> str | None: ``UNSLOTH_LLAMA_CPP_BACKEND`` is shared with setup.sh/setup.ps1. ``auto`` and unknown values leave selection automatic; ``cpu`` and ``vulkan`` are explicit. + Falls back to the legacy ``UNSLOTH_LLAMA_BACKEND`` var (pre-consolidation name) + when the new one is unset/auto, so an existing UNSLOTH_LLAMA_BACKEND=vulkan + environment still forces Vulkan instead of silently reverting to auto-detected + CUDA/ROCm/CPU. """ - return _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_CPP_BACKEND")) + backend = _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_CPP_BACKEND")) + if backend is not None: + return backend + return _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_BACKEND")) def resolved_llama_backend(llama_backend: str | None = None) -> str | None: diff --git a/studio/setup.ps1 b/studio/setup.ps1 index cf9c309b22..5bca27b9ec 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3482,6 +3482,12 @@ $ResolvedSourceRef = $RequestedLlamaTag $ResolvedSourceRefKind = "tag" $ResolvedLlamaTag = $RequestedLlamaTag $sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() +if (-not $sourceLlamaBackend) { + # Legacy pre-consolidation var name; still honored so an existing + # UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing Vulkan instead of + # silently reverting to auto-detected CUDA/ROCm/CPU. + $sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant() +} $sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() $explicitVulkanSourceBuild = ( -not $IsMacOS -and @@ -3746,7 +3752,7 @@ if ($LocalLlamaCppLinked) { Write-Host " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan } } elseif ($llamaBackend -and $llamaBackend -notin @("auto", "vulkan")) { - Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$($env:UNSLOTH_LLAMA_CPP_BACKEND)' (expected 'auto', 'cpu', or 'vulkan')" -ForegroundColor Yellow + Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND/UNSLOTH_LLAMA_BACKEND='$llamaBackend' (expected 'auto', 'cpu', or 'vulkan')" -ForegroundColor Yellow } if (-not $IsMacOS -and $llamaBackend -ne "cpu" -and $legacyForceVulkan -in @("1", "true", "yes", "on")) { if ($windowsArm64) { diff --git a/studio/setup.sh b/studio/setup.sh index 0a872c390d..59193fc626 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -40,6 +40,8 @@ fi # UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", or "vulkan". "cpu" # forces the CPU-only prebuilt. "vulkan" selects # Vulkan even when CUDA or ROCm is detected. +# UNSLOTH_LLAMA_BACKEND (legacy name) is honored +# as a fallback when this is unset. # ────────────────────────────────────────────────────────────────────────── _DEFAULT_LLAMA_PR_FORCE="" _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" @@ -1238,7 +1240,13 @@ _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" _HOST_SYSTEM="$(uname -s 2>/dev/null || true)" _HOST_MACHINE="$(uname -m 2>/dev/null || true)" -_source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" +_source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-}" | awk '{$1=$1; print tolower($0)}')" +if [ -z "$_source_backend_choice" ]; then + # Legacy pre-consolidation var name; still honored so an existing + # UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing Vulkan instead of + # silently reverting to auto-detected CUDA/ROCm/CPU. + _source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" +fi _source_legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" _explicit_vulkan_source_build=false if [ "$_HOST_SYSTEM" != "Darwin" ] && [ "$_source_backend_choice" != "cpu" ]; then @@ -1451,7 +1459,7 @@ else fi ;; ""|auto) ;; - *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$UNSLOTH_LLAMA_CPP_BACKEND' (expected 'auto', 'cpu', or 'vulkan')" "$C_WARN" >&2 ;; + *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_CPP_BACKEND/UNSLOTH_LLAMA_BACKEND='$_llama_backend' (expected 'auto', 'cpu', or 'vulkan')" "$C_WARN" >&2 ;; esac if [ "$_llama_backend" != "cpu" ] && [ "$_HOST_SYSTEM" != "Darwin" ]; then case "$_legacy_force_vulkan" in diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index f4572ab8ee..93ca2e7dd5 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -184,7 +184,9 @@ def test_gpu_picker_round_trips_requested_pool_not_fitted_subset(): store = _read("features/chat/stores/chat-runtime-store.ts") assert 'hasOwnProperty.call(resp, "requested_gpu_ids")' in store assert "const reportedGpuIds = requestedGpuIdsFromResponse(resp)" in store - assert "reportedGpuIds != null && gpuIndexKind != null" in store + # A cold discovery cache reports gpuIndexKind === undefined (deferred, not + # rejected); only a warm cache's definitive null should drop the pin. + assert "reportedGpuIds != null && gpuIndexKind !== null" in store status = _read("features/chat/lib/apply-inference-status-to-store.ts") assert "const incomingGpuFields = loadedGpuMemoryFields(status)" in status From 80c56d0d63a036744777b13d5fe4866e044ee1a0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:54:24 +0000 Subject: [PATCH 085/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../backend/tests/test_setup_llama_cpp_backend.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 0f84872026..5e7a2c219e 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -163,7 +163,9 @@ def test_force_compile_sets_need_source_build_before_vulkan_guard(): # _NEED_LLAMA_SOURCE_BUILD/$NeedLlamaSourceBuild is already true by the time # the explicit-Vulkan elif guard runs, i.e. set earlier in the script. sh = _SETUP_SH.read_text(encoding = "utf-8") - force_compile_set = sh.index('if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then\n _NEED_LLAMA_SOURCE_BUILD=true') + force_compile_set = sh.index( + 'if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then\n _NEED_LLAMA_SOURCE_BUILD=true' + ) vulkan_guard = sh.index('elif [ "$_explicit_vulkan_source_build" = true ] && ') assert force_compile_set < vulkan_guard @@ -206,9 +208,7 @@ def _source_backend_choice_block() -> str: ("vulkan", None, "vulkan"), ], ) -def test_legacy_llama_backend_env_falls_back_in_setup_sh( - cpp_backend, legacy_backend, expected -): +def test_legacy_llama_backend_env_falls_back_in_setup_sh(cpp_backend, legacy_backend, expected): # UNSLOTH_LLAMA_BACKEND was the documented override before setup.sh moved to # UNSLOTH_LLAMA_CPP_BACKEND; an environment that still sets only the legacy # name must resolve the same as if the new var had been set directly. @@ -222,9 +222,7 @@ def test_legacy_llama_backend_env_falls_back_in_setup_sh( if legacy_backend is not None: env["UNSLOTH_LLAMA_BACKEND"] = legacy_backend harness = ( - "set -u\n" - f"{_source_backend_choice_block()}\n" - 'printf "%s" "$_source_backend_choice"' + "set -u\n" f"{_source_backend_choice_block()}\n" 'printf "%s" "$_source_backend_choice"' ) out = subprocess.run( ["bash", "-c", harness], capture_output = True, text = True, env = env, check = True @@ -242,8 +240,7 @@ def _run_ps1(value: str | None) -> str: # The override is normalized (assign + warn) at the top of the prebuilt block and # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( - r"\$llamaBackend = \$sourceLlamaBackend.*?" - r"Ignoring UNSLOTH_LLAMA_CPP_BACKEND.*?\n\s*\}", + r"\$llamaBackend = \$sourceLlamaBackend.*?Ignoring UNSLOTH_LLAMA_CPP_BACKEND.*?\n\s*\}", re.DOTALL, ) apply_flag = _ps1_search( From b74bfe9f123b19fd7cc1adc5e97977fa9e0de7b5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 28 Jul 2026 10:41:23 +0000 Subject: [PATCH 086/110] Studio: fix Vulkan backend env fallback and GGUF preflight token Three narrow correctness fixes on the Vulkan GPU selection path. setup.sh / setup.ps1 consulted the legacy UNSLOTH_LLAMA_BACKEND only when UNSLOTH_LLAMA_CPP_BACKEND was empty, while install_llama_prebuilt.py's llama_backend_from_env() consults it whenever the new var is not an explicit backend name. With UNSLOTH_LLAMA_CPP_BACKEND=auto beside a legacy UNSLOTH_LLAMA_BACKEND=vulkan the shell dropped the request, leaving _explicit_vulkan_backend and _explicit_vulkan_source_build false while the installer it launches still planned Vulkan, so a Vulkan install that could not be satisfied silently degraded to a CUDA/ROCm/CPU build instead of failing closed. Mirror the Python rule in both scripts; an unrecognized value with no legacy backend is still preserved so the warning can name it. The new GGUF diffusion preflight in the chat load path sent the raw stored HF token to /validate before validateModel/loadModel could run prepareHfTokenForUse. The Hub rejects an invalid Authorization header with 401 even on a public repo, so a stale saved token aborted the whole load instead of offering the existing continue-anonymously/replace-token recovery. Prepare the token the same way the compare path already does. The training guard handed can_load_chat_during_training an empty Vulkan free-VRAM map for an unclassified GGUF on a Vulkan build. That reads as no free VRAM anywhere, so every uncached remote GGUF was refused with 409 while training ran. Only an explicit pin is unbudgetable (the ordinal belongs to one namespace and neither can stand in for the other); automatic placement has no ordinal to mis-map, so it keeps the torch view. Each fix has a regression test that fails without it. --- studio/backend/routes/inference.py | 9 ++- .../tests/test_chat_load_during_training.py | 55 +++++++++++++++++++ .../tests/test_setup_llama_cpp_backend.py | 54 ++++++++++++++++++ .../chat/hooks/use-chat-model-runtime.ts | 14 ++++- studio/setup.ps1 | 13 ++++- studio/setup.sh | 14 ++++- tests/studio/test_model_picker_contracts.py | 19 +++++++ 7 files changed, 172 insertions(+), 6 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 840018fd2e..b32dc625bf 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4683,10 +4683,13 @@ def _guard_chat_load_against_training( vulkan_free_vram_gb = { index: free_mib / 1024.0 for index, free_mib, _total_mib in gpu_memory } - elif is_vulkan_backend and diffusion_kind is None: + elif is_vulkan_backend and diffusion_kind is None and requested_gpu_ids: # Until the header is available, the model may use either the Vulkan - # llama-server or the CUDA-only diffusion runner. Neither device - # namespace can safely stand in for the other. + # llama-server or the CUDA-only diffusion runner, so an explicit pin + # cannot be budgeted: neither device namespace can stand in for the + # other. Automatic placement has no ordinal to mis-map, so it keeps + # the torch view below rather than refusing every uncached remote + # GGUF while training runs. vulkan_free_vram_gb = {} ok, info = can_load_chat_during_training( diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 185a3b563a..90f6bcc806 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -653,6 +653,61 @@ class TestChatLoadGuardRoute(unittest.TestCase): ) self.assertEqual(captured[0]["vulkan_free_vram_gb"], {1: 2.0}) + def test_unclassified_gguf_without_pin_keeps_the_torch_budget(self): + # An uncached remote GGUF that neither the header nor the name can + # classify may still turn out to be the CUDA-only diffusion runner, so + # the Vulkan free-VRAM map cannot stand in for it. With no gpu_ids there + # is no ordinal to mis-map, so the guard must fall back to the torch view + # (vulkan_free_vram_gb = None) instead of handing down an empty map, which + # reads as "no free VRAM anywhere" and 409s every such load during + # training. + captured = [] + config = SimpleNamespace(is_gguf = True) + with ( + patch.object(self.route, "_classify_diffusion_gguf", return_value = None), + patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), + patch.object( + self.route.LlamaCppBackend, + "_find_llama_server_binary", + return_value = "/tmp/llama-server", + ), + patch.object(self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True), + ): + self._guard( + config = config, + captured = captured, + training_active = True, + decision = (True, {"mode": "single_device"}), + ) + self.assertEqual(len(captured), 1) + self.assertIsNone(captured[0]["vulkan_free_vram_gb"]) + + def test_unclassified_gguf_with_pin_refuses_to_budget(self): + # An explicit pin on an unclassified GGUF is the opposite case: the + # ordinal belongs to exactly one of the two device namespaces and neither + # can stand in for the other, so the guard must fail closed. + captured = [] + config = SimpleNamespace(is_gguf = True) + with ( + patch.object(self.route, "_classify_diffusion_gguf", return_value = None), + patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), + patch.object( + self.route.LlamaCppBackend, + "_find_llama_server_binary", + return_value = "/tmp/llama-server", + ), + patch.object(self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True), + ): + self._guard( + config = config, + captured = captured, + training_active = True, + decision = (True, {"mode": "gguf_vulkan"}), + requested_gpu_ids = [1], + ) + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0]["vulkan_free_vram_gb"], {}) + def test_refuses_with_headroom_number(self): info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"} with self.assertRaises(HTTPException) as exc: diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 5e7a2c219e..31f7556d45 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -206,6 +206,18 @@ def _source_backend_choice_block() -> str: (None, None, "auto"), ("cpu", "vulkan", "cpu"), ("vulkan", None, "vulkan"), + # install_llama_prebuilt.py's llama_backend_from_env() consults the legacy + # var whenever the new one is not an explicit backend name, so an explicit + # "auto" (or a typo) beside a legacy UNSLOTH_LLAMA_BACKEND=vulkan must + # resolve to vulkan here too. Otherwise setup leaves _explicit_vulkan_backend + # false while the installer it launches plans Vulkan, and a failed Vulkan + # install silently degrades to a CUDA/ROCm/CPU build instead of failing closed. + ("auto", "vulkan", "vulkan"), + ("banana", "vulkan", "vulkan"), + ("auto", "cpu", "cpu"), + # An unrecognized value with no legacy backend is preserved so the + # "Ignoring ..." warning can still name what the user actually set. + ("banana", None, "banana"), ], ) def test_legacy_llama_backend_env_falls_back_in_setup_sh(cpp_backend, legacy_backend, expected): @@ -230,6 +242,48 @@ def test_legacy_llama_backend_env_falls_back_in_setup_sh(cpp_backend, legacy_bac assert out.stdout == expected +@_SKIP_NO_PWSH +@pytest.mark.parametrize( + "cpp_backend, legacy_backend, expected", + [ + (None, "vulkan", "vulkan"), + (None, "VULKAN", "vulkan"), + (None, None, ""), + ("cpu", "vulkan", "cpu"), + ("vulkan", None, "vulkan"), + ("auto", "vulkan", "vulkan"), + ("banana", "vulkan", "vulkan"), + ("auto", "cpu", "cpu"), + ("banana", None, "banana"), + ], +) +def test_legacy_llama_backend_env_falls_back_in_setup_ps1(cpp_backend, legacy_backend, expected): + # setup.ps1 must resolve the legacy var exactly like setup.sh and + # install_llama_prebuilt.py's llama_backend_from_env(), so a Windows host with + # an inherited UNSLOTH_LLAMA_BACKEND=vulkan still fails closed on Vulkan. + normalize = _ps1_search( + r'\$sourceLlamaBackend = "\$\(\$env:UNSLOTH_LLAMA_CPP_BACKEND\)".*?\n\}\n', + re.DOTALL, + ) + env = { + k: v + for k, v in os.environ.items() + if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_LLAMA_BACKEND") + } + if cpp_backend is not None: + env["UNSLOTH_LLAMA_CPP_BACKEND"] = cpp_backend + if legacy_backend is not None: + env["UNSLOTH_LLAMA_BACKEND"] = legacy_backend + out = subprocess.run( + ["pwsh", "-NoProfile", "-Command", f'{normalize}\n"RESULT:$sourceLlamaBackend"'], + capture_output = True, + text = True, + env = env, + check = True, + ) + assert out.stdout.strip() == f"RESULT:{expected}" + + def _ps1_search(pattern: str, flags = 0) -> str: m = re.search(pattern, _SETUP_PS1.read_text(encoding = "utf-8"), flags) assert m, f"setup.ps1 block not found: {pattern}" diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 5633fd6669..e30de901b3 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -9,6 +9,7 @@ import { useTransformersUpgradeDialogStore, } from "@/features/transformers-upgrade"; import { consumeNativePathToken } from "@/features/native-intents/api"; +import { prepareHfTokenForUse } from "@/features/hf-auth"; import { notifyNative, primeNativeNotificationPermission, @@ -617,11 +618,22 @@ export function useChatModelRuntime() { const pendingLoadConfig = typeof selection !== "string" ? selection.config : undefined; if (isGguf && isDiffusion === undefined) { + // Prepare the token exactly as validateModel/loadModel do (and as + // the compare path does): the Hub rejects an invalid Authorization + // header with 401 even for a public repo, so sending the raw stored + // token here would abort the load before the existing "continue + // anonymously / replace token" recovery flow could run. + const preparedToken = await prepareHfTokenForUse( + useChatRuntimeStore.getState().hfToken || null, + ); + if (!preparedToken.proceed) { + throw new Error("Model load cancelled."); + } isDiffusion = ( await fetchGgufStagedMetadata({ model_path: modelId, gguf_variant: ggufVariant ?? null, - hf_token: useChatRuntimeStore.getState().hfToken || null, + hf_token: preparedToken.token, nativePathToken: nativePathToken ?? null, }) ).isDiffusion; diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 5bca27b9ec..75fa064404 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -3482,11 +3482,22 @@ $ResolvedSourceRef = $RequestedLlamaTag $ResolvedSourceRefKind = "tag" $ResolvedLlamaTag = $RequestedLlamaTag $sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() +$sourceLegacyLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant() if (-not $sourceLlamaBackend) { # Legacy pre-consolidation var name; still honored so an existing # UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing Vulkan instead of # silently reverting to auto-detected CUDA/ROCm/CPU. - $sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant() + $sourceLlamaBackend = $sourceLegacyLlamaBackend +} elseif ( + $sourceLlamaBackend -notin @("cpu", "vulkan") -and + $sourceLegacyLlamaBackend -in @("cpu", "vulkan") +) { + # Same rule install_llama_prebuilt.py's llama_backend_from_env() applies: + # "auto" (or a typo) is not an explicit backend, so the legacy var is still + # consulted. Without this the installer plans Vulkan from the inherited + # environment while setup keeps $explicitVulkanBackend false, silently + # substituting a non-Vulkan build for a request meant to fail closed. + $sourceLlamaBackend = $sourceLegacyLlamaBackend } $sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() $explicitVulkanSourceBuild = ( diff --git a/studio/setup.sh b/studio/setup.sh index 59193fc626..33534d4c5e 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1241,11 +1241,23 @@ _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" _HOST_SYSTEM="$(uname -s 2>/dev/null || true)" _HOST_MACHINE="$(uname -m 2>/dev/null || true)" _source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-}" | awk '{$1=$1; print tolower($0)}')" +_source_legacy_backend="$(printf '%s' "${UNSLOTH_LLAMA_BACKEND:-}" | awk '{$1=$1; print tolower($0)}')" if [ -z "$_source_backend_choice" ]; then # Legacy pre-consolidation var name; still honored so an existing # UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing Vulkan instead of # silently reverting to auto-detected CUDA/ROCm/CPU. - _source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" + _source_backend_choice="${_source_legacy_backend:-auto}" +elif [ "$_source_backend_choice" != "cpu" ] && [ "$_source_backend_choice" != "vulkan" ]; then + # install_llama_prebuilt.py's llama_backend_from_env() also consults the + # legacy var when the new one is SET but is not an explicit backend name + # ("auto", or a typo). Mirror it: the installer inherits this environment + # and plans Vulkan either way, so without this setup leaves + # _explicit_vulkan_backend/_explicit_vulkan_source_build false and silently + # substitutes a CUDA/ROCm/CPU build for a request meant to fail closed. + # An unrecognized value with no legacy backend is preserved for the warning. + case "$_source_legacy_backend" in + cpu|vulkan) _source_backend_choice="$_source_legacy_backend" ;; + esac fi _source_legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" _explicit_vulkan_source_build=false diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 93ca2e7dd5..889e628a44 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -624,6 +624,25 @@ def test_compare_classifies_gguf_before_reconciling_gpu_ids(): assert "onRun(effectiveLoadConfig, classifiedIsDiffusion)" in page +def test_chat_load_prepares_hf_token_before_gguf_metadata_preflight(): + """The single-model load path classifies a GGUF via fetchGgufStagedMetadata + before validateModel/loadModel run. The Hub rejects an invalid Authorization + header with 401 even for a PUBLIC repo, so that preflight must prepare the + token like every other caller; otherwise a stale saved token aborts the whole + load instead of offering the "continue anonymously / replace token" recovery. + """ + runtime = _read("features/chat/hooks/use-chat-model-runtime.ts") + prepare = runtime.index("prepareHfTokenForUse(") + metadata = runtime.index("fetchGgufStagedMetadata({", prepare) + assert prepare < metadata + # The raw store token must not be handed to the preflight. + assert "hf_token: preparedToken.token" in runtime + assert ( + "hf_token: useChatRuntimeStore.getState().hfToken" not in runtime + ), "GGUF metadata preflight must not send the unprepared stored token" + assert 'throw new Error("Model load cancelled.")' in runtime + + def test_cpu_only_llama_build_hides_gpu_picker(): src = (WORKDIR / "studio" / "backend" / "main.py").read_text(encoding = "utf-8") assert "and not LlamaCppBackend._backend_lacks_gpu_lib()" in src From 94a5643bf3a311ba9fd19cbca891f145b4a19829 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 28 Jul 2026 11:54:12 +0000 Subject: [PATCH 087/110] Let the diffusion runner take physical ROCm indices device_backend is a display label: _backend_label swaps CUDA for "rocm" when IS_ROCM is set, but ROCm reuses torch.cuda.* and the DiffusionGemma runner selects by the same physical index either way. Gating on "cuda" alone marked every physical ROCm device unpinnable, so the picker vanished on multi-GPU ROCm hosts and status hydration could discard a valid selection. --- studio/frontend/src/hooks/use-gpu-info.ts | 5 ++++- tests/studio/test_model_picker_contracts.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index f9e0e156d8..76ce067531 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -172,7 +172,10 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { // must hide every pick surface: the backend reports gguf_gpu_ids_supported, // and absent support info defaults to pinnable (older backend). const pinnableBackend = data?.gpu?.gguf_gpu_ids_supported !== false; - const diffusionBackend = data?.device_backend === "cuda"; + // ROCm reuses torch.cuda.* and the same physical-ID path, so the runner takes + // its indices too; only the reported label differs (_backend_label swaps it). + const diffusionBackend = + data?.device_backend === "cuda" || data?.device_backend === "rocm"; return (data?.gpu?.devices ?? []) .filter((d) => typeof d.index === "number") .map((d) => ({ diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 889e628a44..9a18f6cfc5 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -737,6 +737,11 @@ def test_vulkan_inference_devices_are_the_pickable_set(): '(d.index_kind === "vulkan" || ' '(data?.device_backend !== "xpu" && d.index_kind === "physical")),' in src ) - # Only a physical CUDA index is ever handed to the diffusion runner. - assert 'const diffusionBackend = data?.device_backend === "cuda";' in src + # Only a physical index is ever handed to the diffusion runner, and ROCm + # counts: it reuses torch.cuda.* and the same physical-ID path, so excluding + # it would hide the picker on every multi-GPU ROCm host. + assert ( + "const diffusionBackend = " + 'data?.device_backend === "cuda" || data?.device_backend === "rocm";' in src + ) assert 'diffusionPinnable: diffusionBackend && d.index_kind === "physical",' in src From 0877ee4c8f3660b00c1a937dc69678e31394c3c5 Mon Sep 17 00:00:00 2001 From: InfoSage05 Date: Tue, 28 Jul 2026 18:29:11 +0530 Subject: [PATCH 088/110] fix(studio): keep unavailable Vulkan inventory out of CUDA fallback, prepare autoload HF token, restore legacy hip/rocm opt-out Three P2s from the latest Codex review round on PR #7188: - toGpuDevices(): a confirmed-Vulkan inference backend with a still-cold or transiently-empty device probe no longer falls through to the torch/CUDA inventory. That fallthrough was marking physical CUDA/ROCm devices diffusionPinnable, exposing a GPU picker for DiffusionGemma that sent IDs the backend rejects outright whenever is_vulkan_build is true. Now returns no devices until the Vulkan probe actually succeeds. - loadAutoLoadCandidate(): the startup auto-load path's diffusion-classifying fetchGgufStagedMetadata probe (fired when a cached GGUF has saved gpu_ids) sent the raw stored HF token, bypassing prepareHfTokenForUse. Unlike validateModel/loadModel (which prepare internally), fetchGgufStagedMetadata does not, so an expired token could 401 even a public repo and abort the candidate before the anonymous/replace-token recovery flow got a chance to run. Now prepares the token first, mirroring performLoad's identical guard. - _normalized_llama_backend(): dropped hip/rocm entirely during the backend- selector consolidation, despite force_vulkan_requested()'s own comment ("so =hip is a real opt-out a stale UNSLOTH_FORCE_VULKAN cannot overrule") and _route_to_vulkan_prebuilt()'s ("an explicit hip/cpu is the opt-out") describing behavior it no longer delivered. An existing UNSLOTH_LLAMA_BACKEND=hip/rocm environment on an unsupported HIP arch read as "no backend named" and silently routed to Vulkan (auto-fallback, or a stale UNSLOTH_FORCE_VULKAN=1). Restored hip/rocm recognition (canonicalized to "hip"), matching the pre-consolidation mapping. Regression tests added for all three. --- .../tests/test_install_resolve_prebuilt.py | 38 +++++++++++++++++++ .../src/features/chat/api/chat-adapter.ts | 30 ++++++++++----- studio/frontend/src/hooks/use-gpu-info.ts | 9 ++++- studio/install_llama_prebuilt.py | 12 +++++- tests/studio/test_model_picker_contracts.py | 34 ++++++++++++++++- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index d87ea2f0be..4081db20f6 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -1379,6 +1379,44 @@ def test_legacy_llama_backend_env_ignored_when_new_var_unrecognized(monkeypatch) assert ilp.llama_backend_from_env() == "vulkan" +@pytest.mark.parametrize("legacy_value", ["hip", "HIP", "rocm", "ROCM", " hip "]) +def test_legacy_hip_rocm_backend_env_is_an_explicit_non_vulkan_opt_out(legacy_value, monkeypatch): + # UNSLOTH_LLAMA_BACKEND=hip/rocm was the documented pre-consolidation opt-out that + # keeps HIP even when Vulkan would otherwise auto-route. It must normalize to an + # explicit backend (not None), or force_vulkan_requested()/_route_to_vulkan_prebuilt() + # read it as "no backend named" and silently route to Vulkan anyway. + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", legacy_value) + assert ilp.llama_backend_from_env() == "hip" + assert ilp.force_vulkan_requested() is False + + +def test_legacy_hip_backend_env_overrules_a_stale_force_vulkan(monkeypatch): + # The explicit hip opt-out is authoritative: a stale UNSLOTH_FORCE_VULKAN=1 left over + # from a previous run must not overrule it (matches force_vulkan_requested's own + # "so =hip is a real opt-out a stale UNSLOTH_FORCE_VULKAN cannot overrule" comment). + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "hip") + monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") + assert ilp.force_vulkan_requested() is False + + +def test_legacy_hip_backend_env_suppresses_auto_vulkan_fallback_on_unsupported_gfx(monkeypatch): + # Same unsupported-gfx host as test_route_to_vulkan_prebuilt_auto_fallback_for_legacy_amd_gfx + # (which auto-falls-back to Vulkan when NO backend is named), but with an explicit + # UNSLOTH_LLAMA_BACKEND=hip opt-out: _route_to_vulkan_prebuilt must keep HIP, not + # silently substitute Vulkan for the request meant to fail closed on HIP. + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "hip") + host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"]) + routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) + assert routed is host + assert repo == FORK + assert persist is None + + def test_route_to_vulkan_prebuilt_hidden_physical_nvidia_amd_not_rerouted(): # Vulkan ignores CUDA_VISIBLE_DEVICES, so a CUDA-masked NVIDIA card next to a legacy # AMD gfx must not auto-route: Vulkan could grab the reserved NVIDIA GPU. diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 2abd5c0e97..3b9de0e25e 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { getAuthToken } from "@/features/auth"; +import { prepareHfTokenForUse } from "@/features/hf-auth"; import { resolveInitialConfig } from "@/features/model-picker"; import { projectHasSources } from "@/features/rag/api/rag-api"; import { apiUrl } from "@/lib/api-base"; @@ -1568,16 +1569,25 @@ async function autoLoadSmallestModel(): Promise<{ // the load with the picker hidden. await ensureGpuDeviceCache(); } - const isDiffusion = - candidate.kind === "gguf" && config.selectedGpuIds != null - ? ( - await fetchGgufStagedMetadata({ - model_path: modelPath, - gguf_variant: candidate.ggufVariant, - hf_token: hfToken, - }) - ).isDiffusion - : false; + let isDiffusion = false; + if (candidate.kind === "gguf" && config.selectedGpuIds != null) { + // Prepare the token before this probe, exactly as validateModel/loadModel + // (and the interactive performLoad path) do: the Hub 401s an invalid + // Authorization header even for a public repo, so sending the raw stored + // token here would abort this candidate before the existing anonymous/ + // replacement-token recovery flow could run. + const preparedToken = await prepareHfTokenForUse(hfToken); + if (!preparedToken.proceed) { + throw new Error("Model load cancelled."); + } + isDiffusion = ( + await fetchGgufStagedMetadata({ + model_path: modelPath, + gguf_variant: candidate.ggufVariant, + hf_token: preparedToken.token, + }) + ).isDiffusion; + } const effectiveGpuIds = config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 76ce067531..3c53a96407 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -152,7 +152,14 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { // The XPU ban does not apply there, it is about torch-xpu ordinals that no // applicator speaks; a Vulkan pick does not use them. const inference = data?.inference_gpu; - if (inference?.backend === "vulkan" && (inference.devices ?? []).length) { + if (inference?.backend === "vulkan") { + // The installed inference backend is confirmed Vulkan, so even an empty + // device list (probe still cold, or transiently failed) must NOT fall + // through to the torch/CUDA inventory below: those physical IDs are + // meaningless to a Vulkan llama-server, and the backend rejects every + // explicit diffusion pin outright while is_vulkan_build is true. Report no + // pinnable/diffusionPinnable devices until the probe succeeds. + if (!(inference.devices ?? []).length) return []; const picksAccepted = inference.gguf_gpu_ids_supported !== false; return (inference.devices ?? []) .filter((d) => typeof d.index === "number") diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 96ca92877d..c753107917 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6210,7 +6210,17 @@ def _normalized_llama_backend(value: str | None) -> str | None: if not value: return None backend = value.strip().lower() - return backend if backend in {"vulkan", "cpu"} else None + if backend in {"vulkan", "cpu"}: + return backend + if backend in {"hip", "rocm"}: + # Legacy pre-consolidation values: not new canonical UNSLOTH_LLAMA_CPP_BACKEND + # values (setup.sh/setup.ps1 only document auto/cpu/vulkan), but still an + # explicit non-Vulkan opt-out that force_vulkan_requested()/_route_to_vulkan_prebuilt() + # must treat as authoritative -- else an existing UNSLOTH_LLAMA_BACKEND=hip/rocm + # environment on an unsupported HIP arch reads as "no backend named" and silently + # gets routed to Vulkan (auto-fallback, or a stale UNSLOTH_FORCE_VULKAN=1). + return "hip" + return None def llama_backend_from_env() -> str | None: diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index e97ecd656e..5296c665bd 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -73,6 +73,30 @@ def test_autoload_records_backend_loaded_model_identity(): assert "m.id === loadedModelId" in autoload +def test_autoload_staged_metadata_probe_prepares_the_hf_token(): + """Startup auto-load restoring a cached GGUF with saved gpu_ids probes + fetchGgufStagedMetadata for its diffusion classification. Unlike + validateModel/loadModel (which prepare the token internally), + fetchGgufStagedMetadata sends whatever hf_token it is given straight to + /api/inference/validate -- an expired/invalid stored token would 401 even a + public repo and abort this candidate before the anonymous/replace-token + recovery in prepareHfTokenForUse ever runs. The caller must prepare it + first, mirroring performLoad's identical guard in use-chat-model-runtime.ts. + """ + src = _read("features/chat/api/chat-adapter.ts") + assert 'import { prepareHfTokenForUse } from "@/features/hf-auth";' in src + autoload = src.split("async function loadAutoLoadCandidate", 1)[1] + autoload = autoload.split("\n try {", 1)[0] + prepare_idx = autoload.index("const preparedToken = await prepareHfTokenForUse(hfToken)") + metadata_idx = autoload.index("await fetchGgufStagedMetadata({") + assert prepare_idx < metadata_idx + assert "if (!preparedToken.proceed) {" in autoload + assert "hf_token: preparedToken.token," in autoload + # The raw, unprepared token must not reach fetchGgufStagedMetadata directly. + between = autoload[metadata_idx : autoload.index("\n", autoload.index("hf_token:", metadata_idx))] + assert "hf_token: hfToken" not in between + + def test_chat_autoload_toast_is_persistent_and_dismissible(): """Send-triggered autoload stays visible until it settles but remains dismissible, matching the explicit model-loading toast's lifetime.""" @@ -770,11 +794,17 @@ def test_vulkan_inference_devices_are_the_pickable_set(): applicator speaks, and a Vulkan pick does not use them. """ src = " ".join(_read("hooks/use-gpu-info.ts").split()) - # The Vulkan inventory is consulted first, and only when it has devices. + # The Vulkan inventory is consulted first, on backend alone -- NOT gated on + # devices already being non-empty, or a confirmed-Vulkan host whose probe is + # still cold/transiently empty would fall through to the code below and + # expose torch/CUDA physical IDs the Vulkan backend cannot use. assert ( "const inference = data?.inference_gpu; " - 'if (inference?.backend === "vulkan" && (inference.devices ?? []).length) {' in src + 'if (inference?.backend === "vulkan") {' in src ) + # A confirmed-Vulkan backend with no enumerated devices yet must return no + # devices, not fall through to the torch/CUDA inventory below. + assert "if (!(inference.devices ?? []).length) return [];" in src # Pinnable on the ggml ordinal space, gated on the backend's own support flag. assert "const picksAccepted = inference.gguf_gpu_ids_supported !== false;" in src assert 'pinnable: picksAccepted && d.index_kind === "vulkan",' in src From dea8ffda5016cf00598c5cbafdbb9d3093c0e7a3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 28 Jul 2026 13:08:06 +0000 Subject: [PATCH 089/110] Cover the auto-load HF token preflight behaviourally The contract test added alongside the fix asserts on the source text. This runs the real classification block out of chat-adapter.ts under node with a stubbed Hub that 401s any non-null Authorization value, so it fails on the token value that actually reaches /api/inference/validate rather than on a symbol being present. Reverting only the source (leaving the import in place) reddens it with the simulated 401, not an ImportError. --- .../test_autoload_hf_token_preflight.py | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 tests/studio/test_autoload_hf_token_preflight.py diff --git a/tests/studio/test_autoload_hf_token_preflight.py b/tests/studio/test_autoload_hf_token_preflight.py new file mode 100644 index 0000000000..69da9e1548 --- /dev/null +++ b/tests/studio/test_autoload_hf_token_preflight.py @@ -0,0 +1,191 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Background auto-load must prepare the stored HF token before its GGUF +metadata preflight. + +The Hub rejects an invalid Authorization header with 401 even for a PUBLIC +repo. ``fetchGgufStagedMetadata`` posts to the same /api/inference/validate +endpoint ``validateModel`` uses, and ``parseJsonOrThrow`` turns a non-OK +response into a throw. In ``loadAutoLoadCandidate`` that preflight runs BEFORE +``validateModel``, and every call site of ``loadAutoLoadCandidate`` is wrapped +in ``catch { hadNonTrustFailure = true; continue; }``. So a stale saved token +made auto-load skip a cached model that would have loaded anonymously, without +ever reaching validateModel's "continue anonymously / replace token" recovery. + +The real classification block is sliced verbatim out of chat-adapter.ts and run +under node, so this asserts on the token value that actually reaches the +request rather than on the presence of a symbol. +""" + +import json +import os +import shutil +import subprocess +import tempfile +import textwrap +from pathlib import Path + +import pytest + +WORKDIR = Path(__file__).resolve().parents[2] + + +def _source_path(relative_path: str) -> Path: + direct = WORKDIR / relative_path + if direct.exists(): + return direct + return WORKDIR / "unsloth_repo" / relative_path + + +ADAPTER = _source_path("studio/frontend/src/features/chat/api/chat-adapter.ts") +TEMP = WORKDIR / "temp" / "autoload_hf_token_preflight" + + +def _require_node(): + if shutil.which("node") is None: + pytest.skip("node not available") + if not ADAPTER.exists(): + pytest.skip("studio chat sources not present") + result = subprocess.run( + ["node", "--experimental-strip-types", "--version"], + capture_output = True, + text = True, + timeout = 5, + ) + if result.returncode != 0: + pytest.skip("node --experimental-strip-types not available") + + +def _classification_slice() -> str: + """The verbatim `isDiffusion` classification block from loadAutoLoadCandidate. + + Anchored on the declaration and on the `effectiveGpuIds` statement that + consumes it, so the slice tracks either the prepared-token form or the + older raw-token ternary. + """ + src = ADAPTER.read_text(encoding = "utf-8") + anchor = src.index("async function loadAutoLoadCandidate(") + starts = [ + pos + for pos in ( + src.find("let isDiffusion", anchor), + src.find("const isDiffusion", anchor), + ) + if pos != -1 + ] + assert starts, "could not locate the isDiffusion classification block" + start = min(starts) + end = src.index("const effectiveGpuIds", start) + return src[start:end].rstrip() + + +def _run(script: str, harness: str): + _require_node() + TEMP.mkdir(parents = True, exist_ok = True) + workdir = Path(tempfile.mkdtemp(prefix = "run", dir = TEMP)) + (workdir / "harness.ts").write_text(harness, encoding = "utf-8") + (workdir / "run.mts").write_text(script, encoding = "utf-8") + env = dict(os.environ, NODE_NO_WARNINGS = "1") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "run.mts"], + cwd = str(workdir), + capture_output = True, + text = True, + timeout = 30, + env = env, + ) + assert result.returncode == 0, f"stderr: {result.stderr}\nstdout: {result.stdout}" + last = [line for line in result.stdout.strip().splitlines() if line.strip()][-1] + return json.loads(last) + + +_HARNESS_TEMPLATE = """\ +// Real classification block, sliced verbatim from chat-adapter.ts. +export async function classify(ctx: any) {{ + const {{ + candidate, + config, + modelPath, + hfToken, + prepareHfTokenForUse, + fetchGgufStagedMetadata, + }} = ctx; +{slice} + return isDiffusion; +}} +""" + + +_STALE_TOKEN = "hf_staleTokenFromAnEarlierSession"; + + +def _harness() -> str: + return _HARNESS_TEMPLATE.format(slice = textwrap.indent(_classification_slice(), " ")) + + +_SCRIPT = textwrap.dedent( + """ + import { classify } from "./harness.ts"; + + const sent: Array = []; + + // Mirrors prepareHfTokenForUse: an invalid stored token, with the user's + // one-shot "continue anonymously" choice, resolves to a null token. + const prepareHfTokenForUse = async (token: string | null) => { + if (!token) return { proceed: true, token: null }; + return { proceed: true, token: null }; + }; + + // Mirrors the Hub via /api/inference/validate + parseJsonOrThrow: any + // non-null Authorization value here is the stale token, and the Hub 401s + // on it even though the repo is public. + const fetchGgufStagedMetadata = async (payload: any) => { + sent.push(payload.hf_token ?? null); + if (payload.hf_token != null) { + throw new Error("401 Unauthorized: Invalid credentials in Authorization header"); + } + return { isDiffusion: true }; + }; + + let threw: string | null = null; + let isDiffusion: boolean | null = null; + try { + isDiffusion = await classify({ + candidate: { kind: "gguf", ggufVariant: "Q4_K_M" }, + config: { selectedGpuIds: [0] }, + modelPath: "unsloth/some-public-gguf", + hfToken: %s, + prepareHfTokenForUse, + fetchGgufStagedMetadata, + }); + } catch (e) { + threw = String((e as Error).message); + } + + console.log(JSON.stringify({ sent, threw, isDiffusion })); + """ +) + + +def test_autoload_preflight_sends_the_prepared_token_not_the_stale_one(): + out = _run(_SCRIPT % json.dumps(_STALE_TOKEN), _harness()) + assert out["threw"] is None, ( + "a stale saved token aborted the auto-load metadata preflight; the " + f"candidate would be skipped: {out['threw']}" + ) + assert out["sent"] == [None], ( + "the GGUF metadata preflight must send the prepared token, not the raw " + f"stored one; it sent {out['sent']!r}" + ) + assert out["isDiffusion"] is True + + +def test_autoload_preflight_is_skipped_without_a_remembered_gpu_pick(): + """No remembered GPU selection means no preflight and no token use at all.""" + script = _SCRIPT % json.dumps(_STALE_TOKEN) + script = script.replace("selectedGpuIds: [0]", "selectedGpuIds: null") + out = _run(script, _harness()) + assert out["sent"] == [] + assert out["threw"] is None + assert out["isDiffusion"] is False From 9926f9dcf32b2d917fa2721dc08890f0ffa1fc66 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:08:32 -0300 Subject: [PATCH 090/110] Converge Vulkan backend selection and review fixes --- studio/backend/routes/inference.py | 18 +- .../tests/test_chat_load_during_training.py | 5 + .../tests/test_install_resolve_prebuilt.py | 81 +++----- studio/backend/tests/test_llama_cpp_update.py | 2 +- .../tests/test_setup_llama_cpp_backend.py | 189 ++++++++++-------- studio/backend/utils/llama_cpp_update.py | 2 +- .../src/features/chat/api/chat-adapter.ts | 25 ++- studio/frontend/src/hooks/use-gpu-info.ts | 2 +- studio/install_llama_prebuilt.py | 28 ++- studio/setup.ps1 | 43 ++-- studio/setup.sh | 65 +++--- tests/studio/test_model_picker_contracts.py | 20 +- 12 files changed, 249 insertions(+), 231 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1d867371c4..b67b6fac32 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4773,6 +4773,15 @@ def _guard_chat_load_against_training( if is_gguf and gpu_memory_mode == "manual" and diffusion_kind is False: return + binary = LlamaCppBackend._find_llama_server_binary() if is_gguf else None + is_vulkan_backend = bool( + is_gguf + and ( + gpu_ids_are_vulkan_ordinals + or (binary and LlamaCppBackend._is_vulkan_backend(binary)) + ) + ) + diffusion_gpu = None if is_gguf and diffusion_kind is not False and not gpu_ids_are_vulkan_ordinals: # Use the same token selection as the runner: an explicit pick wins, @@ -4794,7 +4803,10 @@ def _guard_chat_load_against_training( cache_type_kv = cache_type_kv, tensor_parallel = ( _effective_tensor_parallel(llama_extra_args, tensor_parallel) - and (is_vulkan or LlamaCppBackend._effective_gpu_count(requested_gpu_ids) >= 2) + and ( + is_vulkan_backend + or LlamaCppBackend._effective_gpu_count(requested_gpu_ids) >= 2 + ) ), ) if is_gguf @@ -4803,10 +4815,6 @@ def _guard_chat_load_against_training( vulkan_free_vram_gb = None if is_gguf: - binary = LlamaCppBackend._find_llama_server_binary() - is_vulkan_backend = bool( - gpu_ids_are_vulkan_ordinals or (binary and LlamaCppBackend._is_vulkan_backend(binary)) - ) if is_vulkan_backend and (gpu_ids_are_vulkan_ordinals or diffusion_kind is False): gpu_memory = LlamaCppBackend._get_gpu_memory(binary) if not requested_gpu_ids: diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 14c86b7c46..531b0be218 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -757,6 +757,11 @@ class TestChatLoadGuardRoute(unittest.TestCase): "_effective_gpu_count", return_value = 0, ), + patch.object( + self.route.LlamaCppBackend, + "_find_llama_server_binary", + return_value = "/fake/llama-server", + ), patch.object(self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True), ): self._guard( diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index d87ea2f0be..5530b039d7 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -561,15 +561,17 @@ def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): (None, "on", True), ("auto", None, False), ("cpu", "0", False), + ("hip", "1", False), + ("rocm", "on", False), ], ) def test_force_vulkan_requested_accepts_public_selector_and_legacy_alias( monkeypatch, backend, legacy, expected ): - # UNSLOTH_LLAMA_CPP_BACKEND is the public selector; a recognized non-vulkan + # UNSLOTH_LLAMA_BACKEND is the public selector; a recognized non-vulkan # value is authoritative, so it opts out even against a stale legacy alias. for name, value in ( - ("UNSLOTH_LLAMA_CPP_BACKEND", backend), + ("UNSLOTH_LLAMA_BACKEND", backend), ("UNSLOTH_FORCE_VULKAN", legacy), ): if value is None: @@ -696,7 +698,7 @@ def test_resolve_prebuilt_cpu_fallback_overrides_intel_vulkan(monkeypatch, capsy def test_cpu_backend_env_forces_cpu_in_resolver(monkeypatch, capsys): - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") monkeypatch.setattr( ilp, "detect_host", @@ -708,7 +710,7 @@ def test_cpu_backend_env_forces_cpu_in_resolver(monkeypatch, capsys): def test_cpu_backend_env_forces_cpu_in_direct_install(monkeypatch, tmp_path): - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") monkeypatch.setattr( ilp, "detect_host", @@ -743,7 +745,7 @@ def test_cpu_backend_env_forces_cpu_in_direct_install(monkeypatch, tmp_path): # Automatic/transient last resort (arm64 GPU-build recovery): drops GPU but # does NOT persist, so a later update heals to a GPU bundle (#6097). (["--cpu-fallback"], True, False), - # Deliberate CPU-only (UNSLOTH_LLAMA_CPP_BACKEND=cpu): drops GPU AND persists so + # Deliberate CPU-only (UNSLOTH_LLAMA_BACKEND=cpu): drops GPU AND persists so # the updater re-asserts it and never revives the Intel iGPU crash (#7213). (["--force-cpu"], True, True), (["--cpu-fallback", "--force-cpu"], True, True), @@ -1311,7 +1313,7 @@ def test_route_to_vulkan_prebuilt_gfx1034_keeps_rocm(): def test_route_to_vulkan_prebuilt_explicit_opt_in_on_mixed_amd(monkeypatch): - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") host = _windows_amd_host( rocm_gfx_target = "gfx1201", rocm_gfx_targets = ["gfx1201", "gfx803"], @@ -1340,45 +1342,26 @@ def test_direct_upstream_windows_amd_legacy_gfx_routes_to_vulkan(): def test_llama_cpp_backend_env_requests_vulkan(monkeypatch): assert ilp.llama_backend_from_env() is None - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") assert ilp.llama_backend_from_env() == "vulkan" assert ilp.force_vulkan_requested() is True def test_llama_cpp_backend_auto_does_not_trigger_vulkan(monkeypatch): monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "auto") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "auto") assert ilp.llama_backend_from_env() is None assert ilp.force_vulkan_requested() is False -def test_legacy_llama_backend_env_still_forces_vulkan(monkeypatch): - # UNSLOTH_LLAMA_BACKEND=vulkan was the documented override before the - # consolidation onto UNSLOTH_LLAMA_CPP_BACKEND; an environment that still - # sets only the legacy name must keep forcing Vulkan, not silently fall - # back to auto-detected CUDA/ROCm/CPU. - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") - assert ilp.llama_backend_from_env() == "vulkan" - assert ilp.force_vulkan_requested() is True - - -def test_new_llama_cpp_backend_env_takes_precedence_over_legacy(monkeypatch): - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") - assert ilp.llama_backend_from_env() == "cpu" +@pytest.mark.parametrize("backend", ["hip", "rocm", " HIP ", " ROCM "]) +def test_hip_backend_env_opts_out_of_vulkan(monkeypatch, backend): + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", backend) + monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") + assert ilp.llama_backend_from_env() == "hip" assert ilp.force_vulkan_requested() is False -def test_legacy_llama_backend_env_ignored_when_new_var_unrecognized(monkeypatch): - # An unrecognized UNSLOTH_LLAMA_CPP_BACKEND value (e.g. a typo) still - # normalizes to None, so the legacy var is consulted as a fallback rather - # than the invalid value silently winning. - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "banana") - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") - assert ilp.llama_backend_from_env() == "vulkan" - - def test_route_to_vulkan_prebuilt_hidden_physical_nvidia_amd_not_rerouted(): # Vulkan ignores CUDA_VISIBLE_DEVICES, so a CUDA-masked NVIDIA card next to a legacy # AMD gfx must not auto-route: Vulkan could grab the reserved NVIDIA GPU. @@ -1396,7 +1379,7 @@ def test_route_to_vulkan_prebuilt_hidden_physical_nvidia_amd_not_rerouted(): def test_route_to_vulkan_prebuilt_explicit_opt_in_overrides_hidden_nvidia(monkeypatch): # The physical-NVIDIA guard only gates the AMD auto path; an explicit opt-in wins. - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") host = _windows_amd_host( rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"], @@ -1496,7 +1479,7 @@ def test_windows_hip_gfx_floor_covers_every_fork_windows_rocm_bundle(): @pytest.mark.parametrize("gfx", _FORK_WINDOWS_ROCM_GFX) def test_route_to_vulkan_prebuilt_keeps_every_fork_windows_rocm_arch(gfx, monkeypatch): # No ambient opt-in: this asserts the AUTO path leaves covered archs alone. - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) host = _windows_amd_host(rocm_gfx_target = gfx, rocm_gfx_targets = [gfx]) routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) @@ -1510,7 +1493,7 @@ def test_forwarded_gfx_does_not_undo_visible_device_auto_vulkan(monkeypatch): # Under CUDA_VISIBLE_DEVICES=1 setup.ps1 still resolves GPU 0 and forwards gfx1100, but # detect_host() resolved the visible gfx1010, so folding the forward in must not # reinstate gfx1100 and install a HIP bundle the visible GPU cannot run. - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1010", rocm_gfx_targets = ["gfx1100", "gfx1010"]) @@ -1533,7 +1516,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_the_physical_hip_card(monkeypatch # the HIP target but must not delete the probe's inventory, or the floor check concludes # no AMD GPU here reaches HIP and auto-routes to Vulkan, which ignores the HIP mask and # enumerates the reserved gfx1100. - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx1100", "gfx803"]) @@ -1549,7 +1532,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_the_physical_hip_card(monkeypatch def test_forwarded_gfx_absent_from_probe_keeps_a_single_probed_hip_card(monkeypatch): # Same rule on a single-GPU box: a stale below-floor forward over a probe-confirmed # gfx1100 must not auto-route that machine to Vulkan. - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) @@ -1561,7 +1544,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_a_single_probed_hip_card(monkeypa def test_forwarded_gfx_absent_from_probe_still_allows_explicit_vulkan(monkeypatch): # The physical-inventory rule gates the AUTO path only; naming the backend wins. - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) host = ilp._apply_host_overrides(host, override_rocm_gfx = "gfx803") @@ -1574,7 +1557,7 @@ def test_forwarded_gfx_on_unprobed_host_still_auto_vulkans(monkeypatch): # Negative control: a driver-only AMD host runs no successful probe (no hipinfo, amd-smi # suppressed), so --rocm-gfx is the ONLY source of the arch and there is no inventory to # preserve. This is the #7357 path the feature exists for; it must still reach Vulkan. - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = None, rocm_gfx_targets = []) @@ -1589,7 +1572,7 @@ def test_forwarded_gfx_on_unprobed_host_still_auto_vulkans(monkeypatch): def test_forwarded_gfx_still_fills_an_unprobed_arch(monkeypatch): # Negative control: on an amd-smi-only host detect_host() reports no arch, so the # forward is the only source and must still apply. - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = None, rocm_gfx_targets = []) @@ -1603,7 +1586,7 @@ def test_forwarded_gfx_still_fills_an_unprobed_arch(monkeypatch): def test_llama_cpp_backend_cpu_opts_out_of_auto_vulkan(monkeypatch): # cpu is an explicit non-Vulkan choice, so it suppresses the auto-fallback. - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"]) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) assert routed is host @@ -1615,7 +1598,7 @@ def test_llama_cpp_backend_cpu_opts_out_of_auto_vulkan(monkeypatch): def test_explicit_backend_beats_legacy_force_vulkan(monkeypatch): # A stale UNSLOTH_FORCE_VULKAN must not overrule the canonical CPU choice. monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") assert ilp.resolved_llama_backend() == "cpu" assert ilp.force_vulkan_requested() is False host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) @@ -1626,7 +1609,7 @@ def test_explicit_backend_beats_legacy_force_vulkan(monkeypatch): def test_unknown_llama_backend_value_falls_through_to_legacy_flag(monkeypatch): # An unrecognised value is ignored, not an error, so the legacy flag still works. - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "banana") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "banana") assert ilp.resolved_llama_backend() is None assert ilp.force_vulkan_requested() is False monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") @@ -1635,7 +1618,7 @@ def test_unknown_llama_backend_value_falls_through_to_legacy_flag(monkeypatch): def test_llama_backend_flag_beats_conflicting_env(monkeypatch): # --llama-backend is the caller's explicit request and outranks the env. - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") assert ilp.force_vulkan_requested("vulkan") is True host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt( @@ -1670,7 +1653,7 @@ def _windows_arm64_host(**overrides): @pytest.mark.parametrize( "env, flag", [ - ({"UNSLOTH_LLAMA_CPP_BACKEND": "vulkan"}, None), + ({"UNSLOTH_LLAMA_BACKEND": "vulkan"}, None), ({"UNSLOTH_FORCE_VULKAN": "1"}, None), ({}, "vulkan"), ], @@ -1691,7 +1674,7 @@ def test_vulkan_opt_in_keeps_windows_arm64_host_for_strict_rejection(monkeypatch def test_vulkan_opt_in_still_routes_on_windows_x64(monkeypatch): # Negative control for the arm64 guard: x64 keeps its Vulkan routing. - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) assert repo == FORK @@ -1765,7 +1748,7 @@ def test_marker_records_no_backend_when_vulkan_fell_back_to_cpu(tmp_path): assert marker["llama_backend"] == "vulkan" -# setup translates UNSLOTH_LLAMA_CPP_BACKEND=cpu into --force-cpu to pin the +# setup translates UNSLOTH_LLAMA_BACKEND=cpu into --force-cpu to pin the # CPU-only bundle on a GPU host, which is what keeps Intel iGPU Vulkan crashes # away (#7213). Vulkan is opt-in, so no trigger may outrank that flag on any host. _SIM_PLATFORMS = { @@ -1856,9 +1839,9 @@ def test_forced_cpu_outranks_every_vulkan_trigger( """A deliberate CPU install stays CPU on every host, whatever asks for Vulkan.""" monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) if backend_env is None: - monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) else: - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend_env) + monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", backend_env) # The legacy switch too, so a stale one cannot smuggle Vulkan past --force-cpu. monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 01a1b2ac3d..8579e6bffb 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -499,7 +499,7 @@ def test_start_update_preserves_vulkan_via_env(monkeypatch, tmp_path): time.sleep(0.05) assert job["state"] == "success", job assert popen_kwargs["env"]["UNSLOTH_FORCE_VULKAN"] == "1" - assert popen_kwargs["env"]["UNSLOTH_LLAMA_CPP_BACKEND"] == "vulkan" + assert popen_kwargs["env"]["UNSLOTH_LLAMA_BACKEND"] == "vulkan" assert "--llama-backend" in captured["cmd"] and "vulkan" in captured["cmd"] diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 31f7556d45..f7ff07a07d 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -27,7 +27,7 @@ _SKIP_NO_PWSH = pytest.mark.skipif(shutil.which("pwsh") is None, reason = "pwsh def _backend_block() -> str: text = _SETUP_SH.read_text(encoding = "utf-8") m = re.search(r"_llama_backend=.*?esac", text, re.DOTALL) - assert m, "UNSLOTH_LLAMA_CPP_BACKEND block not found in setup.sh" + assert m, "UNSLOTH_LLAMA_BACKEND block not found in setup.sh" return m.group(0) @@ -35,12 +35,16 @@ def _run(value: str | None, system: str = "Linux") -> tuple[list[str], str]: # Pass the value through env (not the script text) so whitespace survives, and # stub the setup.sh logging helpers the unknown-value branch calls. system sets # _HOST_SYSTEM so the macOS (Darwin) no-op branch can be exercised. - env = {k: v for k, v in os.environ.items() if k != "UNSLOTH_LLAMA_CPP_BACKEND"} + env = { + k: v + for k, v in os.environ.items() + if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") + } if value is not None: - env["UNSLOTH_LLAMA_CPP_BACKEND"] = value + env["UNSLOTH_LLAMA_BACKEND"] = value harness = ( f'set -u\n_PREBUILT_CMD=()\nC_WARN=""\nC_OK=""\n_HOST_SYSTEM="{system}"\n' - '_source_backend_choice="$(printf \'%s\' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" ' + '_source_backend_choice="$(printf \'%s\' "${UNSLOTH_LLAMA_BACKEND:-auto}" ' "| awk '{$1=$1; print tolower($0)}')\"\n" '_source_legacy_force_vulkan="$(printf \'%s\' "${UNSLOTH_FORCE_VULKAN:-}" ' "| awk '{$1=$1; print tolower($0)}')\"\n" @@ -93,6 +97,15 @@ def test_backend_vulkan_is_accepted(value): assert "Ignoring" not in stderr +@_SKIP_NO_BASH +@pytest.mark.parametrize("value", ["hip", "HIP", "rocm", " ROCM "]) +def test_backend_hip_opt_out_is_accepted(value): + args, stderr = _run(value) + assert "--force-cpu" not in args + assert "--llama-backend" not in args + assert "Ignoring" not in stderr + + @_SKIP_NO_BASH @pytest.mark.parametrize("value", ["gpu", "cuda"]) def test_backend_unknown_warns_and_no_flag(value): @@ -190,98 +203,52 @@ def test_legacy_force_vulkan_gets_the_same_strict_fallback(): def _source_backend_choice_block() -> str: text = _SETUP_SH.read_text(encoding = "utf-8") m = re.search( - r'_source_backend_choice="\$\(printf.*?\n(?:.*?\n)*?fi\n', + r'_source_backend_choice="\$\(printf.*?\n.*?_explicit_vulkan_source_build=false\n' + r'.*?\nfi\n', text, + re.DOTALL, ) - assert m, "_source_backend_choice fallback block not found in setup.sh" + assert m, "_source_backend_choice block not found in setup.sh" return m.group(0) @_SKIP_NO_BASH @pytest.mark.parametrize( - "cpp_backend, legacy_backend, expected", + "backend, force_vulkan, expected_backend, expected_explicit", [ - (None, "vulkan", "vulkan"), - (None, "VULKAN", "vulkan"), - (None, None, "auto"), - ("cpu", "vulkan", "cpu"), - ("vulkan", None, "vulkan"), - # install_llama_prebuilt.py's llama_backend_from_env() consults the legacy - # var whenever the new one is not an explicit backend name, so an explicit - # "auto" (or a typo) beside a legacy UNSLOTH_LLAMA_BACKEND=vulkan must - # resolve to vulkan here too. Otherwise setup leaves _explicit_vulkan_backend - # false while the installer it launches plans Vulkan, and a failed Vulkan - # install silently degrades to a CUDA/ROCm/CPU build instead of failing closed. - ("auto", "vulkan", "vulkan"), - ("banana", "vulkan", "vulkan"), - ("auto", "cpu", "cpu"), - # An unrecognized value with no legacy backend is preserved so the - # "Ignoring ..." warning can still name what the user actually set. - ("banana", None, "banana"), + (None, None, "auto", "false"), + ("vulkan", None, "vulkan", "true"), + ("auto", "on", "auto", "true"), + ("banana", "1", "banana", "true"), + ("cpu", "1", "cpu", "false"), + ("hip", "1", "hip", "false"), + ("rocm", "1", "rocm", "false"), ], ) -def test_legacy_llama_backend_env_falls_back_in_setup_sh(cpp_backend, legacy_backend, expected): - # UNSLOTH_LLAMA_BACKEND was the documented override before setup.sh moved to - # UNSLOTH_LLAMA_CPP_BACKEND; an environment that still sets only the legacy - # name must resolve the same as if the new var had been set directly. +def test_llama_backend_source_choice_in_setup_sh( + backend, + force_vulkan, + expected_backend, + expected_explicit, +): env = { k: v for k, v in os.environ.items() - if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_LLAMA_BACKEND") + if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") } - if cpp_backend is not None: - env["UNSLOTH_LLAMA_CPP_BACKEND"] = cpp_backend - if legacy_backend is not None: - env["UNSLOTH_LLAMA_BACKEND"] = legacy_backend + if backend is not None: + env["UNSLOTH_LLAMA_BACKEND"] = backend + if force_vulkan is not None: + env["UNSLOTH_FORCE_VULKAN"] = force_vulkan harness = ( - "set -u\n" f"{_source_backend_choice_block()}\n" 'printf "%s" "$_source_backend_choice"' + "set -u\n_HOST_SYSTEM=Linux\n" + f"{_source_backend_choice_block()}\n" + 'printf "%s:%s" "$_source_backend_choice" "$_explicit_vulkan_source_build"' ) out = subprocess.run( ["bash", "-c", harness], capture_output = True, text = True, env = env, check = True ) - assert out.stdout == expected - - -@_SKIP_NO_PWSH -@pytest.mark.parametrize( - "cpp_backend, legacy_backend, expected", - [ - (None, "vulkan", "vulkan"), - (None, "VULKAN", "vulkan"), - (None, None, ""), - ("cpu", "vulkan", "cpu"), - ("vulkan", None, "vulkan"), - ("auto", "vulkan", "vulkan"), - ("banana", "vulkan", "vulkan"), - ("auto", "cpu", "cpu"), - ("banana", None, "banana"), - ], -) -def test_legacy_llama_backend_env_falls_back_in_setup_ps1(cpp_backend, legacy_backend, expected): - # setup.ps1 must resolve the legacy var exactly like setup.sh and - # install_llama_prebuilt.py's llama_backend_from_env(), so a Windows host with - # an inherited UNSLOTH_LLAMA_BACKEND=vulkan still fails closed on Vulkan. - normalize = _ps1_search( - r'\$sourceLlamaBackend = "\$\(\$env:UNSLOTH_LLAMA_CPP_BACKEND\)".*?\n\}\n', - re.DOTALL, - ) - env = { - k: v - for k, v in os.environ.items() - if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_LLAMA_BACKEND") - } - if cpp_backend is not None: - env["UNSLOTH_LLAMA_CPP_BACKEND"] = cpp_backend - if legacy_backend is not None: - env["UNSLOTH_LLAMA_BACKEND"] = legacy_backend - out = subprocess.run( - ["pwsh", "-NoProfile", "-Command", f'{normalize}\n"RESULT:$sourceLlamaBackend"'], - capture_output = True, - text = True, - env = env, - check = True, - ) - assert out.stdout.strip() == f"RESULT:{expected}" + assert out.stdout == f"{expected_backend}:{expected_explicit}" def _ps1_search(pattern: str, flags = 0) -> str: @@ -290,22 +257,73 @@ def _ps1_search(pattern: str, flags = 0) -> str: return m.group(0) +@_SKIP_NO_PWSH +@pytest.mark.parametrize( + "backend, force_vulkan, expected_explicit", + [ + (None, None, "False"), + ("vulkan", None, "True"), + ("auto", "on", "True"), + ("cpu", "1", "False"), + ("hip", "1", "False"), + ("rocm", "1", "False"), + ], +) +def test_llama_backend_source_choice_in_setup_ps1( + backend, + force_vulkan, + expected_explicit, +): + normalize = _ps1_search( + r'\$sourceLlamaBackend = "\$\(\$env:UNSLOTH_LLAMA_BACKEND\)".*?' + r"\$explicitVulkanSourceBuild = \(.*?\n\)\n", + re.DOTALL, + ) + env = { + k: v + for k, v in os.environ.items() + if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") + } + if backend is not None: + env["UNSLOTH_LLAMA_BACKEND"] = backend + if force_vulkan is not None: + env["UNSLOTH_FORCE_VULKAN"] = force_vulkan + out = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-Command", + f'{normalize}\n"RESULT:$sourceLlamaBackend:$explicitVulkanSourceBuild"', + ], + capture_output = True, + text = True, + env = env, + check = True, + ) + expected_backend = (backend or "").strip().lower() + assert out.stdout.strip() == f"RESULT:{expected_backend}:{expected_explicit}" + + def _run_ps1(value: str | None) -> str: # The override is normalized (assign + warn) at the top of the prebuilt block and # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( - r"\$llamaBackend = \$sourceLlamaBackend.*?Ignoring UNSLOTH_LLAMA_CPP_BACKEND.*?\n\s*\}", + r"\$llamaBackend = \$sourceLlamaBackend.*?Ignoring UNSLOTH_LLAMA_BACKEND.*?\n\s*\}", re.DOTALL, ) apply_flag = _ps1_search( r'if \(\$llamaBackend -eq "cpu"\) \{\s*\$prebuiltArgs \+= "--force-cpu"\s*\}' ) - env = {k: v for k, v in os.environ.items() if k != "UNSLOTH_LLAMA_CPP_BACKEND"} + env = { + k: v + for k, v in os.environ.items() + if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") + } if value is not None: - env["UNSLOTH_LLAMA_CPP_BACKEND"] = value + env["UNSLOTH_LLAMA_BACKEND"] = value harness = ( "$prebuiltArgs = @()\n" - '$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant()\n' + '$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant()\n' '$sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant()\n' f'{normalize}\n{apply_flag}\n"ARGS:" + ($prebuiltArgs -join ",")' ) @@ -344,6 +362,15 @@ def test_ps1_backend_vulkan_is_accepted(value): assert "Ignoring" not in out +@_SKIP_NO_PWSH +@pytest.mark.parametrize("value", ["hip", "HIP", "rocm", " ROCM "]) +def test_ps1_backend_hip_opt_out_is_accepted(value): + out = _run_ps1(value) + assert "--force-cpu" not in out + assert "--llama-backend" not in out + assert "Ignoring" not in out + + def test_ps1_forced_vulkan_fails_closed_on_windows_arm64(): ps1 = _SETUP_PS1.read_text(encoding = "utf-8") arm64_guard = ps1.index("elseif ($windowsArm64)") diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index 78a46a91e4..dffcddb452 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -463,7 +463,7 @@ def _run_llama_phase( # otherwise re-route and silently replace it. Re-assert via setup's env/CLI flags. if llama_backend == "vulkan" or (asset and "vulkan" in asset.lower()): env["UNSLOTH_FORCE_VULKAN"] = "1" - env["UNSLOTH_LLAMA_CPP_BACKEND"] = "vulkan" + env["UNSLOTH_LLAMA_BACKEND"] = "vulkan" _flow.stream_installer( cmd, env, diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index abdc358b88..344a9a6f42 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { getAuthToken } from "@/features/auth"; +import { prepareHfTokenForUse } from "@/features/hf-auth"; import { resolveInitialConfig } from "@/features/model-picker"; import { projectHasSources } from "@/features/rag/api/rag-api"; import { apiUrl } from "@/lib/api-base"; @@ -1570,16 +1571,20 @@ async function autoLoadSmallestModel(): Promise<{ // the load with the picker hidden. await ensureGpuDeviceCache(); } - const isDiffusion = - candidate.kind === "gguf" && config.selectedGpuIds != null - ? ( - await fetchGgufStagedMetadata({ - model_path: modelPath, - gguf_variant: candidate.ggufVariant, - hf_token: hfToken, - }) - ).isDiffusion - : false; + let isDiffusion = false; + if (candidate.kind === "gguf" && config.selectedGpuIds != null) { + const preparedToken = await prepareHfTokenForUse(hfToken); + if (!preparedToken.proceed) { + throw new Error("Model load cancelled."); + } + isDiffusion = ( + await fetchGgufStagedMetadata({ + model_path: modelPath, + gguf_variant: candidate.ggufVariant, + hf_token: preparedToken.token, + }) + ).isDiffusion; + } const effectiveGpuIds = config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 76ce067531..5716cde88a 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -152,7 +152,7 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { // The XPU ban does not apply there, it is about torch-xpu ordinals that no // applicator speaks; a Vulkan pick does not use them. const inference = data?.inference_gpu; - if (inference?.backend === "vulkan" && (inference.devices ?? []).length) { + if (inference?.backend === "vulkan") { const picksAccepted = inference.gguf_gpu_ids_supported !== false; return (inference.devices ?? []) .filter((d) => typeof d.index === "number") diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 96ca92877d..7a048e3312 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6210,22 +6210,18 @@ def _normalized_llama_backend(value: str | None) -> str | None: if not value: return None backend = value.strip().lower() - return backend if backend in {"vulkan", "cpu"} else None + if backend in {"vulkan", "hip", "rocm", "cpu"}: + return "hip" if backend == "rocm" else backend + return None def llama_backend_from_env() -> str | None: """Read an explicit llama.cpp backend preference from the environment. - ``UNSLOTH_LLAMA_CPP_BACKEND`` is shared with setup.sh/setup.ps1. ``auto`` and - unknown values leave selection automatic; ``cpu`` and ``vulkan`` are explicit. - Falls back to the legacy ``UNSLOTH_LLAMA_BACKEND`` var (pre-consolidation name) - when the new one is unset/auto, so an existing UNSLOTH_LLAMA_BACKEND=vulkan - environment still forces Vulkan instead of silently reverting to auto-detected - CUDA/ROCm/CPU. + ``UNSLOTH_LLAMA_BACKEND`` is shared with setup.sh/setup.ps1. ``auto`` and + unknown values leave selection automatic. ``cpu`` and ``vulkan`` select those + bundles explicitly, while ``hip``/``rocm`` opt out of automatic Vulkan routing. """ - backend = _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_CPP_BACKEND")) - if backend is not None: - return backend return _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_BACKEND")) @@ -6238,8 +6234,8 @@ def resolved_llama_backend(llama_backend: str | None = None) -> str | None: def force_vulkan_requested(llama_backend: str | None = None) -> bool: """Whether this run should install the Vulkan llama.cpp prebuilt. - Triggered by ``UNSLOTH_LLAMA_CPP_BACKEND=vulkan``, legacy - ``UNSLOTH_FORCE_VULKAN``, or ``--llama-backend vulkan``. Scoped to the + Triggered by ``UNSLOTH_LLAMA_BACKEND=vulkan``, legacy ``UNSLOTH_FORCE_VULKAN``, + or ``--llama-backend vulkan``. Scoped to the llama.cpp backend; the torch/training stack installs separately and still sees the real GPU. """ @@ -6420,7 +6416,7 @@ def _route_to_vulkan_prebuilt( The default Unsloth release manifest includes Vulkan app bundles, including the DiffusionGemma visual server. Three triggers route here, all suppressed when a CPU flag (--cpu-fallback or --force-cpu, folded into force_cpu) wins: - * ``UNSLOTH_LLAMA_CPP_BACKEND=vulkan`` / ``UNSLOTH_FORCE_VULKAN`` / + * ``UNSLOTH_LLAMA_BACKEND=vulkan`` / ``UNSLOTH_FORCE_VULKAN`` / ``--llama-backend vulkan`` forces Vulkan over the detected CUDA/ROCm backend; * Windows AMD with no HIP-prebuilt gfx arch auto-falls back to Vulkan (#7357); * an auto-detected Intel GPU with NO physical NVIDIA/ROCm, the purpose of the @@ -6445,7 +6441,7 @@ def _route_to_vulkan_prebuilt( if host.is_macos: if forced: log( - "UNSLOTH_LLAMA_CPP_BACKEND=vulkan is set but ignored on macOS " + "UNSLOTH_LLAMA_BACKEND=vulkan is set but ignored on macOS " "(Metal is used; there is no Vulkan prebuilt)" ) return host, published_repo, published_release_tag, None @@ -6876,7 +6872,7 @@ def parse_args() -> argparse.Namespace: action = "store_true", default = False, help = ( - "Deliberate CPU-only install (UNSLOTH_LLAMA_CPP_BACKEND=cpu). Drops GPU " + "Deliberate CPU-only install (UNSLOTH_LLAMA_BACKEND=cpu). Drops GPU " "detection like --cpu-fallback but also records force_cpu in the marker, so " "the in-app updater re-asserts CPU and never re-routes to a GPU/Vulkan " "bundle that would revive the Intel iGPU crash (#7213)." @@ -6890,7 +6886,7 @@ def parse_args() -> argparse.Namespace: "bundle and records the choice so Studio updates keep it. It is ignored " "on macOS, which uses Metal, and fails closed on Windows arm64 when no " "compatible Vulkan bundle is published. " - "Same effect as UNSLOTH_LLAMA_CPP_BACKEND=vulkan / " + "Same effect as UNSLOTH_LLAMA_BACKEND=vulkan / " "UNSLOTH_FORCE_VULKAN=1." ), ) diff --git a/studio/setup.ps1 b/studio/setup.ps1 index ea250f7756..4568dd0695 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -33,9 +33,9 @@ $PackageDir = Split-Path -Parent $ScriptDir # errors. Only use "master" temporarily when the latest release is missing # support for a new model architecture. # -# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", or "vulkan". "cpu" -# forces the CPU-only prebuilt. "vulkan" selects Vulkan even when CUDA or -# ROCm is detected. +# UNSLOTH_LLAMA_BACKEND : "auto" (default), "cpu", "vulkan", "hip", or +# "rocm". "cpu" forces the CPU-only prebuilt. "vulkan" selects Vulkan even +# when CUDA or ROCm is detected. "hip"/"rocm" opts out of automatic Vulkan. $DefaultLlamaPrForce = "" $DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" $DefaultLlamaTag = "latest" @@ -3537,31 +3537,16 @@ $ResolvedSourceUrl = $LlamaSource $ResolvedSourceRef = $RequestedLlamaTag $ResolvedSourceRefKind = "tag" $ResolvedLlamaTag = $RequestedLlamaTag -$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() -$sourceLegacyLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant() -if (-not $sourceLlamaBackend) { - # Legacy pre-consolidation var name; still honored so an existing - # UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing Vulkan instead of - # silently reverting to auto-detected CUDA/ROCm/CPU. - $sourceLlamaBackend = $sourceLegacyLlamaBackend -} elseif ( - $sourceLlamaBackend -notin @("cpu", "vulkan") -and - $sourceLegacyLlamaBackend -in @("cpu", "vulkan") -) { - # Same rule install_llama_prebuilt.py's llama_backend_from_env() applies: - # "auto" (or a typo) is not an explicit backend, so the legacy var is still - # consulted. Without this the installer plans Vulkan from the inherited - # environment while setup keeps $explicitVulkanBackend false, silently - # substituting a non-Vulkan build for a request meant to fail closed. - $sourceLlamaBackend = $sourceLegacyLlamaBackend -} +$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant() $sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() $explicitVulkanSourceBuild = ( -not $IsMacOS -and - $sourceLlamaBackend -ne "cpu" -and ( $sourceLlamaBackend -eq "vulkan" -or - $sourceLegacyForceVulkan -in @("1", "true", "yes", "on") + ( + $sourceLlamaBackend -notin @("cpu", "vulkan", "hip", "rocm") -and + $sourceLegacyForceVulkan -in @("1", "true", "yes", "on") + ) ) ) @@ -3812,16 +3797,20 @@ if ($LocalLlamaCppLinked) { if ($IsMacOS) { Write-Host "[WARN] Vulkan has no effect on macOS; the universal build uses Metal" -ForegroundColor Yellow } elseif ($windowsArm64) { - throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_LLAMA_CPP_BACKEND or compile llama.cpp from source." + throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_LLAMA_BACKEND or compile llama.cpp from source." } else { $prebuiltArgs += @("--llama-backend", "vulkan") $explicitVulkanBackend = $true Write-Host " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan } - } elseif ($llamaBackend -and $llamaBackend -notin @("auto", "vulkan")) { - Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND/UNSLOTH_LLAMA_BACKEND='$llamaBackend' (expected 'auto', 'cpu', or 'vulkan')" -ForegroundColor Yellow + } elseif ($llamaBackend -and $llamaBackend -notin @("auto", "hip", "rocm")) { + Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_BACKEND='$llamaBackend' (expected 'auto', 'cpu', 'vulkan', 'hip', or 'rocm')" -ForegroundColor Yellow } - if (-not $IsMacOS -and $llamaBackend -ne "cpu" -and $legacyForceVulkan -in @("1", "true", "yes", "on")) { + if ( + -not $IsMacOS -and + $llamaBackend -notin @("cpu", "vulkan", "hip", "rocm") -and + $legacyForceVulkan -in @("1", "true", "yes", "on") + ) { if ($windowsArm64) { throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_FORCE_VULKAN or compile llama.cpp from source." } diff --git a/studio/setup.sh b/studio/setup.sh index f8dd7b2aae..7d2632af47 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -37,11 +37,11 @@ fi # Only use "master" temporarily when the latest release # is missing support for a new model architecture. # -# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", or "vulkan". "cpu" -# forces the CPU-only prebuilt. "vulkan" selects -# Vulkan even when CUDA or ROCm is detected. -# UNSLOTH_LLAMA_BACKEND (legacy name) is honored -# as a fallback when this is unset. +# UNSLOTH_LLAMA_BACKEND : "auto" (default), "cpu", "vulkan", "hip", or +# "rocm". "cpu" forces the CPU-only prebuilt. "vulkan" +# selects Vulkan even when CUDA or ROCm is detected. +# "hip"/"rocm" keeps the detected HIP backend and opts +# out of automatic Vulkan fallback. # ────────────────────────────────────────────────────────────────────────── _DEFAULT_LLAMA_PR_FORCE="" _DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" @@ -1274,35 +1274,19 @@ _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" _HOST_SYSTEM="$(uname -s 2>/dev/null || true)" _HOST_MACHINE="$(uname -m 2>/dev/null || true)" -_source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-}" | awk '{$1=$1; print tolower($0)}')" -_source_legacy_backend="$(printf '%s' "${UNSLOTH_LLAMA_BACKEND:-}" | awk '{$1=$1; print tolower($0)}')" -if [ -z "$_source_backend_choice" ]; then - # Legacy pre-consolidation var name; still honored so an existing - # UNSLOTH_LLAMA_BACKEND=vulkan environment keeps forcing Vulkan instead of - # silently reverting to auto-detected CUDA/ROCm/CPU. - _source_backend_choice="${_source_legacy_backend:-auto}" -elif [ "$_source_backend_choice" != "cpu" ] && [ "$_source_backend_choice" != "vulkan" ]; then - # install_llama_prebuilt.py's llama_backend_from_env() also consults the - # legacy var when the new one is SET but is not an explicit backend name - # ("auto", or a typo). Mirror it: the installer inherits this environment - # and plans Vulkan either way, so without this setup leaves - # _explicit_vulkan_backend/_explicit_vulkan_source_build false and silently - # substitutes a CUDA/ROCm/CPU build for a request meant to fail closed. - # An unrecognized value with no legacy backend is preserved for the warning. - case "$_source_legacy_backend" in - cpu|vulkan) _source_backend_choice="$_source_legacy_backend" ;; - esac -fi +_source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" _source_legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" _explicit_vulkan_source_build=false -if [ "$_HOST_SYSTEM" != "Darwin" ] && [ "$_source_backend_choice" != "cpu" ]; then - if [ "$_source_backend_choice" = "vulkan" ]; then - _explicit_vulkan_source_build=true - else - case "$_source_legacy_force_vulkan" in - 1|true|yes|on) _explicit_vulkan_source_build=true ;; - esac - fi +if [ "$_HOST_SYSTEM" != "Darwin" ]; then + case "$_source_backend_choice" in + vulkan) _explicit_vulkan_source_build=true ;; + cpu|hip|rocm) ;; + *) + case "$_source_legacy_force_vulkan" in + 1|true|yes|on) _explicit_vulkan_source_build=true ;; + esac + ;; + esac fi # Pick the release repo install_llama_prebuilt.py plans against. Every host this @@ -1490,7 +1474,7 @@ else case "$_llama_backend" in cpu) if [ "$_HOST_SYSTEM" = "Darwin" ]; then - step "llama.cpp" "UNSLOTH_LLAMA_CPP_BACKEND=cpu has no effect on macOS (universal build; use -ngl 0 at runtime for CPU-only)" "$C_WARN" >&2 + step "llama.cpp" "UNSLOTH_LLAMA_BACKEND=cpu has no effect on macOS (universal build; use -ngl 0 at runtime for CPU-only)" "$C_WARN" >&2 else _PREBUILT_CMD+=(--force-cpu) fi @@ -1504,12 +1488,17 @@ else step "llama.cpp" "Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" "$C_OK" fi ;; - ""|auto) ;; - *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_CPP_BACKEND/UNSLOTH_LLAMA_BACKEND='$_llama_backend' (expected 'auto', 'cpu', or 'vulkan')" "$C_WARN" >&2 ;; + ""|auto|hip|rocm) ;; + *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_BACKEND='$_llama_backend' (expected 'auto', 'cpu', 'vulkan', 'hip', or 'rocm')" "$C_WARN" >&2 ;; esac - if [ "$_llama_backend" != "cpu" ] && [ "$_HOST_SYSTEM" != "Darwin" ]; then - case "$_legacy_force_vulkan" in - 1|true|yes|on) _explicit_vulkan_backend=true ;; + if [ "$_HOST_SYSTEM" != "Darwin" ]; then + case "$_llama_backend" in + cpu|vulkan|hip|rocm) ;; + *) + case "$_legacy_force_vulkan" in + 1|true|yes|on) _explicit_vulkan_backend=true ;; + esac + ;; esac fi _PREBUILT_LOG="$(mktemp)" diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index e97ecd656e..4d872daa4e 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -694,6 +694,20 @@ def test_chat_load_prepares_hf_token_before_gguf_metadata_preflight(): assert 'throw new Error("Model load cancelled.")' in runtime +def test_chat_autoload_prepares_hf_token_before_gguf_metadata_preflight(): + """Background autoload performs the same GGUF classification probe as the + interactive paths, so a stale saved token must take the same recovery path. + """ + adapter = _read("features/chat/api/chat-adapter.ts") + autoload = adapter.split("async function loadAutoLoadCandidate", 1)[1] + autoload = autoload.split("async function autoLoadSmallestModel", 1)[0] + prepare = autoload.index("prepareHfTokenForUse(") + metadata = autoload.index("fetchGgufStagedMetadata({", prepare) + assert prepare < metadata + assert "hf_token: preparedToken.token" in autoload + assert 'throw new Error("Model load cancelled.")' in autoload + + def test_cpu_only_llama_build_hides_gpu_picker(): src = (WORKDIR / "studio" / "backend" / "main.py").read_text(encoding = "utf-8") assert "and not LlamaCppBackend._backend_lacks_gpu_lib()" in src @@ -770,10 +784,12 @@ def test_vulkan_inference_devices_are_the_pickable_set(): applicator speaks, and a Vulkan pick does not use them. """ src = " ".join(_read("hooks/use-gpu-info.ts").split()) - # The Vulkan inventory is consulted first, and only when it has devices. + # The Vulkan inventory is authoritative even while its probe is temporarily + # empty. Falling through would expose physical CUDA/ROCm IDs in an ordinal + # picker and make DiffusionGemma offer a selection the route rejects. assert ( "const inference = data?.inference_gpu; " - 'if (inference?.backend === "vulkan" && (inference.devices ?? []).length) {' in src + 'if (inference?.backend === "vulkan") {' in src ) # Pinnable on the ggml ordinal space, gated on the backend's own support flag. assert "const picksAccepted = inference.gguf_gpu_ids_supported !== false;" in src From c402bb95a0b7594b82f57f507242692bcde7ac4c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:17:10 +0000 Subject: [PATCH 091/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/inference.py | 5 +---- tests/studio/test_autoload_hf_token_preflight.py | 2 +- tests/studio/test_model_picker_contracts.py | 9 ++++----- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 2d8922c18c..cf499efab6 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4790,10 +4790,7 @@ def _guard_chat_load_against_training( binary = LlamaCppBackend._find_llama_server_binary() if is_gguf else None is_vulkan_backend = bool( is_gguf - and ( - gpu_ids_are_vulkan_ordinals - or (binary and LlamaCppBackend._is_vulkan_backend(binary)) - ) + and (gpu_ids_are_vulkan_ordinals or (binary and LlamaCppBackend._is_vulkan_backend(binary))) ) required_override_gb = ( diff --git a/tests/studio/test_autoload_hf_token_preflight.py b/tests/studio/test_autoload_hf_token_preflight.py index 69da9e1548..e9f1b06988 100644 --- a/tests/studio/test_autoload_hf_token_preflight.py +++ b/tests/studio/test_autoload_hf_token_preflight.py @@ -117,7 +117,7 @@ export async function classify(ctx: any) {{ """ -_STALE_TOKEN = "hf_staleTokenFromAnEarlierSession"; +_STALE_TOKEN = "hf_staleTokenFromAnEarlierSession" def _harness() -> str: diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 5296c665bd..5d7f17b8ee 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -93,7 +93,9 @@ def test_autoload_staged_metadata_probe_prepares_the_hf_token(): assert "if (!preparedToken.proceed) {" in autoload assert "hf_token: preparedToken.token," in autoload # The raw, unprepared token must not reach fetchGgufStagedMetadata directly. - between = autoload[metadata_idx : autoload.index("\n", autoload.index("hf_token:", metadata_idx))] + between = autoload[ + metadata_idx : autoload.index("\n", autoload.index("hf_token:", metadata_idx)) + ] assert "hf_token: hfToken" not in between @@ -798,10 +800,7 @@ def test_vulkan_inference_devices_are_the_pickable_set(): # devices already being non-empty, or a confirmed-Vulkan host whose probe is # still cold/transiently empty would fall through to the code below and # expose torch/CUDA physical IDs the Vulkan backend cannot use. - assert ( - "const inference = data?.inference_gpu; " - 'if (inference?.backend === "vulkan") {' in src - ) + assert "const inference = data?.inference_gpu; " 'if (inference?.backend === "vulkan") {' in src # A confirmed-Vulkan backend with no enumerated devices yet must return no # devices, not fall through to the torch/CUDA inventory below. assert "if (!(inference.devices ?? []).length) return [];" in src From 8125aaf22cce9163c974765e26f9c70f13af751f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 28 Jul 2026 13:20:35 +0000 Subject: [PATCH 092/110] Stop the structlog stub shadowing the real package for PR #7188 The bare setdefault parked an empty placeholder before anything imported the real structlog, so every later module calling structlog.get_logger at import time raised AttributeError. It only bit when this file was collected first, which is why the 8 hardware-dispatch cases passed alone and failed under pytest tests/studio. Only stub when the package is genuinely absent. --- .../load_freeze/test_load_orchestrator.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/studio/load_freeze/test_load_orchestrator.py b/tests/studio/load_freeze/test_load_orchestrator.py index a1f4caa309..0ff769fc78 100644 --- a/tests/studio/load_freeze/test_load_orchestrator.py +++ b/tests/studio/load_freeze/test_load_orchestrator.py @@ -49,7 +49,22 @@ import logging as _logging # noqa: E402 _loggers_stub = types.ModuleType("loggers") _loggers_stub.get_logger = lambda name: _logging.getLogger(name) sys.modules.setdefault("loggers", _loggers_stub) -sys.modules.setdefault("structlog", types.ModuleType("structlog")) +# structlog is a hard studio.txt requirement, but it is only imported lazily, so a +# bare setdefault here used to park an empty placeholder BEFORE anything imported +# the real package -- and it then shadowed it for the rest of the session. Every +# later file importing a studio module that calls structlog.get_logger at module +# scope (routes.inference -> core.inference.external_provider, utils.mlx_repair) +# blew up with AttributeError, but only when this file was collected first, so the +# same test passed alone and failed under `pytest tests/studio`. Only stub when the +# package is genuinely missing, and give the stub the attribute those callers use. +try: + import structlog # noqa: E402, F401 +except ImportError: + _structlog_stub = types.ModuleType("structlog") + _structlog_stub.get_logger = lambda *args, **kwargs: _logging.getLogger( + args[0] if args else "structlog" + ) + sys.modules["structlog"] = _structlog_stub import httpx # noqa: E402 From 50fb40e91e6676948548b2263fcc1210e4a5df1c Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:35:06 -0300 Subject: [PATCH 093/110] Prepare model settings GGUF token preflight --- .../components/model-config-page.tsx | 46 ++++++++++--------- tests/studio/test_model_picker_contracts.py | 14 ++++++ 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index aa00a62f47..f539838131 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -19,6 +19,7 @@ import { readPersistedSpeculativeType, useChatRuntimeStore, } from "@/features/chat"; +import { prepareHfTokenForUse } from "@/features/hf-auth"; import { type GpuIndexKind, type SystemGpuDevice, @@ -766,28 +767,31 @@ export function ModelConfigPage({ return; } let cancelled = false; - void fetchGgufStagedMetadata({ - model_path: target.id, - gguf_variant: target.ggufVariant ?? null, - hf_token: hfToken || null, - nativePathToken, - }) - .then((dims) => { - if (!cancelled) { - setFetchedStagedDims({ key: contextFetchKey, ...dims }); - } - }) - .catch(() => { - if (!cancelled) { - setFetchedStagedDims({ - key: contextFetchKey, - contextLength: null, - layerCount: null, - moeLayerCount: null, - isDiffusion: undefined, - }); - } + void (async () => { + const preparedToken = await prepareHfTokenForUse(hfToken || null); + if (cancelled || !preparedToken.proceed) { + return; + } + const dims = await fetchGgufStagedMetadata({ + model_path: target.id, + gguf_variant: target.ggufVariant ?? null, + hf_token: preparedToken.token, + nativePathToken, }); + if (!cancelled) { + setFetchedStagedDims({ key: contextFetchKey, ...dims }); + } + })().catch(() => { + if (!cancelled) { + setFetchedStagedDims({ + key: contextFetchKey, + contextLength: null, + layerCount: null, + moeLayerCount: null, + isDiffusion: undefined, + }); + } + }); return () => { cancelled = true; }; diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 6d35135cfd..d1f7d878e1 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -675,6 +675,20 @@ def test_compare_classifies_gguf_before_reconciling_gpu_ids(): assert "onRun(effectiveLoadConfig, classifiedIsDiffusion)" in page +def test_model_config_prepares_hf_token_before_gguf_metadata_preflight(): + """Settings classification must use the same stale-token recovery as load.""" + page = _read("features/model-picker/components/model-config-page.tsx") + assert 'import { prepareHfTokenForUse } from "@/features/hf-auth";' in page + effect = page.split("// Fetch GGUF header dims", 1)[1] + effect = effect.split("const stagedDims =", 1)[0] + prepare = effect.index("prepareHfTokenForUse(hfToken || null)") + metadata = effect.index("fetchGgufStagedMetadata({", prepare) + assert prepare < metadata + assert "if (cancelled || !preparedToken.proceed)" in effect + assert "hf_token: preparedToken.token" in effect + assert "hf_token: hfToken" not in effect + + def test_chat_load_prepares_hf_token_before_gguf_metadata_preflight(): """The single-model load path classifies a GGUF via fetchGgufStagedMetadata before validateModel/loadModel run. The Hub rejects an invalid Authorization From e4d469a5a3d8ff7b6c431783fb179f0e9abbe9ad Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:37:05 +0000 Subject: [PATCH 094/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../backend/tests/test_setup_llama_cpp_backend.py | 13 +++---------- tests/studio/test_model_picker_contracts.py | 5 +---- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index f7ff07a07d..a3d3ed5239 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -204,7 +204,7 @@ def _source_backend_choice_block() -> str: text = _SETUP_SH.read_text(encoding = "utf-8") m = re.search( r'_source_backend_choice="\$\(printf.*?\n.*?_explicit_vulkan_source_build=false\n' - r'.*?\nfi\n', + r".*?\nfi\n", text, re.DOTALL, ) @@ -226,10 +226,7 @@ def _source_backend_choice_block() -> str: ], ) def test_llama_backend_source_choice_in_setup_sh( - backend, - force_vulkan, - expected_backend, - expected_explicit, + backend, force_vulkan, expected_backend, expected_explicit ): env = { k: v @@ -269,11 +266,7 @@ def _ps1_search(pattern: str, flags = 0) -> str: ("rocm", "1", "False"), ], ) -def test_llama_backend_source_choice_in_setup_ps1( - backend, - force_vulkan, - expected_explicit, -): +def test_llama_backend_source_choice_in_setup_ps1(backend, force_vulkan, expected_explicit): normalize = _ps1_search( r'\$sourceLlamaBackend = "\$\(\$env:UNSLOTH_LLAMA_BACKEND\)".*?' r"\$explicitVulkanSourceBuild = \(.*?\n\)\n", diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index d1f7d878e1..2eb595791d 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -801,10 +801,7 @@ def test_vulkan_inference_devices_are_the_pickable_set(): # The Vulkan inventory is authoritative even while its probe is temporarily # empty. Falling through would expose physical CUDA/ROCm IDs in an ordinal # picker and make DiffusionGemma offer a selection the route rejects. - assert ( - "const inference = data?.inference_gpu; " - 'if (inference?.backend === "vulkan") {' in src - ) + assert "const inference = data?.inference_gpu; " 'if (inference?.backend === "vulkan") {' in src # A confirmed-Vulkan backend with no enumerated devices yet must return no # devices, not fall through to the torch/CUDA inventory below. assert "if (!(inference.devices ?? []).length) return [];" in src From 8750e624628346af1dba7298cda710c9dc3f7d57 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:48:53 -0300 Subject: [PATCH 095/110] Keep the established llama.cpp backend selector --- .../tests/test_install_resolve_prebuilt.py | 54 +++++++++---------- studio/backend/tests/test_llama_cpp_update.py | 2 +- .../tests/test_setup_llama_cpp_backend.py | 26 ++++----- studio/backend/utils/llama_cpp_update.py | 2 +- studio/install_llama_prebuilt.py | 14 ++--- studio/setup.ps1 | 8 +-- studio/setup.sh | 8 +-- 7 files changed, 57 insertions(+), 57 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 3130cb8a16..ae1d95ba90 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -568,10 +568,10 @@ def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): def test_force_vulkan_requested_accepts_public_selector_and_legacy_alias( monkeypatch, backend, legacy, expected ): - # UNSLOTH_LLAMA_BACKEND is the public selector; a recognized non-vulkan + # UNSLOTH_LLAMA_CPP_BACKEND is the public selector; a recognized non-vulkan # value is authoritative, so it opts out even against a stale legacy alias. for name, value in ( - ("UNSLOTH_LLAMA_BACKEND", backend), + ("UNSLOTH_LLAMA_CPP_BACKEND", backend), ("UNSLOTH_FORCE_VULKAN", legacy), ): if value is None: @@ -698,7 +698,7 @@ def test_resolve_prebuilt_cpu_fallback_overrides_intel_vulkan(monkeypatch, capsy def test_cpu_backend_env_forces_cpu_in_resolver(monkeypatch, capsys): - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") monkeypatch.setattr( ilp, "detect_host", @@ -710,7 +710,7 @@ def test_cpu_backend_env_forces_cpu_in_resolver(monkeypatch, capsys): def test_cpu_backend_env_forces_cpu_in_direct_install(monkeypatch, tmp_path): - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") monkeypatch.setattr( ilp, "detect_host", @@ -745,7 +745,7 @@ def test_cpu_backend_env_forces_cpu_in_direct_install(monkeypatch, tmp_path): # Automatic/transient last resort (arm64 GPU-build recovery): drops GPU but # does NOT persist, so a later update heals to a GPU bundle (#6097). (["--cpu-fallback"], True, False), - # Deliberate CPU-only (UNSLOTH_LLAMA_BACKEND=cpu): drops GPU AND persists so + # Deliberate CPU-only (UNSLOTH_LLAMA_CPP_BACKEND=cpu): drops GPU AND persists so # the updater re-asserts it and never revives the Intel iGPU crash (#7213). (["--force-cpu"], True, True), (["--cpu-fallback", "--force-cpu"], True, True), @@ -1313,7 +1313,7 @@ def test_route_to_vulkan_prebuilt_gfx1034_keeps_rocm(): def test_route_to_vulkan_prebuilt_explicit_opt_in_on_mixed_amd(monkeypatch): - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") host = _windows_amd_host( rocm_gfx_target = "gfx1201", rocm_gfx_targets = ["gfx1201", "gfx803"], @@ -1342,21 +1342,21 @@ def test_direct_upstream_windows_amd_legacy_gfx_routes_to_vulkan(): def test_llama_cpp_backend_env_requests_vulkan(monkeypatch): assert ilp.llama_backend_from_env() is None - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") assert ilp.llama_backend_from_env() == "vulkan" assert ilp.force_vulkan_requested() is True def test_llama_cpp_backend_auto_does_not_trigger_vulkan(monkeypatch): monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "auto") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "auto") assert ilp.llama_backend_from_env() is None assert ilp.force_vulkan_requested() is False @pytest.mark.parametrize("backend", ["hip", "rocm", " HIP ", " ROCM "]) def test_hip_backend_env_opts_out_of_vulkan(monkeypatch, backend): - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", backend) + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend) monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") assert ilp.llama_backend_from_env() == "hip" assert ilp.force_vulkan_requested() is False @@ -1366,7 +1366,7 @@ def test_hip_backend_env_suppresses_auto_vulkan_fallback_on_unsupported_gfx(monk # An explicit HIP choice keeps HIP on an unsupported gfx target instead of # silently substituting Vulkan. monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "hip") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "hip") host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"]) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) assert routed is host @@ -1391,7 +1391,7 @@ def test_route_to_vulkan_prebuilt_hidden_physical_nvidia_amd_not_rerouted(): def test_route_to_vulkan_prebuilt_explicit_opt_in_overrides_hidden_nvidia(monkeypatch): # The physical-NVIDIA guard only gates the AMD auto path; an explicit opt-in wins. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") host = _windows_amd_host( rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"], @@ -1491,7 +1491,7 @@ def test_windows_hip_gfx_floor_covers_every_fork_windows_rocm_bundle(): @pytest.mark.parametrize("gfx", _FORK_WINDOWS_ROCM_GFX) def test_route_to_vulkan_prebuilt_keeps_every_fork_windows_rocm_arch(gfx, monkeypatch): # No ambient opt-in: this asserts the AUTO path leaves covered archs alone. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) host = _windows_amd_host(rocm_gfx_target = gfx, rocm_gfx_targets = [gfx]) routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) @@ -1505,7 +1505,7 @@ def test_forwarded_gfx_does_not_undo_visible_device_auto_vulkan(monkeypatch): # Under CUDA_VISIBLE_DEVICES=1 setup.ps1 still resolves GPU 0 and forwards gfx1100, but # detect_host() resolved the visible gfx1010, so folding the forward in must not # reinstate gfx1100 and install a HIP bundle the visible GPU cannot run. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1010", rocm_gfx_targets = ["gfx1100", "gfx1010"]) @@ -1528,7 +1528,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_the_physical_hip_card(monkeypatch # the HIP target but must not delete the probe's inventory, or the floor check concludes # no AMD GPU here reaches HIP and auto-routes to Vulkan, which ignores the HIP mask and # enumerates the reserved gfx1100. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx1100", "gfx803"]) @@ -1544,7 +1544,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_the_physical_hip_card(monkeypatch def test_forwarded_gfx_absent_from_probe_keeps_a_single_probed_hip_card(monkeypatch): # Same rule on a single-GPU box: a stale below-floor forward over a probe-confirmed # gfx1100 must not auto-route that machine to Vulkan. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) @@ -1556,7 +1556,7 @@ def test_forwarded_gfx_absent_from_probe_keeps_a_single_probed_hip_card(monkeypa def test_forwarded_gfx_absent_from_probe_still_allows_explicit_vulkan(monkeypatch): # The physical-inventory rule gates the AUTO path only; naming the backend wins. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) host = ilp._apply_host_overrides(host, override_rocm_gfx = "gfx803") @@ -1569,7 +1569,7 @@ def test_forwarded_gfx_on_unprobed_host_still_auto_vulkans(monkeypatch): # Negative control: a driver-only AMD host runs no successful probe (no hipinfo, amd-smi # suppressed), so --rocm-gfx is the ONLY source of the arch and there is no inventory to # preserve. This is the #7357 path the feature exists for; it must still reach Vulkan. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = None, rocm_gfx_targets = []) @@ -1584,7 +1584,7 @@ def test_forwarded_gfx_on_unprobed_host_still_auto_vulkans(monkeypatch): def test_forwarded_gfx_still_fills_an_unprobed_arch(monkeypatch): # Negative control: on an amd-smi-only host detect_host() reports no arch, so the # forward is the only source and must still apply. - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) monkeypatch.delenv("UNSLOTH_ROCM_GFX_ARCH", raising = False) host = _windows_amd_host(rocm_gfx_target = None, rocm_gfx_targets = []) @@ -1598,7 +1598,7 @@ def test_forwarded_gfx_still_fills_an_unprobed_arch(monkeypatch): def test_llama_cpp_backend_cpu_opts_out_of_auto_vulkan(monkeypatch): # cpu is an explicit non-Vulkan choice, so it suppresses the auto-fallback. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") host = _windows_amd_host(rocm_gfx_target = "gfx803", rocm_gfx_targets = ["gfx803"]) routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) assert routed is host @@ -1610,7 +1610,7 @@ def test_llama_cpp_backend_cpu_opts_out_of_auto_vulkan(monkeypatch): def test_explicit_backend_beats_legacy_force_vulkan(monkeypatch): # A stale UNSLOTH_FORCE_VULKAN must not overrule the canonical CPU choice. monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") assert ilp.resolved_llama_backend() == "cpu" assert ilp.force_vulkan_requested() is False host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) @@ -1621,7 +1621,7 @@ def test_explicit_backend_beats_legacy_force_vulkan(monkeypatch): def test_unknown_llama_backend_value_falls_through_to_legacy_flag(monkeypatch): # An unrecognised value is ignored, not an error, so the legacy flag still works. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "banana") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "banana") assert ilp.resolved_llama_backend() is None assert ilp.force_vulkan_requested() is False monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") @@ -1630,7 +1630,7 @@ def test_unknown_llama_backend_value_falls_through_to_legacy_flag(monkeypatch): def test_llama_backend_flag_beats_conflicting_env(monkeypatch): # --llama-backend is the caller's explicit request and outranks the env. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "cpu") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "cpu") assert ilp.force_vulkan_requested("vulkan") is True host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt( @@ -1665,7 +1665,7 @@ def _windows_arm64_host(**overrides): @pytest.mark.parametrize( "env, flag", [ - ({"UNSLOTH_LLAMA_BACKEND": "vulkan"}, None), + ({"UNSLOTH_LLAMA_CPP_BACKEND": "vulkan"}, None), ({"UNSLOTH_FORCE_VULKAN": "1"}, None), ({}, "vulkan"), ], @@ -1686,7 +1686,7 @@ def test_vulkan_opt_in_keeps_windows_arm64_host_for_strict_rejection(monkeypatch def test_vulkan_opt_in_still_routes_on_windows_x64(monkeypatch): # Negative control for the arm64 guard: x64 keeps its Vulkan routing. - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", "vulkan") + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") host = _windows_amd_host(rocm_gfx_target = "gfx1100", rocm_gfx_targets = ["gfx1100"]) _routed, repo, _tag, persist = ilp._route_to_vulkan_prebuilt(host, FORK, "pin", force_cpu = False) assert repo == FORK @@ -1760,7 +1760,7 @@ def test_marker_records_no_backend_when_vulkan_fell_back_to_cpu(tmp_path): assert marker["llama_backend"] == "vulkan" -# setup translates UNSLOTH_LLAMA_BACKEND=cpu into --force-cpu to pin the +# setup translates UNSLOTH_LLAMA_CPP_BACKEND=cpu into --force-cpu to pin the # CPU-only bundle on a GPU host, which is what keeps Intel iGPU Vulkan crashes # away (#7213). Vulkan is opt-in, so no trigger may outrank that flag on any host. _SIM_PLATFORMS = { @@ -1851,9 +1851,9 @@ def test_forced_cpu_outranks_every_vulkan_trigger( """A deliberate CPU install stays CPU on every host, whatever asks for Vulkan.""" monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) if backend_env is None: - monkeypatch.delenv("UNSLOTH_LLAMA_BACKEND", raising = False) + monkeypatch.delenv("UNSLOTH_LLAMA_CPP_BACKEND", raising = False) else: - monkeypatch.setenv("UNSLOTH_LLAMA_BACKEND", backend_env) + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend_env) # The legacy switch too, so a stale one cannot smuggle Vulkan past --force-cpu. monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") diff --git a/studio/backend/tests/test_llama_cpp_update.py b/studio/backend/tests/test_llama_cpp_update.py index 8579e6bffb..01a1b2ac3d 100644 --- a/studio/backend/tests/test_llama_cpp_update.py +++ b/studio/backend/tests/test_llama_cpp_update.py @@ -499,7 +499,7 @@ def test_start_update_preserves_vulkan_via_env(monkeypatch, tmp_path): time.sleep(0.05) assert job["state"] == "success", job assert popen_kwargs["env"]["UNSLOTH_FORCE_VULKAN"] == "1" - assert popen_kwargs["env"]["UNSLOTH_LLAMA_BACKEND"] == "vulkan" + assert popen_kwargs["env"]["UNSLOTH_LLAMA_CPP_BACKEND"] == "vulkan" assert "--llama-backend" in captured["cmd"] and "vulkan" in captured["cmd"] diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index a3d3ed5239..48625dca54 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -27,7 +27,7 @@ _SKIP_NO_PWSH = pytest.mark.skipif(shutil.which("pwsh") is None, reason = "pwsh def _backend_block() -> str: text = _SETUP_SH.read_text(encoding = "utf-8") m = re.search(r"_llama_backend=.*?esac", text, re.DOTALL) - assert m, "UNSLOTH_LLAMA_BACKEND block not found in setup.sh" + assert m, "UNSLOTH_LLAMA_CPP_BACKEND block not found in setup.sh" return m.group(0) @@ -38,13 +38,13 @@ def _run(value: str | None, system: str = "Linux") -> tuple[list[str], str]: env = { k: v for k, v in os.environ.items() - if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") + if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_FORCE_VULKAN") } if value is not None: - env["UNSLOTH_LLAMA_BACKEND"] = value + env["UNSLOTH_LLAMA_CPP_BACKEND"] = value harness = ( f'set -u\n_PREBUILT_CMD=()\nC_WARN=""\nC_OK=""\n_HOST_SYSTEM="{system}"\n' - '_source_backend_choice="$(printf \'%s\' "${UNSLOTH_LLAMA_BACKEND:-auto}" ' + '_source_backend_choice="$(printf \'%s\' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" ' "| awk '{$1=$1; print tolower($0)}')\"\n" '_source_legacy_force_vulkan="$(printf \'%s\' "${UNSLOTH_FORCE_VULKAN:-}" ' "| awk '{$1=$1; print tolower($0)}')\"\n" @@ -231,10 +231,10 @@ def test_llama_backend_source_choice_in_setup_sh( env = { k: v for k, v in os.environ.items() - if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") + if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_FORCE_VULKAN") } if backend is not None: - env["UNSLOTH_LLAMA_BACKEND"] = backend + env["UNSLOTH_LLAMA_CPP_BACKEND"] = backend if force_vulkan is not None: env["UNSLOTH_FORCE_VULKAN"] = force_vulkan harness = ( @@ -268,17 +268,17 @@ def _ps1_search(pattern: str, flags = 0) -> str: ) def test_llama_backend_source_choice_in_setup_ps1(backend, force_vulkan, expected_explicit): normalize = _ps1_search( - r'\$sourceLlamaBackend = "\$\(\$env:UNSLOTH_LLAMA_BACKEND\)".*?' + r'\$sourceLlamaBackend = "\$\(\$env:UNSLOTH_LLAMA_CPP_BACKEND\)".*?' r"\$explicitVulkanSourceBuild = \(.*?\n\)\n", re.DOTALL, ) env = { k: v for k, v in os.environ.items() - if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") + if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_FORCE_VULKAN") } if backend is not None: - env["UNSLOTH_LLAMA_BACKEND"] = backend + env["UNSLOTH_LLAMA_CPP_BACKEND"] = backend if force_vulkan is not None: env["UNSLOTH_FORCE_VULKAN"] = force_vulkan out = subprocess.run( @@ -301,7 +301,7 @@ def _run_ps1(value: str | None) -> str: # The override is normalized (assign + warn) at the top of the prebuilt block and # applied to $prebuiltArgs lower down; compose both real snippets. normalize = _ps1_search( - r"\$llamaBackend = \$sourceLlamaBackend.*?Ignoring UNSLOTH_LLAMA_BACKEND.*?\n\s*\}", + r"\$llamaBackend = \$sourceLlamaBackend.*?Ignoring UNSLOTH_LLAMA_CPP_BACKEND.*?\n\s*\}", re.DOTALL, ) apply_flag = _ps1_search( @@ -310,13 +310,13 @@ def _run_ps1(value: str | None) -> str: env = { k: v for k, v in os.environ.items() - if k not in ("UNSLOTH_LLAMA_BACKEND", "UNSLOTH_FORCE_VULKAN") + if k not in ("UNSLOTH_LLAMA_CPP_BACKEND", "UNSLOTH_FORCE_VULKAN") } if value is not None: - env["UNSLOTH_LLAMA_BACKEND"] = value + env["UNSLOTH_LLAMA_CPP_BACKEND"] = value harness = ( "$prebuiltArgs = @()\n" - '$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant()\n' + '$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant()\n' '$sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant()\n' f'{normalize}\n{apply_flag}\n"ARGS:" + ($prebuiltArgs -join ",")' ) diff --git a/studio/backend/utils/llama_cpp_update.py b/studio/backend/utils/llama_cpp_update.py index dffcddb452..78a46a91e4 100644 --- a/studio/backend/utils/llama_cpp_update.py +++ b/studio/backend/utils/llama_cpp_update.py @@ -463,7 +463,7 @@ def _run_llama_phase( # otherwise re-route and silently replace it. Re-assert via setup's env/CLI flags. if llama_backend == "vulkan" or (asset and "vulkan" in asset.lower()): env["UNSLOTH_FORCE_VULKAN"] = "1" - env["UNSLOTH_LLAMA_BACKEND"] = "vulkan" + env["UNSLOTH_LLAMA_CPP_BACKEND"] = "vulkan" _flow.stream_installer( cmd, env, diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 08e8245c2c..9234e08f05 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6227,11 +6227,11 @@ def _normalized_llama_backend(value: str | None) -> str | None: def llama_backend_from_env() -> str | None: """Read an explicit llama.cpp backend preference from the environment. - ``UNSLOTH_LLAMA_BACKEND`` is shared with setup.sh/setup.ps1. ``auto`` and + ``UNSLOTH_LLAMA_CPP_BACKEND`` is shared with setup.sh/setup.ps1. ``auto`` and unknown values leave selection automatic. ``cpu`` and ``vulkan`` select those bundles explicitly, while ``hip``/``rocm`` opt out of automatic Vulkan routing. """ - return _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_BACKEND")) + return _normalized_llama_backend(os.environ.get("UNSLOTH_LLAMA_CPP_BACKEND")) def resolved_llama_backend(llama_backend: str | None = None) -> str | None: @@ -6243,7 +6243,7 @@ def resolved_llama_backend(llama_backend: str | None = None) -> str | None: def force_vulkan_requested(llama_backend: str | None = None) -> bool: """Whether this run should install the Vulkan llama.cpp prebuilt. - Triggered by ``UNSLOTH_LLAMA_BACKEND=vulkan``, legacy ``UNSLOTH_FORCE_VULKAN``, + Triggered by ``UNSLOTH_LLAMA_CPP_BACKEND=vulkan``, legacy ``UNSLOTH_FORCE_VULKAN``, or ``--llama-backend vulkan``. Scoped to the llama.cpp backend; the torch/training stack installs separately and still sees the real GPU. @@ -6425,7 +6425,7 @@ def _route_to_vulkan_prebuilt( The default Unsloth release manifest includes Vulkan app bundles, including the DiffusionGemma visual server. Three triggers route here, all suppressed when a CPU flag (--cpu-fallback or --force-cpu, folded into force_cpu) wins: - * ``UNSLOTH_LLAMA_BACKEND=vulkan`` / ``UNSLOTH_FORCE_VULKAN`` / + * ``UNSLOTH_LLAMA_CPP_BACKEND=vulkan`` / ``UNSLOTH_FORCE_VULKAN`` / ``--llama-backend vulkan`` forces Vulkan over the detected CUDA/ROCm backend; * Windows AMD with no HIP-prebuilt gfx arch auto-falls back to Vulkan (#7357); * an auto-detected Intel GPU with NO physical NVIDIA/ROCm, the purpose of the @@ -6450,7 +6450,7 @@ def _route_to_vulkan_prebuilt( if host.is_macos: if forced: log( - "UNSLOTH_LLAMA_BACKEND=vulkan is set but ignored on macOS " + "UNSLOTH_LLAMA_CPP_BACKEND=vulkan is set but ignored on macOS " "(Metal is used; there is no Vulkan prebuilt)" ) return host, published_repo, published_release_tag, None @@ -6881,7 +6881,7 @@ def parse_args() -> argparse.Namespace: action = "store_true", default = False, help = ( - "Deliberate CPU-only install (UNSLOTH_LLAMA_BACKEND=cpu). Drops GPU " + "Deliberate CPU-only install (UNSLOTH_LLAMA_CPP_BACKEND=cpu). Drops GPU " "detection like --cpu-fallback but also records force_cpu in the marker, so " "the in-app updater re-asserts CPU and never re-routes to a GPU/Vulkan " "bundle that would revive the Intel iGPU crash (#7213)." @@ -6895,7 +6895,7 @@ def parse_args() -> argparse.Namespace: "bundle and records the choice so Studio updates keep it. It is ignored " "on macOS, which uses Metal, and fails closed on Windows arm64 when no " "compatible Vulkan bundle is published. " - "Same effect as UNSLOTH_LLAMA_BACKEND=vulkan / " + "Same effect as UNSLOTH_LLAMA_CPP_BACKEND=vulkan / " "UNSLOTH_FORCE_VULKAN=1." ), ) diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 34fd951a0e..6ffbcb0a8f 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -33,7 +33,7 @@ $PackageDir = Split-Path -Parent $ScriptDir # errors. Only use "master" temporarily when the latest release is missing # support for a new model architecture. # -# UNSLOTH_LLAMA_BACKEND : "auto" (default), "cpu", "vulkan", "hip", or +# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", "vulkan", "hip", or # "rocm". "cpu" forces the CPU-only prebuilt. "vulkan" selects Vulkan even # when CUDA or ROCm is detected. "hip"/"rocm" opts out of automatic Vulkan. $DefaultLlamaPrForce = "" @@ -3601,7 +3601,7 @@ $ResolvedSourceUrl = $LlamaSource $ResolvedSourceRef = $RequestedLlamaTag $ResolvedSourceRefKind = "tag" $ResolvedLlamaTag = $RequestedLlamaTag -$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_BACKEND)".Trim().ToLowerInvariant() +$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant() $sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant() $explicitVulkanSourceBuild = ( -not $IsMacOS -and @@ -3861,14 +3861,14 @@ if ($LocalLlamaCppLinked) { if ($IsMacOS) { Write-Host "[WARN] Vulkan has no effect on macOS; the universal build uses Metal" -ForegroundColor Yellow } elseif ($windowsArm64) { - throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_LLAMA_BACKEND or compile llama.cpp from source." + throw "Vulkan was requested, but no Windows ARM64 Vulkan bundle is published. Unset UNSLOTH_LLAMA_CPP_BACKEND or compile llama.cpp from source." } else { $prebuiltArgs += @("--llama-backend", "vulkan") $explicitVulkanBackend = $true Write-Host " llama.cpp Vulkan selected for GGUF inference; the PyTorch training backend is unchanged" -ForegroundColor Cyan } } elseif ($llamaBackend -and $llamaBackend -notin @("auto", "hip", "rocm")) { - Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_BACKEND='$llamaBackend' (expected 'auto', 'cpu', 'vulkan', 'hip', or 'rocm')" -ForegroundColor Yellow + Write-Host "[WARN] Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$llamaBackend' (expected 'auto', 'cpu', 'vulkan', 'hip', or 'rocm')" -ForegroundColor Yellow } if ( -not $IsMacOS -and diff --git a/studio/setup.sh b/studio/setup.sh index 7d2632af47..7894fe8c3a 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -37,7 +37,7 @@ fi # Only use "master" temporarily when the latest release # is missing support for a new model architecture. # -# UNSLOTH_LLAMA_BACKEND : "auto" (default), "cpu", "vulkan", "hip", or +# UNSLOTH_LLAMA_CPP_BACKEND : "auto" (default), "cpu", "vulkan", "hip", or # "rocm". "cpu" forces the CPU-only prebuilt. "vulkan" # selects Vulkan even when CUDA or ROCm is detected. # "hip"/"rocm" keeps the detected HIP backend and opts @@ -1274,7 +1274,7 @@ _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}" _HOST_SYSTEM="$(uname -s 2>/dev/null || true)" _HOST_MACHINE="$(uname -m 2>/dev/null || true)" -_source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" +_source_backend_choice="$(printf '%s' "${UNSLOTH_LLAMA_CPP_BACKEND:-auto}" | awk '{$1=$1; print tolower($0)}')" _source_legacy_force_vulkan="$(printf '%s' "${UNSLOTH_FORCE_VULKAN:-}" | awk '{$1=$1; print tolower($0)}')" _explicit_vulkan_source_build=false if [ "$_HOST_SYSTEM" != "Darwin" ]; then @@ -1474,7 +1474,7 @@ else case "$_llama_backend" in cpu) if [ "$_HOST_SYSTEM" = "Darwin" ]; then - step "llama.cpp" "UNSLOTH_LLAMA_BACKEND=cpu has no effect on macOS (universal build; use -ngl 0 at runtime for CPU-only)" "$C_WARN" >&2 + step "llama.cpp" "UNSLOTH_LLAMA_CPP_BACKEND=cpu has no effect on macOS (universal build; use -ngl 0 at runtime for CPU-only)" "$C_WARN" >&2 else _PREBUILT_CMD+=(--force-cpu) fi @@ -1489,7 +1489,7 @@ else fi ;; ""|auto|hip|rocm) ;; - *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_BACKEND='$_llama_backend' (expected 'auto', 'cpu', 'vulkan', 'hip', or 'rocm')" "$C_WARN" >&2 ;; + *) step "llama.cpp" "Ignoring UNSLOTH_LLAMA_CPP_BACKEND='$_llama_backend' (expected 'auto', 'cpu', 'vulkan', 'hip', or 'rocm')" "$C_WARN" >&2 ;; esac if [ "$_HOST_SYSTEM" != "Darwin" ]; then case "$_llama_backend" in From 5c873ffcd42b75eade0308a49ee431b9ef90ecde Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:01:07 -0300 Subject: [PATCH 096/110] Preserve GPU namespace while Vulkan inventory recovers --- .../components/model-config-page.tsx | 66 +++----- studio/frontend/src/hooks/gpu-selection.ts | 108 ++++++++++++ studio/frontend/src/hooks/use-gpu-info.ts | 158 +++++------------- studio/frontend/tests/gpu-selection.test.ts | 108 ++++++++++++ tests/studio/test_model_picker_contracts.py | 10 +- 5 files changed, 289 insertions(+), 161 deletions(-) create mode 100644 studio/frontend/src/hooks/gpu-selection.ts create mode 100644 studio/frontend/tests/gpu-selection.test.ts diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index f539838131..72f40b2c12 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -23,7 +23,7 @@ import { prepareHfTokenForUse } from "@/features/hf-auth"; import { type GpuIndexKind, type SystemGpuDevice, - gpuDeviceCacheReady, + cachedPinnableGpuContext, pinnableGpuContext, reconcileGpuSelection, useGpuDevices, @@ -81,14 +81,16 @@ const SELECT_TRIGGER_CLASS = `grid h-8 min-w-0 grid-cols-[minmax(0,1fr)_auto] it const NUMBER_INPUT_CLASS = `h-8 w-[92px] ${CONTROL_SURFACE} pl-3 pr-2 py-0 text-right text-ui-13 font-medium text-nav-fg outline-none focus-visible:ring-0`; const KV_CACHE_DTYPE_DEFAULT = "f16"; -const SPECULATIVE_TYPE_LABELS: Record<(typeof SPECULATIVE_TYPES)[number], string> = - { - auto: "Auto", - mtp: "MTP", - ngram: "Ngram", - "mtp+ngram": "MTP+Ngram", - off: "Off", - }; +const SPECULATIVE_TYPE_LABELS: Record< + (typeof SPECULATIVE_TYPES)[number], + string +> = { + auto: "Auto", + mtp: "MTP", + ngram: "Ngram", + "mtp+ngram": "MTP+Ngram", + off: "Off", +}; function hasNonDefaultAdvanced(config: PerModelConfig): boolean { return ( @@ -138,19 +140,12 @@ function withoutUnsupportedDiffusionSettings( function reconcileConfigGpuSelection( config: PerModelConfig, - gpuDevices: SystemGpuDevice[], isDiffusion: boolean, - devicesReady: boolean, + gpuDevices?: SystemGpuDevice[], ): PerModelConfig { - const context = pinnableGpuContext( - devicesReady ? gpuDevices : null, - isDiffusion, - ); + const context = cachedPinnableGpuContext(isDiffusion, gpuDevices); const supported = isDiffusion - ? withoutUnsupportedDiffusionSettings( - config, - context.indexKind ?? null, - ) + ? withoutUnsupportedDiffusionSettings(config, context.indexKind ?? null) : config; if (supported.selectedGpuIds == null) { return supported; @@ -350,8 +345,7 @@ function GpuMemorySettings({ const gpuContext = pinnableGpuContext(gpuDevices, isDiffusion); const pinnableDevices = gpuContext.devices ?? []; const gpuIndexKind = gpuContext.indexKind ?? null; - const singleGpuInUse = - (selectedGpuIds ?? gpuContext.ids ?? []).length <= 1; + const singleGpuInUse = (selectedGpuIds ?? gpuContext.ids ?? []).length <= 1; // Multi-GPU only, with one backend-declared index namespace. null = automatic. const showGpuPicker = (gpuContext.ids?.length ?? 0) > 1; const isGpuChecked = (index: number) => @@ -431,8 +425,8 @@ function GpuMemorySettings({ info={ <> Layers to keep on the GPU (--gpu-layers); the rest run on CPU. - Auto lets llama.cpp size the split (and the context) to fit VRAM. - At the maximum, the whole model is on the GPU. + Auto lets llama.cpp size the split (and the context) to fit + VRAM. At the maximum, the whole model is on the GPU. } /> @@ -689,7 +683,6 @@ export function ModelConfigPage({ (s) => s.ggufMaxContextLength, ); const gpuDevices = useGpuDevices(); - const gpuDevicesReady = gpuDeviceCacheReady(); const resolveInitial = () => { const resolved = resolveInitialConfig(target.id, target.ggufVariant); if (loadedConfig) { @@ -707,12 +700,7 @@ export function ModelConfigPage({ }; const [initial] = useState(resolveInitial); const [config, setConfig] = useState(() => - reconcileConfigGpuSelection( - initial.config, - gpuDevices, - isDiffusion, - gpuDevicesReady, - ), + reconcileConfigGpuSelection(initial.config, isDiffusion, gpuDevices), ); const [remember, setRemember] = useState(() => initial.remembered); const [savedRemember, setSavedRemember] = useState(() => initial.remembered); @@ -807,23 +795,16 @@ export function ModelConfigPage({ const stagedMetadataPending = contextFetchKey != null && stagedDims == null && - (config.gpuMemoryMode === "manual" || - config.selectedGpuIds != null); - const classifiedIsDiffusion = - isDiffusion ? true : stagedDims?.isDiffusion; + (config.gpuMemoryMode === "manual" || config.selectedGpuIds != null); + const classifiedIsDiffusion = isDiffusion ? true : stagedDims?.isDiffusion; const resolvedIsDiffusion = classifiedIsDiffusion === true; const gpuIndexKind = pinnableGpuContext(gpuDevices, resolvedIsDiffusion).indexKind ?? null; useEffect(() => { setConfig((current) => - reconcileConfigGpuSelection( - current, - gpuDevices, - resolvedIsDiffusion, - gpuDevicesReady, - ), + reconcileConfigGpuSelection(current, resolvedIsDiffusion, gpuDevices), ); - }, [gpuDevices, gpuDevicesReady, resolvedIsDiffusion]); + }, [gpuDevices, resolvedIsDiffusion]); const isMtp = config.speculativeType != null && @@ -851,8 +832,7 @@ export function ModelConfigPage({ ), maxContext, ); - const setContextLength = (v: number) => - update({ customContextLength: v }); + const setContextLength = (v: number) => update({ customContextLength: v }); const rawBaseline = loadedConfig ?? DEFAULT_PER_MODEL_CONFIG; const baseline = resolvedIsDiffusion ? withoutUnsupportedDiffusionSettings(rawBaseline, gpuIndexKind) diff --git a/studio/frontend/src/hooks/gpu-selection.ts b/studio/frontend/src/hooks/gpu-selection.ts new file mode 100644 index 0000000000..1a63b3fbdb --- /dev/null +++ b/studio/frontend/src/hooks/gpu-selection.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +export type GpuIndexKind = "physical" | "vulkan"; + +export interface SystemGpuDevice { + index: number; + indexKind: GpuIndexKind | null; + name: string; + memoryTotalGb: number; + /** Free VRAM at fetch time, or total VRAM when usage is unavailable. */ + memoryFreeGb: number; + /** Whether `index` is safe to send as gpu_ids. */ + pinnable: boolean; + /** Whether the separate DiffusionGemma runner can use this physical ID. */ + diffusionPinnable: boolean; +} + +export interface ReconciledGpuSelection { + ids: number[] | null; + indexKind: GpuIndexKind | null; +} + +export interface PinnableGpuContext { + devices: SystemGpuDevice[] | null; + ids: number[] | null; + indexKind: GpuIndexKind | null | undefined; +} + +export function pinnableGpuContext( + devices: SystemGpuDevice[] | null, + forDiffusion = false, +): PinnableGpuContext { + if (devices === null) { + return { devices: null, ids: null, indexKind: undefined }; + } + const pinnable = devices.filter((device) => + forDiffusion ? device.diffusionPinnable : device.pinnable, + ); + const indexKind = pinnable[0]?.indexKind ?? null; + if ( + pinnable.length <= 1 || + indexKind === null || + !pinnable.every((device) => device.indexKind === indexKind) + ) { + return { devices: pinnable, ids: [], indexKind: null }; + } + return { + devices: pinnable, + ids: pinnable.map((device) => device.index), + indexKind, + }; +} + +/** + * Selection context when the backend namespace may be known before its device + * membership. A temporarily unavailable Vulkan inventory still proves that + * physical IDs are incompatible, but cannot yet range-check Vulkan ordinals. + */ +export function resolveGpuSelectionContext( + devices: SystemGpuDevice[] | null, + forDiffusion = false, + unavailableIndexKind?: GpuIndexKind, +): PinnableGpuContext { + if (unavailableIndexKind !== undefined) { + if (forDiffusion) { + return { devices: [], ids: [], indexKind: null }; + } + return { + devices: [], + ids: null, + indexKind: unavailableIndexKind, + }; + } + return pinnableGpuContext(devices, forDiffusion); +} + +export function reconcileGpuSelection( + ids: number[] | null, + savedIndexKind: GpuIndexKind | null | undefined, + currentIndexKind: GpuIndexKind | null | undefined, + pinnableIds: number[] | null, +): ReconciledGpuSelection { + if (ids == null) { + return { ids: null, indexKind: null }; + } + const expectedIndexKind = + savedIndexKind === undefined ? "physical" : savedIndexKind; + // Namespace knowledge is authoritative even while membership is unavailable. + // This is what prevents a saved CUDA/ROCm ID from becoming Vulkan. + if ( + currentIndexKind !== undefined && + expectedIndexKind !== null && + expectedIndexKind !== currentIndexKind + ) { + return { ids: null, indexKind: null }; + } + if (currentIndexKind === null) { + return { ids: null, indexKind: null }; + } + if (currentIndexKind === undefined || pinnableIds === null) { + return { ids, indexKind: expectedIndexKind }; + } + const kept = ids.filter((id) => pinnableIds.includes(id)); + return kept.length > 0 + ? { ids: kept, indexKind: currentIndexKind } + : { ids: null, indexKind: null }; +} diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index 3c53a96407..993b4a053e 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -3,13 +3,30 @@ import { useEffect, useState } from "react"; import { + type GpuIndexKind, + type PinnableGpuContext, + type ReconciledGpuSelection, + type SystemGpuDevice, + reconcileGpuSelection, + resolveGpuSelectionContext, +} from "./gpu-selection"; +import { + type SystemInfoResponse, aggregateGpuMemoryTotalGb, fetchSystemInfo, getCachedSystemInfo, subscribeSystemInfo, - type SystemInfoResponse, } from "./use-system"; +export { + pinnableGpuContext, + reconcileGpuSelection, + type GpuIndexKind, + type PinnableGpuContext, + type ReconciledGpuSelection, + type SystemGpuDevice, +} from "./gpu-selection"; + export interface GpuInfo { available: boolean; budgetKnown: boolean; @@ -18,86 +35,7 @@ export interface GpuInfo { cpuCore: number; cpuThread: number; systemRamAvailableGb: number; - systemRamTotalGb: number -} - -export interface SystemGpuDevice { - index: number; - indexKind: GpuIndexKind | null; - name: string; - memoryTotalGb: number; - /** Free VRAM at fetch time, or total VRAM when usage is unavailable. */ - memoryFreeGb: number; - /** Whether `index` is safe to send as gpu_ids. */ - pinnable: boolean; - /** Whether the separate DiffusionGemma runner can use this physical ID. */ - diffusionPinnable: boolean; -} - -export type GpuIndexKind = "physical" | "vulkan"; - -export interface ReconciledGpuSelection { - ids: number[] | null; - indexKind: GpuIndexKind | null; -} - -export interface PinnableGpuContext { - devices: SystemGpuDevice[] | null; - ids: number[] | null; - indexKind: GpuIndexKind | null | undefined; -} - -export function pinnableGpuContext( - devices: SystemGpuDevice[] | null, - forDiffusion = false, -): PinnableGpuContext { - if (devices === null) { - return { devices: null, ids: null, indexKind: undefined }; - } - const pinnable = devices.filter((device) => - forDiffusion ? device.diffusionPinnable : device.pinnable, - ); - const indexKind = pinnable[0]?.indexKind ?? null; - if ( - pinnable.length <= 1 || - indexKind === null || - !pinnable.every((device) => device.indexKind === indexKind) - ) { - return { devices: pinnable, ids: [], indexKind: null }; - } - return { - devices: pinnable, - ids: pinnable.map((device) => device.index), - indexKind, - }; -} - -export function reconcileGpuSelection( - ids: number[] | null, - savedIndexKind: GpuIndexKind | null | undefined, - currentIndexKind: GpuIndexKind | null | undefined, - pinnableIds: number[] | null, -): ReconciledGpuSelection { - if (ids == null) return { ids: null, indexKind: null }; - if (currentIndexKind === undefined || pinnableIds === null) { - return { - ids, - indexKind: - savedIndexKind === undefined ? "physical" : savedIndexKind, - }; - } - const expectedIndexKind = - savedIndexKind === undefined ? "physical" : savedIndexKind; - if ( - currentIndexKind === null || - (expectedIndexKind !== null && expectedIndexKind !== currentIndexKind) - ) { - return { ids: null, indexKind: null }; - } - const kept = ids.filter((id) => pinnableIds.includes(id)); - return kept.length - ? { ids: kept, indexKind: currentIndexKind } - : { ids: null, indexKind: null }; + systemRamTotalGb: number; } const DEFAULT_GPU: GpuInfo = { @@ -108,7 +46,7 @@ const DEFAULT_GPU: GpuInfo = { cpuCore: 0, cpuThread: 0, systemRamAvailableGb: 0, - systemRamTotalGb: 0 + systemRamTotalGb: 0, }; function toGpuInfo( @@ -124,9 +62,7 @@ function toGpuInfo( systemRamTotalGb: data?.memory?.total_gb ?? 0, }; const gpuData = - source === "inference_gpu" - ? (data?.inference_gpu ?? data?.gpu) - : data?.gpu; + source === "inference_gpu" ? (data?.inference_gpu ?? data?.gpu) : data?.gpu; const devices = gpuData?.devices ?? []; if (!gpuData?.available || !devices.length) { return { ...DEFAULT_GPU, ...base, budgetKnown: data !== null }; @@ -201,8 +137,7 @@ function toGpuDevices(data: SystemInfoResponse | null): SystemGpuDevice[] { pinnableBackend && (d.index_kind === "vulkan" || (data?.device_backend !== "xpu" && d.index_kind === "physical")), - diffusionPinnable: - diffusionBackend && d.index_kind === "physical", + diffusionPinnable: diffusionBackend && d.index_kind === "physical", })); } @@ -294,10 +229,7 @@ export function gpuDeviceCacheReady(): boolean { return false; } const inferenceGpu = cachedSystem.inference_gpu; - return !( - inferenceGpu?.backend === "vulkan" && - !inferenceGpu.available - ); + return !(inferenceGpu?.backend === "vulkan" && !inferenceGpu.available); } /** Warm the shared system cache before validating persisted GPU IDs. */ @@ -309,22 +241,33 @@ export async function ensureGpuDeviceCache(): Promise { export function cachedPinnableGpuIndices( forDiffusion = false, ): number[] | null { - const cachedSystem = getCachedSystemInfo(); - return pinnableGpuContext( - cachedSystem ? toGpuDevices(cachedSystem) : null, - forDiffusion, - ).ids; + return cachedPinnableGpuContext(forDiffusion).ids; } /** Cached index namespace, undefined before fetch and null when unavailable. */ export function cachedPinnableGpuIndexKind( forDiffusion = false, ): GpuIndexKind | null | undefined { + return cachedPinnableGpuContext(forDiffusion).indexKind; +} + +/** + * Cached namespace and membership are separate: an unavailable Vulkan probe + * leaves membership unknown while the Vulkan namespace remains authoritative. + */ +export function cachedPinnableGpuContext( + forDiffusion = false, + devices?: SystemGpuDevice[], +): PinnableGpuContext { const cachedSystem = getCachedSystemInfo(); - return pinnableGpuContext( - cachedSystem ? toGpuDevices(cachedSystem) : null, + const unavailableVulkan = + cachedSystem?.inference_gpu?.backend === "vulkan" && + !cachedSystem.inference_gpu.available; + return resolveGpuSelectionContext( + cachedSystem ? (devices ?? toGpuDevices(cachedSystem)) : null, forDiffusion, - ).indexKind; + unavailableVulkan ? "vulkan" : undefined, + ); } export function reconcileCachedGpuSelection( @@ -332,22 +275,7 @@ export function reconcileCachedGpuSelection( savedIndexKind?: GpuIndexKind | null, forDiffusion = false, ): ReconciledGpuSelection { - const cachedSystem = getCachedSystemInfo(); - if (!gpuDeviceCacheReady()) { - return { - ids, - indexKind: - ids == null - ? null - : savedIndexKind === undefined - ? "physical" - : savedIndexKind, - }; - } - const context = pinnableGpuContext( - cachedSystem ? toGpuDevices(cachedSystem) : null, - forDiffusion, - ); + const context = cachedPinnableGpuContext(forDiffusion); return reconcileGpuSelection( ids, savedIndexKind, diff --git a/studio/frontend/tests/gpu-selection.test.ts b/studio/frontend/tests/gpu-selection.test.ts new file mode 100644 index 0000000000..7cd5ac6b74 --- /dev/null +++ b/studio/frontend/tests/gpu-selection.test.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + type GpuIndexKind, + reconcileGpuSelection, + resolveGpuSelectionContext, +} from "../src/hooks/gpu-selection.ts"; + +const ids = [0, 1]; + +function reconcile( + savedIndexKind: GpuIndexKind | null | undefined, + currentIndexKind: GpuIndexKind | null | undefined, + pinnableIds: number[] | null, +) { + return reconcileGpuSelection( + ids, + savedIndexKind, + currentIndexKind, + pinnableIds, + ); +} + +test("preserves every namespace while the device cache is genuinely cold", () => { + assert.deepEqual(reconcile(undefined, undefined, null), { + ids, + indexKind: "physical", + }); + assert.deepEqual(reconcile(null, undefined, null), { + ids, + indexKind: null, + }); + assert.deepEqual(reconcile("physical", undefined, null), { + ids, + indexKind: "physical", + }); + assert.deepEqual(reconcile("vulkan", undefined, null), { + ids, + indexKind: "vulkan", + }); +}); + +test("known unavailable Vulkan clears physical picks but preserves compatible state", () => { + const context = resolveGpuSelectionContext([], false, "vulkan"); + assert.deepEqual(context, { + devices: [], + ids: null, + indexKind: "vulkan", + }); + assert.deepEqual(reconcile(undefined, context.indexKind, context.ids), { + ids: null, + indexKind: null, + }); + assert.deepEqual(reconcile("physical", context.indexKind, context.ids), { + ids: null, + indexKind: null, + }); + assert.deepEqual(reconcile(null, context.indexKind, context.ids), { + ids, + indexKind: null, + }); + assert.deepEqual(reconcile("vulkan", context.indexKind, context.ids), { + ids, + indexKind: "vulkan", + }); +}); + +test("known Vulkan suppresses every DiffusionGemma pin while unavailable", () => { + const context = resolveGpuSelectionContext([], true, "vulkan"); + assert.deepEqual(context, { + devices: [], + ids: [], + indexKind: null, + }); + for (const saved of [undefined, null, "physical", "vulkan"] as const) { + assert.deepEqual(reconcile(saved, context.indexKind, context.ids), { + ids: null, + indexKind: null, + }); + } +}); + +test("recovered inventories filter matching picks and reject mismatched namespaces", () => { + assert.deepEqual(reconcile(null, "vulkan", [1]), { + ids: [1], + indexKind: "vulkan", + }); + assert.deepEqual(reconcile("vulkan", "vulkan", [0]), { + ids: [0], + indexKind: "vulkan", + }); + assert.deepEqual(reconcile(undefined, "vulkan", [0, 1]), { + ids: null, + indexKind: null, + }); + assert.deepEqual(reconcile("physical", "physical", [1]), { + ids: [1], + indexKind: "physical", + }); + assert.deepEqual(reconcile("vulkan", "physical", [0, 1]), { + ids: null, + indexKind: null, + }); +}); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 2eb595791d..9cccccd5db 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -638,10 +638,14 @@ def test_default_gpu_mode_clears_manual_knobs(): def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): store = _read("features/chat/stores/chat-runtime-store.ts") assert "requestedGpuIdsFromResponse(resp)" in store + gpu_selection = _read("hooks/gpu-selection.ts") + assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in gpu_selection + assert "expectedIndexKind !== null" in gpu_selection + assert "expectedIndexKind !== currentIndexKind" in gpu_selection + assert "Namespace knowledge is authoritative even while membership is unavailable" in gpu_selection gpu_info = _read("hooks/use-gpu-info.ts") - assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in gpu_info - assert "expectedIndexKind !== null" in gpu_info - assert "expectedIndexKind !== currentIndexKind" in gpu_info + assert "cachedPinnableGpuContext" in gpu_info + assert 'unavailableVulkan ? "vulkan" : undefined' in gpu_info assert "cachedPinnableGpuIndexKind" in gpu_info config = _read("features/model-picker/model-config/per-model-config.ts") assert '"selectedGpuIndexKind"' in config From 66b515e05253b5e0f3a75db572a29bb60e459ce3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:02:54 +0000 Subject: [PATCH 097/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/test_model_picker_contracts.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 9cccccd5db..e3bbe80ed6 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -642,7 +642,9 @@ def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in gpu_selection assert "expectedIndexKind !== null" in gpu_selection assert "expectedIndexKind !== currentIndexKind" in gpu_selection - assert "Namespace knowledge is authoritative even while membership is unavailable" in gpu_selection + assert ( + "Namespace knowledge is authoritative even while membership is unavailable" in gpu_selection + ) gpu_info = _read("hooks/use-gpu-info.ts") assert "cachedPinnableGpuContext" in gpu_info assert 'unavailableVulkan ? "vulkan" : undefined' in gpu_info From cdfc98018d6c1babf5908d6351c5a67ebfbe459e Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:45:03 -0300 Subject: [PATCH 098/110] Fix final PR 7188 review findings --- .../src/features/chat/api/chat-adapter.ts | 12 ++++++-- .../chat/hooks/use-chat-model-runtime.ts | 6 ++-- .../lib/apply-inference-status-to-store.ts | 22 ++++++++++++--- .../src/features/chat/shared-composer.tsx | 8 ++++-- .../chat/stores/chat-runtime-store.ts | 6 ++++ .../model-config/apply-per-model-config.ts | 4 ++- studio/frontend/src/hooks/gpu-selection.ts | 13 +++++++++ studio/frontend/tests/gpu-selection.test.ts | 25 +++++++++++++++++ tests/studio/test_model_picker_contracts.py | 28 +++++++++++++++++++ 9 files changed, 111 insertions(+), 13 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index b7d4cdbcca..a0c08a0c84 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1572,7 +1572,10 @@ async function autoLoadSmallestModel(): Promise<{ await ensureGpuDeviceCache(); } let isDiffusion = false; - if (candidate.kind === "gguf" && config.selectedGpuIds != null) { + if ( + candidate.kind === "gguf" && + (config.selectedGpuIds != null || config.tensorParallel === true) + ) { // Prepare the token before this probe, exactly as validateModel/loadModel // (and the interactive performLoad path) do: the Hub 401s an invalid // Authorization header even for a public repo, so sending the raw stored @@ -1590,6 +1593,9 @@ async function autoLoadSmallestModel(): Promise<{ }) ).isDiffusion; } + const effectiveTensorParallel = isDiffusion + ? false + : config.tensorParallel; const effectiveGpuIds = config.selectedGpuIds !== undefined ? reconcilePersistedGpuIds( @@ -1623,7 +1629,7 @@ async function autoLoadSmallestModel(): Promise<{ is_lora: false, gguf_variant: candidate.ggufVariant, cache_type_kv: config.kvCacheDtype, - tensor_parallel: config.tensorParallel, + tensor_parallel: effectiveTensorParallel, // The same remembered-derived GPU pick the load below sends. ...(candidate.kind === "gguf" ? { @@ -1651,7 +1657,7 @@ async function autoLoadSmallestModel(): Promise<{ cache_type_kv: config.kvCacheDtype, speculative_type: effectiveSpeculativeType, spec_draft_n_max: effectiveSpecDraftNMax, - tensor_parallel: config.tensorParallel, + tensor_parallel: effectiveTensorParallel, // GGUF-only: the safetensors fallback loads via HF auto-placement (no // explicit pins). The split ratio is deliberately never remembered // (positionally bound to an exact GPU set), so auto-load leaves llama.cpp's diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index c86480696f..d65158f9c3 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -765,8 +765,10 @@ export function useChatModelRuntime() { pendingLoadConfig?.customContextLength ?? stateBeforeUnload.customContextLength; const loadGgufContextLength = stateBeforeUnload.ggufContextLength; - const loadTensorParallel = - pendingLoadConfig?.tensorParallel ?? stateBeforeUnload.tensorParallel; + const loadTensorParallel = targetIsDiffusion + ? false + : (pendingLoadConfig?.tensorParallel ?? + stateBeforeUnload.tensorParallel); const loadActivePresetSource = stateBeforeUnload.activePresetSource; const loadActiveGgufVariant = stateBeforeUnload.activeGgufVariant; const loadGpuMemoryMode = diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index 586a18e867..24ab3a9be7 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -21,6 +21,7 @@ import { isMultimodalResponse, } from "../types/api"; import type { ChatModelSummary } from "../types/runtime"; +import { sameGpuSelection } from "@/hooks/gpu-selection"; type LocalReasoningEffort = Extract; @@ -224,12 +225,19 @@ export function applyActiveModelStatusToStore( incomingGpuMode === "manual" ? (status.tensor_split ?? null) : null; const incomingGpuFields = loadedGpuMemoryFields(status); const incomingGpuIds = incomingGpuFields.loadedGpuIds; + const incomingGpuIndexKind = incomingGpuFields.loadedGpuIndexKind; const gpuStatusChanged = prevState.loadedGpuMemoryMode !== incomingGpuMode || prevState.loadedGpuLayers !== incomingGpuLayers || prevState.loadedNCpuMoe !== incomingNCpuMoe || !sameArray(prevState.loadedSplitRatio, incomingSplit) || - !sameArray(prevState.loadedGpuIds, incomingGpuIds) || + !sameGpuSelection( + { + ids: prevState.loadedGpuIds, + indexKind: prevState.loadedGpuIndexKind, + }, + { ids: incomingGpuIds, indexKind: incomingGpuIndexKind }, + ) || prevState.loadedCustomContextLength !== gpuPin; const gpuMemoryEditsPending = (prevState.loadedGpuMemoryMode !== null && @@ -239,9 +247,15 @@ export function applyActiveModelStatusToStore( prevState.nCpuMoe !== prevState.loadedNCpuMoe || !sameArray(prevState.splitRatio, prevState.loadedSplitRatio))) || prevState.customContextLength !== prevState.loadedCustomContextLength; - const gpuIdsEditPending = !sameArray( - prevState.selectedGpuIds, - prevState.loadedGpuIds, + const gpuIdsEditPending = !sameGpuSelection( + { + ids: prevState.selectedGpuIds, + indexKind: prevState.selectedGpuIndexKind, + }, + { + ids: prevState.loadedGpuIds, + indexKind: prevState.loadedGpuIndexKind, + }, ); // A same-model reload from another client advances every loaded baseline. // Preserve each editable group only when this tab has an unapplied change. diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index cffd675812..c7dbbe8b4a 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -1095,9 +1095,11 @@ export function SharedComposer({ ownConfig.specDraftNMax, ) : specSettings.specDraftNMax; - const effectiveTensorParallel = ownRemembered - ? ownConfig.tensorParallel - : fallbackTensorParallel; + const effectiveTensorParallel = resolvedIsDiffusion + ? false + : ownRemembered + ? ownConfig.tensorParallel + : fallbackTensorParallel; if (ownConfig.selectedGpuIds != null) { await ensureGpuDeviceCache(); } diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 49f9f2a298..7edef3dc4d 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -692,6 +692,7 @@ export function loadedGpuMemoryFields(resp: { selectedGpuIds: null, selectedGpuIndexKind: null, loadedGpuIds: null, + loadedGpuIndexKind: null, loadedGpuMemoryMode: null, gpuLayers: GPU_LAYERS_AUTO, loadedGpuLayers: null, @@ -764,6 +765,7 @@ export function loadedGpuMemoryFields(resp: { // explicit-null-namespace convention (discovery not complete yet). selectedGpuIndexKind: gpuIds == null ? null : (gpuIndexKind ?? null), loadedGpuIds: gpuIds, + loadedGpuIndexKind: gpuIds == null ? null : (gpuIndexKind ?? null), ...manualKnobs, }; } @@ -1044,6 +1046,8 @@ type ChatRuntimeStore = { /** Namespace used by selectedGpuIds; kept with deferred persisted picks. */ selectedGpuIndexKind: GpuIndexKind | null; loadedGpuIds: number[] | null; + /** Backend-reported namespace paired with loadedGpuIds. */ + loadedGpuIndexKind: GpuIndexKind | null; /** Persisted: expand every On Device GGUF repo's quantizations by default * instead of waiting for a click. */ expandQuantizations: boolean; @@ -1555,6 +1559,7 @@ export const useChatRuntimeStore = create((set, get) => ({ selectedGpuIds: null, selectedGpuIndexKind: null, loadedGpuIds: null, + loadedGpuIndexKind: null, expandQuantizations: loadBool(CHAT_EXPAND_QUANTIZATIONS_KEY, false), showAllQuantizations: loadBool(CHAT_SHOW_ALL_QUANTIZATIONS_KEY, true), fitOnDeviceOnly: loadBool(MODELS_FIT_ON_DEVICE_ONLY_KEY, false), @@ -1940,6 +1945,7 @@ export const useChatRuntimeStore = create((set, get) => ({ selectedGpuIds: null, selectedGpuIndexKind: null, loadedGpuIds: null, + loadedGpuIndexKind: null, loadedIsMultimodal: false, loadedIsDiffusion: false, customContextLength: null, diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index 7cff5bd7ee..274bcb6222 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -50,7 +50,9 @@ export function applyPerModelConfigToRuntime( normalizeSpeculativeType(config.speculativeType) ?? readPersistedSpeculativeType(), specDraftNMax: config.specDraftNMax ?? null, - tensorParallel: config.tensorParallel ?? false, + tensorParallel: options.isDiffusion + ? false + : (config.tensorParallel ?? false), chatTemplateOverride: cleanTemplate(config.chatTemplateOverride), // GPU Memory knobs are per-model (GGUF-only). Absent = defaults; the mode is // a standing preference so an absent mode falls back to the persisted one. diff --git a/studio/frontend/src/hooks/gpu-selection.ts b/studio/frontend/src/hooks/gpu-selection.ts index 1a63b3fbdb..f1d3827c49 100644 --- a/studio/frontend/src/hooks/gpu-selection.ts +++ b/studio/frontend/src/hooks/gpu-selection.ts @@ -21,6 +21,19 @@ export interface ReconciledGpuSelection { indexKind: GpuIndexKind | null; } +export function sameGpuSelection( + left: ReconciledGpuSelection, + right: ReconciledGpuSelection, +): boolean { + if (left.indexKind !== right.indexKind) return false; + if (left.ids === right.ids) return true; + if (left.ids === null || right.ids === null) return false; + return ( + left.ids.length === right.ids.length && + left.ids.every((id, index) => id === right.ids?.[index]) + ); +} + export interface PinnableGpuContext { devices: SystemGpuDevice[] | null; ids: number[] | null; diff --git a/studio/frontend/tests/gpu-selection.test.ts b/studio/frontend/tests/gpu-selection.test.ts index 7cd5ac6b74..ef3da814a2 100644 --- a/studio/frontend/tests/gpu-selection.test.ts +++ b/studio/frontend/tests/gpu-selection.test.ts @@ -8,6 +8,7 @@ import { type GpuIndexKind, reconcileGpuSelection, resolveGpuSelectionContext, + sameGpuSelection, } from "../src/hooks/gpu-selection.ts"; const ids = [0, 1]; @@ -106,3 +107,27 @@ test("recovered inventories filter matching picks and reject mismatched namespac indexKind: null, }); }); + +test("selection equality includes the GPU index namespace", () => { + assert.equal( + sameGpuSelection( + { ids: [0, 1], indexKind: null }, + { ids: [0, 1], indexKind: "vulkan" }, + ), + false, + ); + assert.equal( + sameGpuSelection( + { ids: [0, 1], indexKind: "vulkan" }, + { ids: [0, 1], indexKind: "vulkan" }, + ), + true, + ); + assert.equal( + sameGpuSelection( + { ids: [0], indexKind: "physical" }, + { ids: [1], indexKind: "physical" }, + ), + false, + ); +}); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index e3bbe80ed6..d95dd2a897 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -235,6 +235,8 @@ def test_gpu_picker_round_trips_requested_pool_not_fitted_subset(): store = _read("features/chat/stores/chat-runtime-store.ts") assert 'hasOwnProperty.call(resp, "requested_gpu_ids")' in store assert "const reportedGpuIds = requestedGpuIdsFromResponse(resp)" in store + assert "loadedGpuIndexKind: GpuIndexKind | null;" in store + assert "loadedGpuIndexKind: gpuIds == null ? null : (gpuIndexKind ?? null)" in store # A cold discovery cache reports gpuIndexKind === undefined (deferred, not # rejected); only a warm cache's definitive null should drop the pin. assert "reportedGpuIds != null && gpuIndexKind !== null" in store @@ -242,6 +244,9 @@ def test_gpu_picker_round_trips_requested_pool_not_fitted_subset(): status = _read("features/chat/lib/apply-inference-status-to-store.ts") assert "const incomingGpuFields = loadedGpuMemoryFields(status)" in status assert "const incomingGpuIds = incomingGpuFields.loadedGpuIds" in status + assert "const incomingGpuIndexKind = incomingGpuFields.loadedGpuIndexKind" in status + assert status.count("prevState.loadedGpuIndexKind") >= 2 + assert status.count("sameGpuSelection(") >= 2 def test_compare_load_uses_each_models_gpu_config(): @@ -266,6 +271,29 @@ def test_compare_load_uses_each_models_gpu_config(): assert "isDiffusion: globalIsDiffusion" in page +def test_diffusion_load_paths_disable_tensor_parallel(): + """Every frontend path that classifies DiffusionGemma must send tensor + parallelism as false so the ignored setting cannot force repeat reloads.""" + compare = _read("features/chat/shared-composer.tsx") + compact_compare = " ".join(compare.split()) + assert "const effectiveTensorParallel = resolvedIsDiffusion ? false :" in compact_compare + assert compare.count("tensor_parallel: effectiveTensorParallel") == 2 + + runtime = " ".join(_read("features/chat/hooks/use-chat-model-runtime.ts").split()) + assert "const loadTensorParallel = targetIsDiffusion ? false :" in runtime + + apply = " ".join(_read("features/model-picker/model-config/apply-per-model-config.ts").split()) + assert "tensorParallel: options.isDiffusion ? false :" in apply + + adapter = _read("features/chat/api/chat-adapter.ts") + autoload = adapter.split("async function loadAutoLoadCandidate", 1)[1] + autoload = autoload.split("async function autoLoadSmallestModel", 1)[0] + compact_autoload = " ".join(autoload.split()) + assert "config.selectedGpuIds != null || config.tensorParallel === true" in compact_autoload + assert "const effectiveTensorParallel = isDiffusion ? false :" in compact_autoload + assert autoload.count("tensor_parallel: effectiveTensorParallel") == 2 + + def test_active_native_gguf_metadata_uses_path_token(): src = _read("features/model-picker/components/model-config-page.tsx") assert "(isActiveModel ? activeNativePathToken : null)" in src From 42e59f83bd4725fd5680e5809d487b92745d1442 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:20:34 -0300 Subject: [PATCH 099/110] Normalize llama.cpp device flag aliases --- studio/backend/core/inference/llama_cpp.py | 3 ++- .../backend/tests/test_chat_load_during_training.py | 11 +++++++++++ studio/backend/tests/test_mtp_vram_budget.py | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 5139a38c52..295b2ba313 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1886,7 +1886,8 @@ def _extra_args_device( args = [str(a) for a in extra_args] if extra_args else [] value: Optional[str] = None for i, raw in enumerate(args): - flag, eq, inline = raw.partition("=") + flag = _flag_name(raw) + _, eq, inline = raw.partition("=") if flag in flags: value = inline if eq else (args[i + 1] if i + 1 < len(args) else "") return value diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 531b0be218..6eec282346 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -1313,6 +1313,17 @@ class TestLoadModelGuardIntegration(unittest.TestCase): gpu_ids_are_vulkan_ordinals = False, ) + def test_vulkan_gpu_ids_reject_underscore_draft_device_alias(self): + with self.assertRaises(HTTPException) as exc: + self.route._reject_draft_device_with_gpu_ids( + [0], + ["--spec_draft_device", "Vulkan1"], + gpu_ids_are_vulkan_ordinals = True, + ) + + self.assertEqual(exc.exception.status_code, 400) + self.assertIn("Vulkan1", exc.exception.detail) + def test_refusal_409_and_no_unload(self): import contextlib from unittest.mock import MagicMock diff --git a/studio/backend/tests/test_mtp_vram_budget.py b/studio/backend/tests/test_mtp_vram_budget.py index 27449e2442..b3799d486b 100644 --- a/studio/backend/tests/test_mtp_vram_budget.py +++ b/studio/backend/tests/test_mtp_vram_budget.py @@ -679,9 +679,11 @@ class TestExtraArgsMtpDetection: # A real GPU device escapes the gpu_ids pin -> return the offending value. (["--spec-draft-device", "CUDA1"], "CUDA1"), (["--device-draft", "Vulkan2"], "Vulkan2"), + (["--spec_draft_device", "Vulkan1"], "Vulkan1"), (["-devd", "CUDA0,CPU"], "CUDA0,CPU"), # any GPU in the list conflicts # inline flag=value form. (["--spec-draft-device=Vulkan3"], "Vulkan3"), + (["--device_draft=Vulkan4"], "Vulkan4"), # last-wins: only the final draft-device value counts. (["--spec-draft-device", "CUDA1", "--spec-draft-device", "cpu"], None), (["--spec-draft-device", "cpu", "--spec-draft-device", "CUDA1"], "CUDA1"), From 8875e2b2f7283cc86afaacf33faa059b21b38f79 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:12:29 -0300 Subject: [PATCH 100/110] Converge PR 7188 review findings --- studio/backend/routes/inference.py | 9 +++- studio/backend/tests/test_gpu_selection.py | 41 +++++++++++++++++++ .../components/model-config-page.tsx | 27 +++++++----- studio/frontend/src/hooks/gpu-selection.ts | 2 +- studio/frontend/tests/gpu-selection.test.ts | 12 ++++-- tests/studio/test_model_picker_contracts.py | 8 +++- 6 files changed, 80 insertions(+), 19 deletions(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index cf499efab6..c64852c207 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4656,11 +4656,16 @@ async def _resolve_gguf_gpu_ids_for_request( device = get_device() lacks_gpu_lib = getattr(llama_backend, "_backend_lacks_gpu_lib", None) - if confirmed_diffusion and device != DeviceType.CUDA: + # ROCm is deliberately DeviceType.CUDA internally because it uses + # torch.cuda.*. Only the API label changes to "rocm", so this accepts both + # CUDA and ROCm physical IDs while rejecting device namespaces the + # diffusion runner cannot apply. + diffusion_physical_ids_supported = device == DeviceType.CUDA + if confirmed_diffusion and not diffusion_physical_ids_supported: raise HTTPException( status_code = 400, detail = ( - "GPU selection (gpu_ids) for DiffusionGemma requires CUDA. " + "GPU selection (gpu_ids) for DiffusionGemma requires CUDA or ROCm. " "Omit gpu_ids on this host." ), ) diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 3816129933..e90767e9c1 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -1167,6 +1167,47 @@ class TestRouteErrors(unittest.TestCase): self.assertEqual(resolved, [0, 1]) self.assertTrue(uses_vulkan_ordinals) + def test_diffusion_gpu_ids_accept_rocm_physical_path(self): + import utils.hardware as hardware_pkg + import utils.hardware.hardware as hardware_mod + + inference_route = _load_route_module( + "inference_route_module_for_diffusion_rocm_path_test", + "routes/inference.py", + ) + config = SimpleNamespace(is_gguf = True) + fake_backend = SimpleNamespace( + is_vulkan_build = lambda: False, + _backend_lacks_gpu_lib = lambda *a, **k: False, + ) + + with ( + patch.object(hardware_mod, "IS_ROCM", True), + patch.object(hardware_pkg, "get_device", return_value = DeviceType.CUDA), + patch.object( + hardware_mod, + "resolve_requested_gpu_ids", + return_value = [0, 1], + ), + patch.object( + inference_route, + "get_llama_cpp_backend", + return_value = fake_backend, + ), + patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread), + ): + self.assertEqual(hardware_mod._backend_label(DeviceType.CUDA), "rocm") + resolved, uses_vulkan_ordinals = asyncio.run( + inference_route._resolve_gguf_gpu_ids_for_request( + config, + [1, 0], + diffusion_kind = True, + ) + ) + + self.assertEqual(resolved, [0, 1]) + self.assertFalse(uses_vulkan_ordinals) + def test_inference_route_validates_gpu_ids_for_gguf(self): import utils.hardware.hardware as hardware_mod import utils.hardware as hardware_pkg diff --git a/studio/frontend/src/features/model-picker/components/model-config-page.tsx b/studio/frontend/src/features/model-picker/components/model-config-page.tsx index 72f40b2c12..03bec569aa 100644 --- a/studio/frontend/src/features/model-picker/components/model-config-page.tsx +++ b/studio/frontend/src/features/model-picker/components/model-config-page.tsx @@ -755,9 +755,24 @@ export function ModelConfigPage({ return; } let cancelled = false; + const settleWithoutMetadata = () => { + if (!cancelled) { + setFetchedStagedDims({ + key: contextFetchKey, + contextLength: null, + layerCount: null, + moeLayerCount: null, + isDiffusion: undefined, + }); + } + }; void (async () => { const preparedToken = await prepareHfTokenForUse(hfToken || null); - if (cancelled || !preparedToken.proceed) { + if (cancelled) { + return; + } + if (!preparedToken.proceed) { + settleWithoutMetadata(); return; } const dims = await fetchGgufStagedMetadata({ @@ -770,15 +785,7 @@ export function ModelConfigPage({ setFetchedStagedDims({ key: contextFetchKey, ...dims }); } })().catch(() => { - if (!cancelled) { - setFetchedStagedDims({ - key: contextFetchKey, - contextLength: null, - layerCount: null, - moeLayerCount: null, - isDiffusion: undefined, - }); - } + settleWithoutMetadata(); }); return () => { cancelled = true; diff --git a/studio/frontend/src/hooks/gpu-selection.ts b/studio/frontend/src/hooks/gpu-selection.ts index f1d3827c49..625ff3d5d1 100644 --- a/studio/frontend/src/hooks/gpu-selection.ts +++ b/studio/frontend/src/hooks/gpu-selection.ts @@ -101,9 +101,9 @@ export function reconcileGpuSelection( savedIndexKind === undefined ? "physical" : savedIndexKind; // Namespace knowledge is authoritative even while membership is unavailable. // This is what prevents a saved CUDA/ROCm ID from becoming Vulkan. + // A null namespace is only safe while discovery is also unresolved. if ( currentIndexKind !== undefined && - expectedIndexKind !== null && expectedIndexKind !== currentIndexKind ) { return { ids: null, indexKind: null }; diff --git a/studio/frontend/tests/gpu-selection.test.ts b/studio/frontend/tests/gpu-selection.test.ts index ef3da814a2..1a9f11704b 100644 --- a/studio/frontend/tests/gpu-selection.test.ts +++ b/studio/frontend/tests/gpu-selection.test.ts @@ -45,7 +45,7 @@ test("preserves every namespace while the device cache is genuinely cold", () => }); }); -test("known unavailable Vulkan clears physical picks but preserves compatible state", () => { +test("known unavailable Vulkan clears picks without a compatible namespace", () => { const context = resolveGpuSelectionContext([], false, "vulkan"); assert.deepEqual(context, { devices: [], @@ -61,7 +61,7 @@ test("known unavailable Vulkan clears physical picks but preserves compatible st indexKind: null, }); assert.deepEqual(reconcile(null, context.indexKind, context.ids), { - ids, + ids: null, indexKind: null, }); assert.deepEqual(reconcile("vulkan", context.indexKind, context.ids), { @@ -87,8 +87,12 @@ test("known Vulkan suppresses every DiffusionGemma pin while unavailable", () => test("recovered inventories filter matching picks and reject mismatched namespaces", () => { assert.deepEqual(reconcile(null, "vulkan", [1]), { - ids: [1], - indexKind: "vulkan", + ids: null, + indexKind: null, + }); + assert.deepEqual(reconcile(null, "physical", [0, 1]), { + ids: null, + indexKind: null, }); assert.deepEqual(reconcile("vulkan", "vulkan", [0]), { ids: [0], diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index d95dd2a897..c3f22ad866 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -668,8 +668,9 @@ def test_requested_gpu_pick_survives_fit_narrowing_and_namespace_changes(): assert "requestedGpuIdsFromResponse(resp)" in store gpu_selection = _read("hooks/gpu-selection.ts") assert 'savedIndexKind === undefined ? "physical" : savedIndexKind' in gpu_selection - assert "expectedIndexKind !== null" in gpu_selection + assert "currentIndexKind !== undefined" in gpu_selection assert "expectedIndexKind !== currentIndexKind" in gpu_selection + assert "A null namespace is only safe while discovery is also unresolved" in gpu_selection assert ( "Namespace knowledge is authoritative even while membership is unavailable" in gpu_selection ) @@ -718,7 +719,10 @@ def test_model_config_prepares_hf_token_before_gguf_metadata_preflight(): prepare = effect.index("prepareHfTokenForUse(hfToken || null)") metadata = effect.index("fetchGgufStagedMetadata({", prepare) assert prepare < metadata - assert "if (cancelled || !preparedToken.proceed)" in effect + assert "if (!preparedToken.proceed)" in effect + cancel = effect.index("if (!preparedToken.proceed)") + assert "settleWithoutMetadata();" in effect[cancel:metadata] + assert effect.count("settleWithoutMetadata();") == 2 assert "hf_token: preparedToken.token" in effect assert "hf_token: hfToken" not in effect From 8fd2d5cad7dbfdc2b3e6c192f1199ab27c5df494 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:32:04 -0300 Subject: [PATCH 101/110] Honor backend opt-outs for Intel Vulkan routing --- .../tests/test_install_resolve_prebuilt.py | 33 +++++++++++++++++++ studio/install_llama_prebuilt.py | 7 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index ae1d95ba90..5754b69713 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -662,6 +662,39 @@ def test_route_to_vulkan_prebuilt_cpu_fallback_wins(): assert routed is host +@pytest.mark.parametrize("backend", ["hip", "rocm", "cpu"]) +def test_explicit_non_vulkan_backend_suppresses_intel_auto_route(monkeypatch, backend): + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend) + host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) + + routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt( + host, FORK, "b9596-mix-abc", force_cpu = False + ) + + assert routed is host + assert repo == FORK + assert tag == "b9596-mix-abc" + assert persist is None + + +@pytest.mark.parametrize("backend", ["hip", "rocm", "cpu"]) +def test_non_vulkan_backend_argument_suppresses_intel_auto_route(backend): + host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) + + routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt( + host, + FORK, + "b9596-mix-abc", + force_cpu = False, + llama_backend = backend, + ) + + assert routed is host + assert repo == FORK + assert tag == "b9596-mix-abc" + assert persist is None + + @pytest.mark.parametrize("cpu_flag", ["--cpu-fallback", "--force-cpu"]) def test_resolve_prebuilt_cpu_fallback_overrides_intel_vulkan(monkeypatch, capsys, cpu_flag): """Either CPU flag via CLI must suppress Vulkan even on an Intel GPU host: both diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 9234e08f05..dd893fe583 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6444,7 +6444,12 @@ def _route_to_vulkan_prebuilt( ) # No PHYSICAL NVIDIA, not merely no usable one: Vulkan ignores CUDA_VISIBLE_DEVICES, so # auto-routing a host that hides its NVIDIA card would let it grab the reserved GPU. - auto_intel = host.has_intel_gpu and not host.has_physical_nvidia and not host.has_rocm + auto_intel = ( + explicit_backend is None + and host.has_intel_gpu + and not host.has_physical_nvidia + and not host.has_rocm + ) if force_cpu or not (forced or auto_intel or auto_no_hip): return host, published_repo, published_release_tag, None if host.is_macos: From ab72faa8fac3f0a74fb0046a7481a3a2151e46ea Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:59:24 -0300 Subject: [PATCH 102/110] Retry cold system discovery for GPU selection --- studio/frontend/src/hooks/system-discovery.ts | 21 +++++++++++ studio/frontend/src/hooks/use-system.ts | 21 ++++++++--- .../frontend/tests/system-discovery.test.ts | 36 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 studio/frontend/src/hooks/system-discovery.ts create mode 100644 studio/frontend/tests/system-discovery.test.ts diff --git a/studio/frontend/src/hooks/system-discovery.ts b/studio/frontend/src/hooks/system-discovery.ts new file mode 100644 index 0000000000..30c331ddb5 --- /dev/null +++ b/studio/frontend/src/hooks/system-discovery.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +interface InferenceGpuDiscovery { + available: boolean; + backend?: string; +} + +export function shouldRetrySystemDiscovery( + cacheIsCold: boolean, + inferenceGpu: InferenceGpuDiscovery | undefined, + retrySubscribers: number, +): boolean { + if (retrySubscribers <= 0) { + return false; + } + if (cacheIsCold) { + return true; + } + return inferenceGpu?.backend === "vulkan" && !inferenceGpu.available; +} diff --git a/studio/frontend/src/hooks/use-system.ts b/studio/frontend/src/hooks/use-system.ts index d5f84f2ef8..e6a6b75617 100644 --- a/studio/frontend/src/hooks/use-system.ts +++ b/studio/frontend/src/hooks/use-system.ts @@ -3,6 +3,7 @@ import { authFetch } from "@/features/auth"; import { useEffect, useState } from "react"; +import { shouldRetrySystemDiscovery } from "./system-discovery"; export interface GpuDevice { index?: number; @@ -118,13 +119,23 @@ export function subscribeSystemInfo( } function scheduleVulkanRetry(): void { - const inferenceGpu = cachedSystem?.inference_gpu; if ( - vulkanRetrySubscribers === 0 || - vulkanRetryId !== null || - inferenceGpu?.backend !== "vulkan" || - inferenceGpu.available + !shouldRetrySystemDiscovery( + cachedSystem === null, + cachedSystem?.inference_gpu, + vulkanRetrySubscribers, + ) ) { + // A cold subscription schedules before its first request settles. Cancel + // that pending retry as soon as discovery succeeds with a usable inventory + // or a non-Vulkan backend. + if (vulkanRetryId !== null) { + window.clearTimeout(vulkanRetryId); + vulkanRetryId = null; + } + return; + } + if (vulkanRetryId !== null) { return; } vulkanRetryId = window.setTimeout(() => { diff --git a/studio/frontend/tests/system-discovery.test.ts b/studio/frontend/tests/system-discovery.test.ts new file mode 100644 index 0000000000..ca69ef8467 --- /dev/null +++ b/studio/frontend/tests/system-discovery.test.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldRetrySystemDiscovery } from "../src/hooks/system-discovery.ts"; + +test("retries a cold system cache while an interested subscriber exists", () => { + assert.equal(shouldRetrySystemDiscovery(true, undefined, 1), true); + assert.equal(shouldRetrySystemDiscovery(true, undefined, 0), false); +}); + +test("retries only an unavailable Vulkan inventory after discovery", () => { + assert.equal( + shouldRetrySystemDiscovery( + false, + { backend: "vulkan", available: false }, + 1, + ), + true, + ); + assert.equal( + shouldRetrySystemDiscovery( + false, + { backend: "vulkan", available: true }, + 1, + ), + false, + ); + assert.equal( + shouldRetrySystemDiscovery(false, { backend: "cuda", available: false }, 1), + false, + ); + assert.equal(shouldRetrySystemDiscovery(false, undefined, 1), false); +}); From eac32820ddaa62f57b34130bb2d5a5494813dead Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 28 Jul 2026 21:56:29 +0000 Subject: [PATCH 103/110] Probe structlog with find_spec instead of a bare import The availability check imported the module purely for its side effect, so the import-hoist verifier flagged it as an added-but-unused import and failed Source lint. find_spec answers the same question without binding a name. --- tests/studio/load_freeze/test_load_orchestrator.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/studio/load_freeze/test_load_orchestrator.py b/tests/studio/load_freeze/test_load_orchestrator.py index 0ff769fc78..2957150a2c 100644 --- a/tests/studio/load_freeze/test_load_orchestrator.py +++ b/tests/studio/load_freeze/test_load_orchestrator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import importlib.util import os import re import socket @@ -57,9 +58,7 @@ sys.modules.setdefault("loggers", _loggers_stub) # blew up with AttributeError, but only when this file was collected first, so the # same test passed alone and failed under `pytest tests/studio`. Only stub when the # package is genuinely missing, and give the stub the attribute those callers use. -try: - import structlog # noqa: E402, F401 -except ImportError: +if importlib.util.find_spec("structlog") is None: _structlog_stub = types.ModuleType("structlog") _structlog_stub.get_logger = lambda *args, **kwargs: _logging.getLogger( args[0] if args else "structlog" From a0b6c33aeb24acd3d81e407751939d04bfe9be9d Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Tue, 28 Jul 2026 23:21:47 +0000 Subject: [PATCH 104/110] Brace the PowerShell variable in the setup.ps1 backend-choice probe --- studio/backend/tests/test_setup_llama_cpp_backend.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 48625dca54..118ec0a5ce 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -286,7 +286,9 @@ def test_llama_backend_source_choice_in_setup_ps1(backend, force_vulkan, expecte "pwsh", "-NoProfile", "-Command", - f'{normalize}\n"RESULT:$sourceLlamaBackend:$explicitVulkanSourceBuild"', + # Brace the name: PowerShell reads "$var:" as a scope qualifier and + # fails to parse, so the probe never ran on a host that has pwsh. + f'{normalize}\n"RESULT:${{sourceLlamaBackend}}:$explicitVulkanSourceBuild"', ], capture_output = True, text = True, From a0c879a6acce591ef249258f1fe426ed8b1156a8 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 01:21:40 +0000 Subject: [PATCH 105/110] Leave an existing structlog entry alone in the availability probe find_spec raises ValueError on a module already in sys.modules whose __spec__ is None, which is what a bare types.ModuleType stub is. Check sys.modules first so anything already present, real or stubbed, is left untouched and only a genuinely absent package gets stubbed. --- tests/studio/load_freeze/test_load_orchestrator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/studio/load_freeze/test_load_orchestrator.py b/tests/studio/load_freeze/test_load_orchestrator.py index 2957150a2c..5cec40653b 100644 --- a/tests/studio/load_freeze/test_load_orchestrator.py +++ b/tests/studio/load_freeze/test_load_orchestrator.py @@ -58,7 +58,11 @@ sys.modules.setdefault("loggers", _loggers_stub) # blew up with AttributeError, but only when this file was collected first, so the # same test passed alone and failed under `pytest tests/studio`. Only stub when the # package is genuinely missing, and give the stub the attribute those callers use. -if importlib.util.find_spec("structlog") is None: +# Guard on sys.modules FIRST: another test module may have parked its own bare +# stub, and find_spec() raises ValueError on a module whose __spec__ is None. +# Anything already there (real or stub) is left alone; only a genuinely absent +# package gets stubbed. +if "structlog" not in sys.modules and importlib.util.find_spec("structlog") is None: _structlog_stub = types.ModuleType("structlog") _structlog_stub.get_logger = lambda *args, **kwargs: _logging.getLogger( args[0] if args else "structlog" From 0e0b078a3aff0899f16c506ea814e8bb777a47d1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 03:46:28 +0000 Subject: [PATCH 106/110] Consolidate duplicated Vulkan selector tests without changing behaviour Three of the tests added for the llama.cpp backend selector asserted on the same two helpers with the same inputs, and two more differed only in whether the backend arrived from the environment or from --llama-backend. Fold them into the parametrized cases that already covered those helpers: - test_llama_cpp_backend_env_requests_vulkan, test_llama_cpp_backend_auto_does_not_trigger_vulkan and test_hip_backend_env_opts_out_of_vulkan become rows on test_force_vulkan_requested_accepts_public_selector_and_legacy_alias, which now also asserts llama_backend_from_env() alongside force_vulkan_requested(). The unset case and the whitespace-padded HIP/ROCM values keep their coverage as new rows. - test_explicit_non_vulkan_backend_suppresses_intel_auto_route and test_non_vulkan_backend_argument_suppresses_intel_auto_route become one test parametrized over the env and argument paths. - test_unclassified_gguf_without_pin_keeps_the_torch_budget and test_unclassified_gguf_with_pin_refuses_to_budget shared the same four patches and differed only in the pin, so they become subtests of one case. No source changes and no assertion is dropped. Each behaviour was re-checked by reverting the source hunk it guards and confirming the surviving test fails on a value. --- .../tests/test_chat_load_during_training.py | 93 ++++++++----------- .../tests/test_install_resolve_prebuilt.py | 80 ++++++---------- 2 files changed, 65 insertions(+), 108 deletions(-) diff --git a/studio/backend/tests/test_chat_load_during_training.py b/studio/backend/tests/test_chat_load_during_training.py index 6eec282346..3ea10fd745 100644 --- a/studio/backend/tests/test_chat_load_during_training.py +++ b/studio/backend/tests/test_chat_load_during_training.py @@ -659,60 +659,45 @@ class TestChatLoadGuardRoute(unittest.TestCase): ) self.assertEqual(captured[0]["vulkan_free_vram_gb"], {1: 2.0}) - def test_unclassified_gguf_without_pin_keeps_the_torch_budget(self): - # An uncached remote GGUF that neither the header nor the name can - # classify may still turn out to be the CUDA-only diffusion runner, so - # the Vulkan free-VRAM map cannot stand in for it. With no gpu_ids there - # is no ordinal to mis-map, so the guard must fall back to the torch view - # (vulkan_free_vram_gb = None) instead of handing down an empty map, which - # reads as "no free VRAM anywhere" and 409s every such load during - # training. - captured = [] - config = SimpleNamespace(is_gguf = True) - with ( - patch.object(self.route, "_classify_diffusion_gguf", return_value = None), - patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), - patch.object( - self.route.LlamaCppBackend, - "_find_llama_server_binary", - return_value = "/tmp/llama-server", - ), - patch.object(self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True), - ): - self._guard( - config = config, - captured = captured, - training_active = True, - decision = (True, {"mode": "single_device"}), - ) - self.assertEqual(len(captured), 1) - self.assertIsNone(captured[0]["vulkan_free_vram_gb"]) - - def test_unclassified_gguf_with_pin_refuses_to_budget(self): - # An explicit pin on an unclassified GGUF is the opposite case: the - # ordinal belongs to exactly one of the two device namespaces and neither - # can stand in for the other, so the guard must fail closed. - captured = [] - config = SimpleNamespace(is_gguf = True) - with ( - patch.object(self.route, "_classify_diffusion_gguf", return_value = None), - patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), - patch.object( - self.route.LlamaCppBackend, - "_find_llama_server_binary", - return_value = "/tmp/llama-server", - ), - patch.object(self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True), - ): - self._guard( - config = config, - captured = captured, - training_active = True, - decision = (True, {"mode": "gguf_vulkan"}), - requested_gpu_ids = [1], - ) - self.assertEqual(len(captured), 1) - self.assertEqual(captured[0]["vulkan_free_vram_gb"], {}) + def test_unclassified_gguf_budget_depends_on_the_pin(self): + # An uncached remote GGUF that neither the header nor the name can classify + # may still turn out to be the CUDA-only diffusion runner, so the Vulkan + # free-VRAM map cannot stand in for it. The pin decides what that means: + cases = ( + # No gpu_ids -> no ordinal to mis-map, so fall back to the torch view + # (None) rather than an empty map, which would read as "no free VRAM + # anywhere" and 409 every such load during training. + (None, "single_device", None), + # An explicit pin is the opposite case: the ordinal belongs to exactly + # one of the two device namespaces and neither can stand in for the + # other, so the guard must fail closed with an empty map. + ([1], "gguf_vulkan", {}), + ) + for requested_gpu_ids, mode, expected in cases: + with self.subTest(requested_gpu_ids = requested_gpu_ids): + captured = [] + config = SimpleNamespace(is_gguf = True) + with ( + patch.object(self.route, "_classify_diffusion_gguf", return_value = None), + patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5), + patch.object( + self.route.LlamaCppBackend, + "_find_llama_server_binary", + return_value = "/tmp/llama-server", + ), + patch.object( + self.route.LlamaCppBackend, "_is_vulkan_backend", return_value = True + ), + ): + self._guard( + config = config, + captured = captured, + training_active = True, + decision = (True, {"mode": mode}), + requested_gpu_ids = requested_gpu_ids, + ) + self.assertEqual(len(captured), 1) + self.assertEqual(captured[0]["vulkan_free_vram_gb"], expected) def test_refuses_with_headroom_number(self): info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"} diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 5754b69713..6c8efa8d04 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -552,24 +552,28 @@ def test_linux_vulkan_health_glob_matches_bare_cpu_lib(): @pytest.mark.parametrize( - "backend, legacy, expected", + "backend, legacy, expected_backend, expected", [ - ("vulkan", None, True), - (" VULKAN ", None, True), - ("auto", "1", True), - (None, "true", True), - (None, "on", True), - ("auto", None, False), - ("cpu", "0", False), - ("hip", "1", False), - ("rocm", "on", False), + (None, None, None, False), + ("vulkan", None, "vulkan", True), + (" VULKAN ", None, "vulkan", True), + ("auto", "1", None, True), + (None, "true", None, True), + (None, "on", None, True), + ("auto", None, None, False), + ("cpu", "0", "cpu", False), + ("hip", "1", "hip", False), + ("rocm", "on", "hip", False), + (" HIP ", "1", "hip", False), + (" ROCM ", "1", "hip", False), ], ) def test_force_vulkan_requested_accepts_public_selector_and_legacy_alias( - monkeypatch, backend, legacy, expected + monkeypatch, backend, legacy, expected_backend, expected ): # UNSLOTH_LLAMA_CPP_BACKEND is the public selector; a recognized non-vulkan # value is authoritative, so it opts out even against a stale legacy alias. + # auto/unset/unknown leave the backend unpinned so selection stays automatic. for name, value in ( ("UNSLOTH_LLAMA_CPP_BACKEND", backend), ("UNSLOTH_FORCE_VULKAN", legacy), @@ -578,6 +582,7 @@ def test_force_vulkan_requested_accepts_public_selector_and_legacy_alias( monkeypatch.delenv(name, raising = False) else: monkeypatch.setenv(name, value) + assert ilp.llama_backend_from_env() == expected_backend assert ilp.force_vulkan_requested() is expected @@ -662,31 +667,20 @@ def test_route_to_vulkan_prebuilt_cpu_fallback_wins(): assert routed is host +@pytest.mark.parametrize("via", ["env", "argument"]) @pytest.mark.parametrize("backend", ["hip", "rocm", "cpu"]) -def test_explicit_non_vulkan_backend_suppresses_intel_auto_route(monkeypatch, backend): - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend) +def test_non_vulkan_backend_suppresses_intel_auto_route(monkeypatch, backend, via): + # The selector suppresses the Intel auto-route the same way whether it arrives + # from the environment or from --llama-backend. + kwargs = {} + if via == "env": + monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend) + else: + kwargs["llama_backend"] = backend host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt( - host, FORK, "b9596-mix-abc", force_cpu = False - ) - - assert routed is host - assert repo == FORK - assert tag == "b9596-mix-abc" - assert persist is None - - -@pytest.mark.parametrize("backend", ["hip", "rocm", "cpu"]) -def test_non_vulkan_backend_argument_suppresses_intel_auto_route(backend): - host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) - - routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt( - host, - FORK, - "b9596-mix-abc", - force_cpu = False, - llama_backend = backend, + host, FORK, "b9596-mix-abc", force_cpu = False, **kwargs ) assert routed is host @@ -1373,28 +1367,6 @@ def test_direct_upstream_windows_amd_legacy_gfx_routes_to_vulkan(): assert plan.attempts[0].install_kind == "windows-vulkan" -def test_llama_cpp_backend_env_requests_vulkan(monkeypatch): - assert ilp.llama_backend_from_env() is None - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "vulkan") - assert ilp.llama_backend_from_env() == "vulkan" - assert ilp.force_vulkan_requested() is True - - -def test_llama_cpp_backend_auto_does_not_trigger_vulkan(monkeypatch): - monkeypatch.delenv("UNSLOTH_FORCE_VULKAN", raising = False) - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", "auto") - assert ilp.llama_backend_from_env() is None - assert ilp.force_vulkan_requested() is False - - -@pytest.mark.parametrize("backend", ["hip", "rocm", " HIP ", " ROCM "]) -def test_hip_backend_env_opts_out_of_vulkan(monkeypatch, backend): - monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend) - monkeypatch.setenv("UNSLOTH_FORCE_VULKAN", "1") - assert ilp.llama_backend_from_env() == "hip" - assert ilp.force_vulkan_requested() is False - - def test_hip_backend_env_suppresses_auto_vulkan_fallback_on_unsupported_gfx(monkeypatch): # An explicit HIP choice keeps HIP on an unsupported gfx target instead of # silently substituting Vulkan. From 090b5f6f8e62b34a97b169314cedc2c8c40da5f5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 06:16:53 +0000 Subject: [PATCH 107/110] Keep the standing GPU mode on diffusion loads and the ARM64 Vulkan fallback for PR #7188 --- .../tests/test_install_resolve_prebuilt.py | 88 +++++++++++++++++++ .../model-config/apply-per-model-config.ts | 10 ++- studio/install_llama_prebuilt.py | 14 +++ tests/studio/test_model_picker_contracts.py | 26 ++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index 6c8efa8d04..b29df53a0a 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -656,6 +656,94 @@ def test_route_to_vulkan_prebuilt_preserves_explicit_upstream_pin(): assert tag == "b9596" +def _linux_arm64_vulkan_host(): + return _host( + system = "Linux", + machine = "aarch64", + is_linux = True, + is_arm64 = True, + has_intel_gpu = True, + ) + + +def test_route_to_vulkan_prebuilt_linux_arm64_falls_back_to_upstream(): + # The fork ships no ARM64 Vulkan bundle, so a forced-Vulkan Linux ARM64 host + # must keep planning against upstream (which publishes + # llama--bin-ubuntu-vulkan-arm64.tar.gz). The fork pin is dropped because + # the two repos use different tag namespaces. + routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt( + _linux_arm64_vulkan_host(), + FORK, + "b9596-mix-abc", + force_cpu = False, + llama_backend = "vulkan", + ) + + assert repo == UPSTREAM + assert tag == "" + assert persist == "vulkan" + assert routed.has_intel_gpu is True + + +def test_forced_vulkan_linux_arm64_still_resolves_a_vulkan_bundle(): + # End to end for the routing above: without it the fork planner yields only the + # ARM64 CPU attempt, and the strict-Vulkan filter then leaves nothing to install. + routed, repo, _tag, _persist = ilp._route_to_vulkan_prebuilt( + _linux_arm64_vulkan_host(), + FORK, + "", + force_cpu = False, + llama_backend = "vulkan", + ) + fork_attempts = ilp._linux_published_attempts( + routed, _published_vulkan_bundle("linux-vulkan", "linux-arm64") + ) + assert [attempt.install_kind for attempt in fork_attempts] == ["linux-arm64"] + + release = _upstream_release( + "b9925", + ["llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz", "llama-b9925-bin-ubuntu-arm64.tar.gz"], + ) + plan = ilp.direct_upstream_release_plan(release, routed, repo, "latest") + filtered = ilp._vulkan_only_release_plans([plan]) + + assert [attempt.name for attempt in filtered[0].attempts] == [ + "llama-b9925-bin-ubuntu-vulkan-arm64.tar.gz" + ] + assert filtered[0].attempts[0].repo == UPSTREAM + + +def test_route_to_vulkan_prebuilt_linux_arm64_keeps_an_explicit_repo_override(): + # A hand-picked --published-repo is the user's call; only the default fork + # repo is rerouted. + other = "someone/llama.cpp" + _routed, repo, tag, _persist = ilp._route_to_vulkan_prebuilt( + _linux_arm64_vulkan_host(), + other, + "b9596", + force_cpu = False, + llama_backend = "vulkan", + ) + + assert repo == other + assert tag == "b9596" + + +def test_route_to_vulkan_prebuilt_linux_x86_64_keeps_the_fork_bundle(): + # Only ARM64 lacks a fork Vulkan bundle; x86_64 must stay on the fork release + # so it keeps the DiffusionGemma visual server. + _routed, repo, tag, _persist = ilp._route_to_vulkan_prebuilt( + _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True), + FORK, + "b9596-mix-abc", + force_cpu = False, + llama_backend = "vulkan", + ) + + assert repo == FORK + assert tag == "b9596-mix-abc" + + def test_route_to_vulkan_prebuilt_cpu_fallback_wins(): # --cpu-fallback suppresses Vulkan routing even for an Intel host. host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) diff --git a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts index c00d15c06f..1cddfbe796 100644 --- a/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts +++ b/studio/frontend/src/features/model-picker/model-config/apply-per-model-config.ts @@ -60,7 +60,15 @@ export function applyPerModelConfigToRuntime( // The per-GPU split ratio is never remembered, so it always resets. The GPU // pick is reconciled against the GPUs present now (a saved [1] on a 1-GPU // host would otherwise be sent and rejected). - gpuMemoryMode: config.gpuMemoryMode ?? readPersistedGpuMemoryMode(), + // A diffusion config is sanitized to gpuMemoryMode "auto" because the mode + // does not apply to it, not because the user chose Auto. Writing that into + // the live standing preference would strand the session on Auto: the load + // itself skips saveGpuMemoryMode for diffusion, so nothing restores it, and + // the next ordinary GGUF loaded without its own config sends this value and + // persists it over the user's Manual. Keep the standing choice instead. + gpuMemoryMode: options.isDiffusion + ? readPersistedGpuMemoryMode() + : (config.gpuMemoryMode ?? readPersistedGpuMemoryMode()), gpuLayers: config.gpuLayers ?? GPU_LAYERS_AUTO, nCpuMoe: config.nCpuMoe ?? 0, splitRatio: null, diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index dd893fe583..6cb9e589ca 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -6484,6 +6484,20 @@ def _route_to_vulkan_prebuilt( else: log("Intel GPU detected; installing the Vulkan llama.cpp prebuilt") persist_backend = None + # The fork manifest's Vulkan app bundles are x64 only, and the architecture + # filter in published_asset_choice_for_kind rejects them for an ARM64 host, so + # the fork planner returns no Vulkan attempt at all there. Strict Vulkan + # filtering then drops the ARM64 CPU attempt too and the install resolves to + # nothing. Upstream does publish llama--bin-ubuntu-vulkan-arm64.tar.gz, so + # keep routing Linux ARM64 there (Windows arm64 exits above via + # _has_no_vulkan_prebuilt, macOS via the Metal branch). + if (published_repo or DEFAULT_PUBLISHED_REPO) == DEFAULT_PUBLISHED_REPO and ( + host.is_linux and host.is_arm64 + ): + # Fork and upstream use different tag namespaces (fork b9596-mix- vs + # upstream b9596), so carrying a fork pin over would make the upstream + # resolver query a release that does not exist. + return host, UPSTREAM_REPO, "", persist_backend return host, published_repo, published_release_tag, persist_backend diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 707934c04b..74ababffb2 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -294,6 +294,32 @@ def test_diffusion_load_paths_disable_tensor_parallel(): assert autoload.count("tensor_parallel: effectiveTensorParallel") == 2 +def test_diffusion_load_keeps_the_standing_gpu_memory_mode(): + """A diffusion config is sanitized to gpuMemoryMode "auto" because the mode does + not apply to it, not because the user picked Auto. Applying that sanitized value + to the runtime store would strand the session on Auto: persistGpuMemoryModeOnLoad + deliberately skips diffusion responses, so nothing writes the standing preference + back, and the next ordinary GGUF loaded without its own config sends the stale + "auto" and persists it over the user's Manual.""" + apply = " ".join(_read("features/model-picker/model-config/apply-per-model-config.ts").split()) + assert ( + "gpuMemoryMode: options.isDiffusion ? readPersistedGpuMemoryMode() : " + "(config.gpuMemoryMode ?? readPersistedGpuMemoryMode())," in apply + ) + # The unconditional form is what leaked the sanitized mode into the store. + assert "gpuMemoryMode: config.gpuMemoryMode ?? readPersistedGpuMemoryMode()," not in apply + + # The other half of the contract: the diffusion load itself must not persist. + store = " ".join(_read("features/chat/stores/chat-runtime-store.ts").split()) + assert "if (resp.is_gguf && !resp.is_diffusion) saveGpuMemoryMode(mode);" in store + + # And the sanitizer that produces the "auto" this guards against still runs. + page = " ".join( + _read("features/model-picker/components/model-config-page.tsx").split() + ) + assert "withoutUnsupportedDiffusionSettings(committedConfig, gpuIndexKind)" in page + + def test_active_native_gguf_metadata_uses_path_token(): src = _read("features/model-picker/components/model-config-page.tsx") assert "(isActiveModel ? activeNativePathToken : null)" in src From 987f22414241e229993a596538d05e871617f24a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:20:55 +0000 Subject: [PATCH 108/110] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/test_model_picker_contracts.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 74ababffb2..4100dd8ea8 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -314,9 +314,7 @@ def test_diffusion_load_keeps_the_standing_gpu_memory_mode(): assert "if (resp.is_gguf && !resp.is_diffusion) saveGpuMemoryMode(mode);" in store # And the sanitizer that produces the "auto" this guards against still runs. - page = " ".join( - _read("features/model-picker/components/model-config-page.tsx").split() - ) + page = " ".join(_read("features/model-picker/components/model-config-page.tsx").split()) assert "withoutUnsupportedDiffusionSettings(committedConfig, gpuIndexKind)" in page From d6f374082da49b3dc916907b374e5134e4ee9990 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 08:43:07 +0000 Subject: [PATCH 109/110] Make the Intel auto-route guard test able to fail and stop the ps1 harness doubling --force-cpu test_non_vulkan_backend_suppresses_intel_auto_route ran on Linux x86_64, where dropping the explicit_backend guard changes only a log line, so the test passed either way. Move it to Linux ARM64 + Intel GPU, the host where the guard decides the routed repo, and assert that value. _run_ps1 composed the --force-cpu snippet twice: it is already inside the normalized block the helper extracts. The substring assertion hid the duplicate. Compose it once and compare the whole argv. setup.ps1 appends the flag once and is unchanged. --- .../tests/test_install_resolve_prebuilt.py | 13 +++++++++---- .../tests/test_setup_llama_cpp_backend.py | 18 ++++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index b29df53a0a..cb1ce8b63b 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -759,21 +759,26 @@ def test_route_to_vulkan_prebuilt_cpu_fallback_wins(): @pytest.mark.parametrize("backend", ["hip", "rocm", "cpu"]) def test_non_vulkan_backend_suppresses_intel_auto_route(monkeypatch, backend, via): # The selector suppresses the Intel auto-route the same way whether it arrives - # from the environment or from --llama-backend. + # from the environment or from --llama-backend. Pinned to Linux ARM64, the one + # host where the suppression shows up in a returned VALUE: without it the Intel + # auto-route fires, falls through to the ARM64 fork -> upstream reroute and + # hands back UPSTREAM with the fork pin dropped. On x86_64 the auto-route + # rewrites neither host nor repo, so only a log line differs and the same case + # there cannot fail. kwargs = {} if via == "env": monkeypatch.setenv("UNSLOTH_LLAMA_CPP_BACKEND", backend) else: kwargs["llama_backend"] = backend - host = _host(is_linux = True, is_x86_64 = True, has_intel_gpu = True) + host = _linux_arm64_vulkan_host() routed, repo, tag, persist = ilp._route_to_vulkan_prebuilt( host, FORK, "b9596-mix-abc", force_cpu = False, **kwargs ) assert routed is host - assert repo == FORK - assert tag == "b9596-mix-abc" + assert repo == FORK, repo + assert tag == "b9596-mix-abc", tag assert persist is None diff --git a/studio/backend/tests/test_setup_llama_cpp_backend.py b/studio/backend/tests/test_setup_llama_cpp_backend.py index 118ec0a5ce..d9f78a684b 100644 --- a/studio/backend/tests/test_setup_llama_cpp_backend.py +++ b/studio/backend/tests/test_setup_llama_cpp_backend.py @@ -300,15 +300,15 @@ def test_llama_backend_source_choice_in_setup_ps1(backend, force_vulkan, expecte def _run_ps1(value: str | None) -> str: - # The override is normalized (assign + warn) at the top of the prebuilt block and - # applied to $prebuiltArgs lower down; compose both real snippets. + # One snippet, not two: NORMALIZE already spans the whole if/elseif chain up to + # and including the "Ignoring UNSLOTH_LLAMA_CPP_BACKEND" warn, so the --force-cpu + # append is inside it. Composing the apply snippet on top of it made the harness + # emit the flag twice while setup.ps1 appends it once. normalize = _ps1_search( r"\$llamaBackend = \$sourceLlamaBackend.*?Ignoring UNSLOTH_LLAMA_CPP_BACKEND.*?\n\s*\}", re.DOTALL, ) - apply_flag = _ps1_search( - r'if \(\$llamaBackend -eq "cpu"\) \{\s*\$prebuiltArgs \+= "--force-cpu"\s*\}' - ) + assert normalize.count('$prebuiltArgs += "--force-cpu"') == 1, normalize env = { k: v for k, v in os.environ.items() @@ -320,7 +320,7 @@ def _run_ps1(value: str | None) -> str: "$prebuiltArgs = @()\n" '$sourceLlamaBackend = "$($env:UNSLOTH_LLAMA_CPP_BACKEND)".Trim().ToLowerInvariant()\n' '$sourceLegacyForceVulkan = "$($env:UNSLOTH_FORCE_VULKAN)".Trim().ToLowerInvariant()\n' - f'{normalize}\n{apply_flag}\n"ARGS:" + ($prebuiltArgs -join ",")' + f'{normalize}\n"ARGS:" + ($prebuiltArgs -join ",")' ) out = subprocess.run( ["pwsh", "-NoProfile", "-Command", harness], @@ -336,8 +336,10 @@ def _run_ps1(value: str | None) -> str: @pytest.mark.parametrize("value", ["cpu", "CPU", "Cpu", " cpu ", "CPU\t"]) def test_ps1_backend_cpu_appends_flag(value): out = _run_ps1(value) - assert "--force-cpu" in out - assert "Ignoring" not in out + # The whole argv, not a substring: an `in` check passed while the harness was + # emitting --force-cpu twice. setup.ps1 appends it exactly once, and nothing + # else, for a cpu override. + assert out.strip() == "ARGS:--force-cpu" @_SKIP_NO_PWSH From 0573a0e0f02a91c64b1a8043739489f9927f242e Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 09:07:34 +0000 Subject: [PATCH 110/110] Dot-source Get-HostMachineArch in the setup.ps1 Pester suite #7549 taught Test-VCRedistInstalled to consult the host architecture before trusting the System32 DLL. The Pester suite dot-sources a fixed list of functions out of setup.ps1 one at a time, and that list did not gain the helper, so the two clean-box cases (registry miss, and an old sub-14.20 redist) throw "Get-HostMachineArch is not recognized" instead of returning false. The registry hit returns early, which is why the other cases pass. #7597 made exactly this fix to the sibling list in the VC++ round-trip job but left the Pester suite alone. Verified with pwsh 7.6 + Pester: 3 failed before, 31 passed 0 failed after. --- tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 b/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 index 35c18770a5..1db715c2f1 100644 --- a/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 +++ b/tests/studio_setup_ps1/Studio.Setup.Vs2026.Tests.ps1 @@ -27,10 +27,16 @@ BeforeAll { if (-not $script:SetupPs1) { throw "Could not locate studio/setup.ps1 (set SETUP_PS1_PATH)." } Write-Host "setup.ps1 under test: $script:SetupPs1" + # Test-VCRedistInstalled consults Get-HostMachineArch before trusting the + # System32 DLL, and dot-sourcing functions one at a time does not carry that + # callee in. The registry hit returns early, so only the clean-box cases reach + # the call and fail there with "Get-HostMachineArch is not recognized"; the + # same list in the VC++ round-trip job gained the helper in #7597. foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools', 'Get-VcBuildCustomizationsDir', 'Test-CmakeSupportsGenerator', 'Get-CmakeVersion', 'Test-CmakeListsGenerator', 'Test-CmakeCanDriveGenerator', 'Get-FallbackVsGenerator', - 'Ensure-BuildToolsForLlamaSourceBuild', 'Test-VCRedistInstalled')) { + 'Ensure-BuildToolsForLlamaSourceBuild', 'Get-HostMachineArch', + 'Test-VCRedistInstalled')) { $src = Get-FunctionSource -Path $script:SetupPs1 -Name $fn if (-not $src) { throw "Function '$fn' not found in $script:SetupPs1 - cannot test the real code." } . ([scriptblock]::Create($src))