diff --git a/studio/backend/core/inference/_vulkan_probe.py b/studio/backend/core/inference/_vulkan_probe.py index 706346daad..4bfefc21ce 100644 --- a/studio/backend/core/inference/_vulkan_probe.py +++ b/studio/backend/core/inference/_vulkan_probe.py @@ -6,12 +6,14 @@ 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. -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 -it to reserve absolute headroom on a discrete card (parity with the CUDA/ROCm -fit) and ignores it for an iGPU, whose "VRAM" is shared system RAM. +``\\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 it to reserve absolute headroom on a discrete card (parity +with the CUDA/ROCm fit) and ignores it for an iGPU, whose "VRAM" is shared +system RAM. ``name`` is ggml's device description (the marketing name, e.g. +"AMD Radeon RX 9070 XT"); empty when the registry lookup fails. Uses only the standard library so it stays runnable as a bare script. """ @@ -24,15 +26,30 @@ 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 _igpu_flags_and_names(base, lib, count: int) -> tuple[list[bool], list[str]]: + """Per-device integrated-GPU flags and descriptions via ggml's backend 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. Returns all-False / empty-name on any + failure so the reader never over-caps a discrete card and the memory + readings still get through. """ flags = [False] * count + names = [""] * count + + # The name lookup is bound OUTSIDE the type-detection try: a ggml-base + # without ggml_backend_dev_description (older/custom build) must degrade to + # unnamed devices, not abort before the iGPU flags are read (which would + # count an iGPU's shared RAM as VRAM). + describe = None + try: + base.ggml_backend_dev_description.restype = ctypes.c_char_p + base.ggml_backend_dev_description.argtypes = [ctypes.c_void_p] + describe = base.ggml_backend_dev_description + except Exception: + pass + try: lib.ggml_backend_vk_reg.restype = ctypes.c_void_p lib.ggml_backend_vk_reg.argtypes = [] @@ -45,17 +62,31 @@ def _igpu_flags(base, lib, count: int) -> list[bool]: 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 + if describe is not None: + try: + desc = describe(dev) + if desc: + # Tabs/newlines would corrupt the line protocol; + # spaces are safe. + names[i] = ( + desc.decode("utf-8", errors = "replace") + .replace("\t", " ") + .replace("\n", " ") + .strip() + ) + except Exception: + pass except Exception: - # Best-effort: any failure degrades to "discrete" so the memory - # readings still get through instead of crashing the probe. + # Best-effort: any failure degrades to "discrete"/"unnamed" so the + # memory readings still get through instead of crashing the probe. pass - return flags + return flags, names def main() -> int: @@ -63,6 +94,14 @@ def main() -> int: return 0 bindir = sys.argv[1] + # Device names can be non-ASCII (localized drivers); the platform-default + # stdout encoding (e.g. cp1252) would raise on them and lose the whole + # inventory. The reader decodes UTF-8 with the same error mode. + try: + sys.stdout.reconfigure(encoding = "utf-8", errors = "replace") + except Exception: + pass + # Hold add_dll_directory's handle for the rest of main() (the documented # idiom) so bindir stays on the search path while the sibling ggml DLLs # resolve below. @@ -96,12 +135,12 @@ def main() -> int: ] count = lib.ggml_backend_vk_get_device_count() - igpu = _igpu_flags(base, lib, count) + igpu, names = _igpu_flags_and_names(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 0621a7f9c8..ff966e8446 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -3501,18 +3501,17 @@ 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 _run_vulkan_probe(binary: Optional[str] = None) -> list[dict]: + """Run ``_vulkan_probe.py`` and parse its per-device lines. - 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. + Returns raw (uncapped) rows sorted by index: + ``{"index", "free_mib", "total_mib", "is_igpu", "name"}``. 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. + ``name`` is ggml's device description; "" from an older 4-column probe. + [] when no Vulkan build or device is reachable. """ binary = binary or LlamaCppBackend._find_llama_server_binary() if not binary: @@ -3537,10 +3536,13 @@ class LlamaCppBackend: ) probe_script = Path(__file__).with_name("_vulkan_probe.py") try: + # UTF-8 to match the probe's stdout reconfigure: device names can be + # non-ASCII, and the platform-default decode (cp1252) could throw. result = subprocess.run( [sys.executable, str(probe_script), str(binary_dir)], capture_output = True, - text = True, + encoding = "utf-8", + errors = "replace", timeout = 15, env = env, **_windows_hidden_subprocess_kwargs(), @@ -3554,21 +3556,56 @@ class LlamaCppBackend: logger.debug(f"vulkan GPU probe failed: {e}") return [] - gpus: list[tuple[int, int, int]] = [] + rows: list[dict] = [] for line in result.stdout.strip().splitlines(): parts = line.split("\t") - if len(parts) != 4: + # 4 columns from an older probe (no name); 5 with the name column. + if len(parts) not in (4, 5): continue try: - idx = int(parts[0]) - free_mib = int(parts[1]) // (1024 * 1024) - 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) + rows.append( + { + "index": int(parts[0]), + "free_mib": int(parts[1]) // (1024 * 1024), + "is_igpu": parts[2] == "1", + "total_mib": int(parts[3]) // (1024 * 1024), + "name": parts[4].strip() if len(parts) == 5 else "", + } + ) except ValueError: continue + rows.sort(key = lambda r: r["index"]) + return rows + + @staticmethod + def vulkan_device_inventory(binary: Optional[str] = None) -> list[dict]: + """UI-facing Vulkan device list: the devices llama-server will actually + use, with real totals (an iGPU keeps its shared-RAM total here -- the + caller labels it, unlike the fit which zeroes it). Same rows as + ``_run_vulkan_probe``; names fall back to ``Vulkan``. + """ + rows = LlamaCppBackend._run_vulkan_probe(binary) + for row in rows: + if not row["name"]: + row["name"] = f"Vulkan{row['index']}" + return rows + + @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. + + Fit-oriented view of ``_run_vulkan_probe``: returns (device_index, + free_mib, total_mib) sorted by index. 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. + """ + gpus: list[tuple[int, int, int]] = [] + for row in LlamaCppBackend._run_vulkan_probe(binary): + idx, free_mib, is_igpu = row["index"], row["free_mib"], row["is_igpu"] + # 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 row["total_mib"] capped = _apply_igpu_host_reserve_mib(free_mib, is_igpu) if capped < free_mib: logger.info( @@ -3577,7 +3614,6 @@ class LlamaCppBackend: f"({free_mib}->{capped}MiB usable)" ) gpus.append((idx, capped, total_mib)) - gpus.sort(key = lambda g: g[0]) if gpus: logger.info( "Vulkan GPU memory detected: " @@ -6635,12 +6671,23 @@ 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: - # Final defense: route and pre-teardown preflights reject before Phase 1. - if is_vulkan_backend and gpu_ids: - raise ValueError(_VULKAN_DIFFUSION_GPU_IDS_ERROR) # 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 + # On a Vulkan build gpu_ids are ggml Vulkan ordinals, but the diffusion + # runner selects its device by CUDA physical index (_diffusion_gpu_arg + # forwards gpu_ids[0] as a CUDA/DG_GPU token) with no mapping to them. + # The route rejects a CONFIRMED-diffusion pick up front; an uncached GGUF + # only classified as diffusion post-download still reaches here with a + # pin, so drop it and serve on the default device (like an unpinned load). + if gpu_ids and is_vulkan_backend: + logger.warning( + "Ignoring gpu_ids %s for diffusion GGUF on a Vulkan build: " + "the diffusion runner cannot map ggml Vulkan ordinals; " + "serving on the default device.", + gpu_ids, + ) + gpu_ids = None with self._lock: if self._cancel_event.is_set(): logger.info("Load cancelled before diffusion server start") diff --git a/studio/backend/main.py b/studio/backend/main.py index e632c9525b..ff09ccb36a 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -1249,16 +1249,18 @@ def _get_cached_system_gpu_info(logger) -> tuple[dict[str, Any], dict[str, Any]] ) 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. + # Whether GGUF loads accept an explicit gpu_ids pick. /load and /validate + # 400 picks on XPU hosts, where no visibility mask speaks torch-xpu + # ordinals. A Vulkan build IS pinnable: its picks are ggml ordinals, the + # same space `--device Vulkan` uses, so check it first and let it + # through even on an XPU host (the XPU ban is about torch ordinals). + is_vulkan_build = False 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_build = LlamaCppBackend._is_vulkan_backend() + gpu_ids_supported = is_vulkan_build or get_device() != DeviceType.XPU except Exception as e: logger.debug(f"Could not resolve gpu_ids support: {e}") gpu_ids_supported = True @@ -1284,7 +1286,9 @@ def _get_cached_system_gpu_info(logger) -> tuple[dict[str, Any], dict[str, Any]] inference_gpu_info = ( { **vulkan_info, - "gguf_gpu_ids_supported": False, + # Pinnable only once the probe actually enumerated devices: + # without ordinals the frontend has nothing valid to offer. + "gguf_gpu_ids_supported": bool(vulkan_info.get("devices")), } if vulkan_info is not None else gpu_info diff --git a/studio/backend/tests/test_gpu_selection.py b/studio/backend/tests/test_gpu_selection.py index 362c751baa..7999eb4f73 100644 --- a/studio/backend/tests/test_gpu_selection.py +++ b/studio/backend/tests/test_gpu_selection.py @@ -427,14 +427,16 @@ class TestVisibleGpuUtilization(_GpuCacheResetMixin, unittest.TestCase): self.assertTrue(result["available"]) self.assertEqual(result["backend"], "vulkan") - self.assertEqual(result["index_kind"], "relative") + # ggml Vulkan ordinals are the space `--device Vulkan` pins, so they + # are selectable, unlike a torch-xpu relative ordinal. + self.assertEqual(result["index_kind"], "vulkan") self.assertEqual(result["parent_visible_gpu_ids"], []) self.assertEqual( result["devices"], [ { "index": 0, - "index_kind": "relative", + "index_kind": "vulkan", "visible_ordinal": 0, "name": "Vulkan0", "memory_total_gb": 8.0, diff --git a/studio/backend/tests/test_system_vulkan_gpu_info.py b/studio/backend/tests/test_system_vulkan_gpu_info.py index 4742b6b4bb..37fb9e6da1 100644 --- a/studio/backend/tests/test_system_vulkan_gpu_info.py +++ b/studio/backend/tests/test_system_vulkan_gpu_info.py @@ -56,7 +56,9 @@ def test_system_gpu_info_preserves_vulkan_visibility_metrics(monkeypatch): assert gpu["available"] is False assert gpu["backend"] == "cpu" assert gpu["index_kind"] == "relative" - assert gpu["gguf_gpu_ids_supported"] is False + # 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 assert gpu["devices"] == [] assert inference_gpu["backend"] == "vulkan" assert inference_gpu["devices"] == [vulkan_device] @@ -124,7 +126,8 @@ def test_system_gpu_info_keeps_forced_vulkan_separate_from_training_metrics(monk assert gpu["devices"][0]["vram_used_gb"] == 6.0 assert inference_gpu["backend"] == "vulkan" assert inference_gpu["devices"][0]["vram_used_gb"] == 1.0 - assert inference_gpu["gguf_gpu_ids_supported"] is False + # Probed devices exist, so the ordinals are known and picks are offered. + assert inference_gpu["gguf_gpu_ids_supported"] is True def test_system_gpu_info_does_not_merge_metrics_across_backend_index_spaces(monkeypatch): @@ -169,3 +172,85 @@ def test_system_gpu_info_does_not_merge_metrics_across_backend_index_spaces(monk assert gpu["devices"] == [vulkan_device] assert inference_gpu == gpu + + +def test_vulkan_inference_gpu_uses_real_device_names_and_igpu_flag(monkeypatch): + """The picker and the GPU labels need ggml's real device description, not a + Vulkan placeholder, and an explicit iGPU flag rather than inferring one + from a zero total. Memory still comes from _get_gpu_memory so the iGPU host + reserve is applied; budgeting off the raw shared total would hand out the + whole machine's RAM with no OS headroom. + """ + 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)]), + ) + monkeypatch.setattr( + LlamaCppBackend, + "vulkan_device_inventory", + staticmethod( + lambda binary = None: [ + { + "index": 0, + "name": "AMD Radeon RX 9070 XT", + "free_mib": 15 * 1024, + "total_mib": 16 * 1024, + "is_igpu": False, + }, + { + "index": 1, + "name": "AMD Radeon(TM) 8060S Graphics", + "free_mib": 89 * 1024, + "total_mib": 91 * 1024, + "is_igpu": True, + }, + ] + ), + ) + + info = get_vulkan_inference_gpu_info() + assert info is not None and info["index_kind"] == "vulkan" + dgpu, igpu = info["devices"] + + assert dgpu["name"] == "AMD Radeon RX 9070 XT" + assert dgpu["index_kind"] == "vulkan" + assert dgpu["shared_memory"] is False + assert dgpu["memory_total_gb"] == 16.0 + + assert igpu["name"] == "AMD Radeon(TM) 8060S Graphics" + assert igpu["shared_memory"] is True + # The capped free budget from _get_gpu_memory, NOT the 91 GiB raw total. + 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.""" + 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"))), + ) + + info = get_vulkan_inference_gpu_info() + assert info["devices"][0]["name"] == "Vulkan0" + assert info["devices"][0]["memory_total_gb"] == 16.0 diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py index b270a8e671..48ba375ec5 100644 --- a/studio/backend/utils/hardware/hardware.py +++ b/studio/backend/utils/hardware/hardware.py @@ -1706,7 +1706,7 @@ def get_visible_gpu_utilization() -> Dict[str, Any]: "backend": _backend_label(device), "parent_visible_gpu_ids": [], "devices": [], - "index_kind": "relative", + "index_kind": "vulkan", } @@ -2600,22 +2600,35 @@ def get_vulkan_inference_gpu_info() -> Optional[Dict[str, Any]]: "backend_cuda_visible_devices": None, "parent_visible_gpu_ids": [], "devices": [], - "index_kind": "relative", + "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(): - # Integrated Vulkan GPUs report total=0 because their memory is - # shared. Publish the capped free value as their usable inference - # budget and mark it so clients do not add system RAM again. - shared_memory = total_mib == 0 + 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 budget_mib = total_mib or free_mib used_mib = max(0, total_mib - free_mib) if total_mib else None result["devices"].append( { "index": ordinal, - "index_kind": "relative", + # ggml Vulkan ordinals are the space `--device Vulkan` pins, + # so unlike a torch-xpu relative ordinal these are selectable. + "index_kind": "vulkan", "visible_ordinal": ordinal, - "name": f"Vulkan{ordinal}", + "name": info.get("name") or f"Vulkan{ordinal}", "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), @@ -2727,7 +2740,7 @@ def get_backend_visible_gpu_info() -> Dict[str, Any]: "backend_cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"), "parent_visible_gpu_ids": [], "devices": [], - "index_kind": "relative", + "index_kind": "vulkan", } diff --git a/studio/frontend/src/hooks/use-gpu-info.ts b/studio/frontend/src/hooks/use-gpu-info.ts index c7eafc7e18..e96b0ddb91 100644 --- a/studio/frontend/src/hooks/use-gpu-info.ts +++ b/studio/frontend/src/hooks/use-gpu-info.ts @@ -100,13 +100,29 @@ function toGpuInfo( } 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). + // GGUF loads run through llama-server, so on a Vulkan build the pickable set + // is the inference inventory, not the torch view: it can see cards torch + // cannot, and its indices are the ggml ordinals `--device Vulkan` pins. + // 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) { + const picksAccepted = inference.gguf_gpu_ids_supported !== false; + return (inference.devices ?? []) + .filter((d) => typeof d.index === "number") + .map((d) => ({ + index: d.index as number, + name: d.name ?? `GPU ${d.index}`, + memoryTotalGb: d.memory_total_gb ?? 0, + memoryFreeGb: d.vram_free_gb ?? 0, + physicalIndex: picksAccepted && d.index_kind === "vulkan", + })); + } + // Otherwise the torch view is the pickable set. Unpinnable configurations + // must hide every pick surface: XPU indices are torch-xpu ordinals no + // applicator speaks, so /load and /validate 400 them, and the backend reports + // gpu.gguf_gpu_ids_supported. Absent support info defaults to pinnable + // (older backend). const pinnableBackend = data?.device_backend !== "xpu" && data?.gpu?.gguf_gpu_ids_supported !== false; diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 93e0d3e834..e1aba66b1b 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -578,3 +578,24 @@ def test_legacy_migration_is_idempotent_and_non_destructive(): # Layer 3: non-overwriting merge skips an existing (or default) key, so even a # forced re-run cannot duplicate or clobber a user's config. assert "if (isDefaultConfig(migrated) || Object.hasOwn(map, key)) {" in src + + +def test_vulkan_inference_devices_are_the_pickable_set(): + """GGUF loads run through llama-server, so on a Vulkan build the picker must + offer the inference inventory (ggml ordinals, the space `--device Vulkan` + pins) rather than the torch view, which can miss cards llama-server drives. + The XPU ban must not apply there: it is about torch-xpu ordinals no + 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. + assert ( + "const inference = data?.inference_gpu; " + 'if (inference?.backend === "vulkan" && (inference.devices ?? []).length) {' 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 'physicalIndex: picksAccepted && d.index_kind === "vulkan",' in src + # The torch fallback keeps its physical-only gate and the XPU ban. + assert 'data?.device_backend !== "xpu" &&' in src + assert 'physicalIndex: pinnableBackend && d.index_kind === "physical",' in src