Vulkan GPUs: real device names and selectable ordinals (rebase of #7356 onto #7476) (#7498)

* Vulkan GPUs: real device names and selectable ordinals

Rebases the durable half of #7356 onto the inference_gpu transport #7476
landed on main. Those two PRs solve an overlapping problem and disagree on
the data model, so merging #7356 as-is would ship two parallel Vulkan
device concepts with different index semantics. This keeps main's transport
and adds what #7356 had that #7476 does not.

- _vulkan_probe.py emits a 5th column, ggml's device description, sanitized
  for the tab protocol and UTF-8 safe. Reader tolerates 4- or 5-column
  output so an older probe still parses.
- llama_cpp gains _run_vulkan_probe (shared parse) and
  vulkan_device_inventory (names + is_igpu + real totals).
- get_vulkan_inference_gpu_info reports the real name and an explicit
  is_igpu instead of "Vulkan<i>" and a total == 0 guess.
- index_kind becomes "vulkan", not "relative", and gpu_ids picks are
  supported on Vulkan builds once the probe enumerated ordinals. The XPU ban
  no longer applies to them: a Vulkan pick is a ggml ordinal, not a torch-xpu
  index, so it works on an Intel host too.
- Frontend picker reads the Vulkan inventory as the pickable set.

Memory deliberately still comes from _get_gpu_memory, not the inventory.
That path applies _apply_igpu_host_reserve_mib and zeroes a shared total;
budgeting an APU off its raw shared total would hand out the whole machine's
RAM with no OS headroom. Identity is joined onto it by ordinal, so a probe
failure degrades to Vulkan<i> names with the memory readings intact.

Dropped from #7356 as superseded: validate_vulkan_gpu_ids (main's
resolve_requested_gpu_ids already rejects duplicates and
_resolve_gguf_gpu_ids_for_request already probes for existence), the
gguf_devices transport, and the iGPU budget fallback in 71619891e, which
main's aggregateGpuMemoryTotalGb handles better by counting a shared pool
once.

Also keeps #7356's removal of the late diffusion raise, so the graceful
gpu_ids drop stays reachable for a GGUF only classified as diffusion after
download. #7415's real guard, _reject_vulkan_diffusion_gpu_ids_before_
teardown, is untouched.

Verified on Windows + Strix Halo: backend Vulkan/GPU-selection suites at the
same 4 pre-existing failures as main, tests/studio 1671 passed with no new
failures, frontend typecheck clean. Hardware confirmation of the underlying
behavior is on #7356 from @Bebiv24 (RX 9070 XT + RX 480).

Co-authored-by: LeoBorcherding <borchborchmail@gmail.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: LeoBorcherding <borchborchmail@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-07-27 05:21:48 -07:00 committed by GitHub
commit 74295d93d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 297 additions and 70 deletions

View file

@ -6,12 +6,14 @@
Run in a short-lived subprocess (``python _vulkan_probe.py <bindir>``) so the
Vulkan instance never lives in the long-running backend process. Loads the
bundled ggml Vulkan backend from ``<bindir>`` and prints one
``<idx>\\t<free_bytes>\\t<is_igpu>\\t<total_bytes>`` 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.
``<idx>\\t<free_bytes>\\t<is_igpu>\\t<total_bytes>\\t<name>`` 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

View file

@ -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<index>`` 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<index>`` 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<i>``.
"""
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")

View file

@ -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<i>` 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

View file

@ -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<i>` 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,

View file

@ -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<i> 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<i> 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

View file

@ -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<i>` 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",
}

View file

@ -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<i>` 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;

View file

@ -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<i>`
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