Coverage for src/lilbee/providers/fleet/gpu_select.py: 100%
379 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Ask the host's Vulkan loader what ggml is going to see.
3Probes the loader via ``ctypes`` for the facts the engine's ``--list-devices``
4text does not carry: each adapter's device type, its ``deviceUUID``, whether it
5supports the one feature ggml requires of it, and how much of its memory is
6actually free. Placement uses these to agree with the engine about which devices
7exist and how big they are; where the two disagree, a fleet gets sized against
8hardware llama-server never uses.
10It deliberately does not choose a device. Selection belongs to ggml, which
11applies its own type filter, support check and same-UUID dedup at launch;
12pinning through ``GGML_VK_VISIBLE_DEVICES`` would switch all three off, so
13Vulkan devices are pinned by the name the engine printed or not at all.
14"""
16from __future__ import annotations
18import ctypes
19import ctypes.util
20import fnmatch
21import json
22import logging
23import ntpath
24import os
25import subprocess
26import sys
27from collections import Counter
28from ctypes import POINTER, byref, c_char, c_char_p, c_uint8, c_uint32, c_uint64, c_void_p
29from dataclasses import dataclass
30from enum import IntEnum, StrEnum
31from functools import lru_cache
33from lilbee.providers.fleet.gpu_hardware import installed_gpu_vendor_ids
34from lilbee.providers.fleet.vulkan_icd_discovery import (
35 iter_vulkan_manifest_paths,
36)
38log = logging.getLogger(__name__)
40# The child that runs the loader, and how long it may take. The bound matters:
41# a wedged ICD can hang inside vkCreateInstance rather than fault, and the
42# placement read that asked must not hang with it.
43_PROBE_MODULE = "lilbee.providers.fleet.vulkan_probe"
44_PROBE_TIMEOUT_S = 10.0
45_PROBE_KILL_WAIT_S = 5.0
47# vk.h constants. Mirrored here so we don't drag a vulkan-headers
48# dependency in for four magic numbers. See the upstream definitions in
49# https://github.com/KhronosGroup/Vulkan-Headers/blob/main/include/vulkan/vulkan_core.h
50# (VkStructureType enum and the VK_API_VERSION_1_0 / VK_SUCCESS macros).
51_VK_STRUCTURE_TYPE_APPLICATION_INFO = 0
52_VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO = 1
53_VK_SUCCESS = 0
54_VK_API_VERSION_1_0 = (1 << 22) | (0 << 12) | 0
55# 1.1 is asked for first, purely to make vkGetPhysicalDeviceProperties2 (and the
56# device UUID it carries) core rather than an extension; a loader that refuses
57# it gets the 1.0 request back and the probe simply has no UUIDs to dedup by.
58_VK_API_VERSION_1_1 = (1 << 22) | (1 << 12) | 0
59_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2 = 1000059000
60_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2 = 1000059001
61_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES = 1000071004
62_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_16BIT_STORAGE_FEATURES = 1000083000
63_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_PROPERTIES_2 = 1000059006
64_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT = 1000237000
65# The device extension that turns heap sizes into a live budget. Without it the
66# only figure available is the heap's capacity, which never moves.
67_VK_EXT_MEMORY_BUDGET = b"VK_EXT_memory_budget"
68_VK_MAX_EXTENSION_NAME_SIZE = 256
71class VkDeviceType(IntEnum):
72 """``VkPhysicalDeviceType`` enum from vulkan_core.h.
74 Values match the C ABI verbatim; the loader writes one of these
75 into the ``deviceType`` field of ``VkPhysicalDeviceProperties``.
76 """
78 OTHER = 0
79 INTEGRATED_GPU = 1
80 DISCRETE_GPU = 2
81 VIRTUAL_GPU = 3
82 CPU = 4
85# The device types ggml's Vulkan backend will actually run on. Anything else --
86# a software rasterizer, a paravirtual adapter, an unknown type -- is not a
87# device the engine would choose, so planning against one guarantees a mismatch.
88USABLE_VULKAN_TYPES = frozenset({VkDeviceType.DISCRETE_GPU, VkDeviceType.INTEGRATED_GPU})
91# vk.h sizes for the inline char arrays inside VkPhysicalDeviceProperties.
92# Both constants are part of the Vulkan 1.0 ABI and frozen forever; see
93# VK_MAX_PHYSICAL_DEVICE_NAME_SIZE and VK_UUID_SIZE in
94# https://github.com/KhronosGroup/Vulkan-Headers/blob/main/include/vulkan/vulkan_core.h
95_VK_MAX_PHYSICAL_DEVICE_NAME_SIZE = 256
96_VK_UUID_SIZE = 16
99@dataclass(frozen=True)
100class VulkanDevice:
101 """One Vulkan adapter as reported by the loader."""
103 index: int
104 device_type: int
105 device_name: str
106 vendor_id: int
107 vram_bytes: int = 0
108 # VkPhysicalDeviceIDProperties::deviceUUID, empty when the loader could not
109 # be asked for it. The spec requires it to be immutable for a given device
110 # across instances, processes, driver APIs, driver versions and reboots, so
111 # two entries sharing one is one piece of silicon behind two drivers.
112 device_uuid: bytes = b""
113 # storageBuffer16BitAccess, the single feature ggml's Vulkan backend requires
114 # of a device before it will use it. Read from
115 # VkPhysicalDevice16BitStorageFeatures rather than the
116 # VkPhysicalDeviceVulkan11Features ggml itself uses: same bit, but the latter
117 # arrived in Vulkan 1.2 and this probe asks for a 1.1 instance.
118 # ``None`` when the loader could not be asked, which is not a refusal.
119 storage_buffer_16bit: bool | None = None
120 # Device-local memory not already committed, from VK_EXT_memory_budget.
121 # ``None`` when the device does not expose that extension, which is the
122 # difference between "nothing else is using this card" and "cannot tell".
123 free_bytes: int | None = None
126class PCIVendorID(IntEnum):
127 """PCI-SIG vendor IDs for the GPU vendors that ship Vulkan ICDs.
129 Values are the canonical PCI vendor IDs that
130 ``VkPhysicalDeviceProperties.vendorID`` surfaces. They are issued by
131 PCI-SIG and frozen per company; see the public PCI vendor-ID
132 registry at https://pcisig.com/membership/member-companies (also
133 mirrored at https://devicehunt.com/all-pci-vendors). Only the
134 vendors we have explicit ICD-disable globs for are enumerated;
135 unknown vendors fall through the dispatch as no-op.
136 """
138 NVIDIA = 0x10DE # NVIDIA Corporation
139 AMD = 0x1002 # Advanced Micro Devices, Inc. [AMD/ATI]
140 INTEL = 0x8086 # Intel Corporation
143# Vulkan loader manifest filename globs, per vendor. The loader matches these
144# against the JSON manifest filename in its known-drivers list (see
145# https://github.com/KhronosGroup/Vulkan-Loader/blob/main/docs/LoaderInterfaceArchitecture.md).
146# Each vendor ships under multiple names across drivers/OSes; list every form
147# we may encounter so disabling one vendor's drivers doesn't half-disable them.
148_VENDOR_ICD_GLOBS: dict[PCIVendorID, tuple[str, ...]] = {
149 # nv-vk*.json (Windows), nvidia_*.json (Linux). Both match nv*.
150 PCIVendorID.NVIDIA: ("nv*",),
151 # amdvlk64.json (Windows AMDVLK), amd_icd*.json (Linux AMDVLK),
152 # amd-vulkan*.json (legacy AMDVLK builds), radeon_icd.*.json
153 # (Mesa RADV on Linux). Adding amd_icd* explicitly because no
154 # other glob covers the Linux AMDVLK manifest.
155 PCIVendorID.AMD: ("amdvlk*", "amd_icd*", "amd-vulkan*", "radeon*"),
156 # intel_icd.*.json (Mesa Intel ANV on Linux), igvk*.json (Windows).
157 PCIVendorID.INTEL: ("intel*", "igvk*"),
158}
161class VulkanIcdEnvVar(StrEnum):
162 """Every documented Vulkan loader env var that influences ICD selection.
164 Names are the verbatim loader env vars from the Khronos
165 LoaderInterfaceArchitecture spec; the StrEnum lets each member be
166 used directly as a ``str`` argument to ``os.environ.get`` /
167 ``os.environ.setdefault`` without ``.value`` plumbing. Any value
168 being non-empty in the environment is treated as a user override
169 and suppresses the dual-vendor auto-pin.
170 """
172 DRIVER_FILES = "VK_DRIVER_FILES"
173 ICD_FILENAMES = "VK_ICD_FILENAMES"
174 ADD_DRIVER_FILES = "VK_ADD_DRIVER_FILES"
175 LOADER_DRIVERS_DISABLE = "VK_LOADER_DRIVERS_DISABLE"
176 LOADER_DRIVERS_SELECT = "VK_LOADER_DRIVERS_SELECT"
179# Field layouts from the Vulkan 1.0 spec. ctypes maps the C structs
180# verbatim so the loader populates them directly; only the prefix
181# fields we read are commented (the trailing fields are kept for ABI
182# alignment, not consumed).
185class _VkApplicationInfo(ctypes.Structure):
186 _fields_ = [
187 ("sType", c_uint32),
188 ("pNext", c_void_p),
189 ("pApplicationName", c_char_p),
190 ("applicationVersion", c_uint32),
191 ("pEngineName", c_char_p),
192 ("engineVersion", c_uint32),
193 ("apiVersion", c_uint32),
194 ]
197class _VkInstanceCreateInfo(ctypes.Structure):
198 _fields_ = [
199 ("sType", c_uint32),
200 ("pNext", c_void_p),
201 ("flags", c_uint32),
202 ("pApplicationInfo", POINTER(_VkApplicationInfo)),
203 ("enabledLayerCount", c_uint32),
204 ("ppEnabledLayerNames", POINTER(c_char_p)),
205 ("enabledExtensionCount", c_uint32),
206 ("ppEnabledExtensionNames", POINTER(c_char_p)),
207 ]
210class _VkPhysicalDeviceLimits(ctypes.Structure):
211 # Opaque to us; we only need the parent struct's *layout* to match
212 # the driver-populated bytes so the loader can write a vendorID and
213 # deviceType into the prefix fields we actually read.
214 #
215 # 504 bytes, per VkPhysicalDeviceLimits in
216 # https://github.com/KhronosGroup/Vulkan-Headers/blob/main/include/vulkan/vulkan_core.h
217 # The size is part of the frozen Vulkan 1.0 layout, so it does not drift
218 # across driver versions.
219 #
220 # Declared as uint64 rather than bytes for its ALIGNMENT, not its size. The
221 # real struct mixes uint32, uint64 (VkDeviceSize), size_t and float, so its C
222 # alignment is 8. A c_uint8 array aligns to 1, which let ctypes seat this
223 # field at offset 292 in the parent instead of the 296 the ABI pads it to,
224 # making the mirror 816 bytes against the driver's 824. The driver fills the
225 # caller's buffer using its own layout, so every probe wrote sparseProperties
226 # four bytes past the end of a Python-heap allocation -- absorbed by allocator
227 # slack, which is what kept it silent.
228 _fields_ = [("_opaque", c_uint64 * 63)]
231class _VkPhysicalDeviceSparseProperties(ctypes.Structure):
232 # 5 ULONG32 booleans, also part of the Vulkan 1.0 ABI; see same header.
233 _fields_ = [("_opaque", c_uint32 * 5)]
236class _VkPhysicalDeviceProperties(ctypes.Structure):
237 _fields_ = [
238 ("apiVersion", c_uint32),
239 ("driverVersion", c_uint32),
240 ("vendorID", c_uint32),
241 ("deviceID", c_uint32),
242 ("deviceType", c_uint32),
243 ("deviceName", c_char * _VK_MAX_PHYSICAL_DEVICE_NAME_SIZE),
244 ("pipelineCacheUUID", c_uint8 * _VK_UUID_SIZE),
245 ("limits", _VkPhysicalDeviceLimits),
246 ("sparseProperties", _VkPhysicalDeviceSparseProperties),
247 ]
250# VkPhysicalDeviceMemoryProperties layout (Vulkan 1.0 ABI, frozen). Array
251# bounds and the device-local heap flag are from vulkan_core.h. The
252# device-local heap size is the cross-vendor VRAM signal (the same heap
253# nvidia-smi/rocm-smi report) used for placement bin-packing.
254_VK_MAX_MEMORY_TYPES = 32
255_VK_MAX_MEMORY_HEAPS = 16
256_VK_MEMORY_HEAP_DEVICE_LOCAL_BIT = 0x00000001
259class _VkMemoryType(ctypes.Structure):
260 _fields_ = [("propertyFlags", c_uint32), ("heapIndex", c_uint32)]
263class _VkMemoryHeap(ctypes.Structure):
264 _fields_ = [("size", c_uint64), ("flags", c_uint32)]
267class _VkPhysicalDevice16BitStorageFeatures(ctypes.Structure):
268 # Promoted to core in Vulkan 1.1 from VK_KHR_16bit_storage. Chained onto
269 # VkPhysicalDeviceFeatures2; only the first flag is read.
270 _fields_ = [
271 ("sType", c_uint32),
272 ("pNext", c_void_p),
273 ("storageBuffer16BitAccess", c_uint32),
274 ("uniformAndStorageBuffer16BitAccess", c_uint32),
275 ("storagePushConstant16", c_uint32),
276 ("storageInputOutput16", c_uint32),
277 ]
280class _VkPhysicalDeviceFeatures2(ctypes.Structure):
281 # VkPhysicalDeviceFeatures is a flat run of VkBool32s whose count grows with
282 # no version of the spec but is easy to miscount, and the driver writes the
283 # whole thing into this buffer. Declared larger than the real struct so a
284 # miscount cannot become a heap overrun the way the limits mirror once did;
285 # the field sits last, so the extra words shift nothing the driver reads.
286 _fields_ = [
287 ("sType", c_uint32),
288 ("pNext", c_void_p),
289 ("features", c_uint32 * 128),
290 ]
293class _VkExtensionProperties(ctypes.Structure):
294 _fields_ = [
295 ("extensionName", c_char * _VK_MAX_EXTENSION_NAME_SIZE),
296 ("specVersion", c_uint32),
297 ]
300class _VkPhysicalDeviceIDProperties(ctypes.Structure):
301 # VkPhysicalDeviceIDProperties, promoted to core in Vulkan 1.1. Chained onto
302 # VkPhysicalDeviceProperties2 via pNext; the driver fills every field, so the
303 # trailing ones are declared for layout even though only deviceUUID is read.
304 _fields_ = [
305 ("sType", c_uint32),
306 ("pNext", c_void_p),
307 ("deviceUUID", c_uint8 * _VK_UUID_SIZE),
308 ("driverUUID", c_uint8 * _VK_UUID_SIZE),
309 ("deviceLUID", c_uint8 * 8),
310 ("deviceNodeMask", c_uint32),
311 ("deviceLUIDValid", c_uint32),
312 ]
315class _VkPhysicalDeviceProperties2(ctypes.Structure):
316 _fields_ = [
317 ("sType", c_uint32),
318 ("pNext", c_void_p),
319 ("properties", _VkPhysicalDeviceProperties),
320 ]
323class _VkPhysicalDeviceMemoryProperties(ctypes.Structure):
324 _fields_ = [
325 ("memoryTypeCount", c_uint32),
326 ("memoryTypes", _VkMemoryType * _VK_MAX_MEMORY_TYPES),
327 ("memoryHeapCount", c_uint32),
328 ("memoryHeaps", _VkMemoryHeap * _VK_MAX_MEMORY_HEAPS),
329 ]
332class _VkPhysicalDeviceMemoryProperties2(ctypes.Structure):
333 _fields_ = [
334 ("sType", c_uint32),
335 ("pNext", c_void_p),
336 ("memoryProperties", _VkPhysicalDeviceMemoryProperties),
337 ]
340class _VkPhysicalDeviceMemoryBudgetPropertiesEXT(ctypes.Structure):
341 # heapBudget is what this process may still allocate from each heap and
342 # heapUsage what it already has; the difference across the device-local heaps
343 # is the only cross-vendor figure that moves when another process takes VRAM.
344 _fields_ = [
345 ("sType", c_uint32),
346 ("pNext", c_void_p),
347 ("heapBudget", c_uint64 * _VK_MAX_MEMORY_HEAPS),
348 ("heapUsage", c_uint64 * _VK_MAX_MEMORY_HEAPS),
349 ]
352def enumerate_gpu_vram() -> list[tuple[int, int, int]] | None:
353 """Return ``[(device_index, device_local_vram_bytes, free_bytes), ...]`` or ``None``.
355 Cross-vendor via the Vulkan probe (NVIDIA/AMD/Intel). ``None`` when the
356 loader/probe is unavailable (macOS Metal, no Vulkan driver), so the
357 placement planner can degrade to count-only or in-process.
359 Only discrete and integrated adapters are returned, the same rule ggml's
360 Vulkan backend applies when it picks a device, so this cannot offer
361 placement something the engine would refuse to run on. Matching that rule
362 is the point: where the two disagree about which devices exist, placement
363 sizes against a device llama-server never uses.
365 Two kinds are excluded. Mesa's llvmpipe is a software rasterizer that
366 advertises itself through Vulkan and reports system RAM as its device
367 memory, so beside integrated graphics it appears at an identical size and
368 is indistinguishable by VRAM alone; planning against it splits the model
369 across a real GPU and a CPU renderer. Paravirtual adapters (virgl, VMware,
370 VirtIO-GPU) report as ``VIRTUAL_GPU`` and are typically compute-incapable
371 or proxies that fail on allocation.
373 The device type is the only signal separating any of these, and the caller
374 has no access to it: this returns sizes, and the ``--list-devices`` text it
375 feeds carries no names on the fallback path.
376 """
377 devices = _enumerate_vulkan_devices()
378 if devices is None:
379 return None
380 return [
381 (d.index, d.vram_bytes, d.free_bytes if d.free_bytes is not None else d.vram_bytes)
382 for d in devices
383 if d.device_type in USABLE_VULKAN_TYPES
384 ]
387@lru_cache(maxsize=1)
388def integrated_vulkan_indices() -> frozenset[int]:
389 """Loader indices of adapters whose memory is the host's.
391 Empty when the loader is unavailable or the probe fails, which reads as
392 "assume dedicated" and preserves the behaviour discrete hosts already have.
394 Cached because the device parser asks per device line: without it an
395 N-device host paid N loader loads and N instance creations to answer the
396 same question. Which adapters are integrated is a property of the machine,
397 so one answer per process is right.
398 """
399 devices = _enumerate_vulkan_devices()
400 if not devices:
401 return frozenset()
402 return frozenset(d.index for d in devices if d.device_type == VkDeviceType.INTEGRATED_GPU)
405def vulkan_free_bytes_by_name() -> dict[str, int]:
406 """Device-local memory still free, keyed by the name the loader reports.
408 Deliberately not cached: free memory is a live number, and freezing it for
409 the process lifetime would hand every later probe the first reading taken.
410 Callers sample it once per parse rather than per device line.
412 Only devices whose driver exposes ``VK_EXT_memory_budget`` appear; the rest
413 have no live figure to offer and are absent rather than guessed at. A name
414 two adapters share is also absent: two identical cards have their own free
415 figures, and nothing in the engine's text says which line is which. Guessing
416 would report one card's headroom for the other.
417 """
418 devices = _enumerate_vulkan_devices()
419 if not devices:
420 return {}
421 seen = Counter(d.device_name for d in devices)
422 return {
423 d.device_name: d.free_bytes
424 for d in devices
425 if d.free_bytes is not None and seen[d.device_name] == 1
426 }
429@lru_cache(maxsize=1)
430def vulkan_device_types_by_name() -> dict[str, VkDeviceType]:
431 """Adapter type keyed by the name the loader reports, empty when unavailable.
433 Keyed by name rather than index because the engine's ``--list-devices``
434 ordinals are assigned after ggml has filtered and deduplicated the loader's
435 list, so ``Vulkan0`` is only the loader's device 0 when nothing ahead of it
436 was dropped. The name is the one field both views print verbatim from
437 ``VkPhysicalDeviceProperties``, so it correlates the two without either
438 side having to replicate the other's filtering.
440 Two adapters of the same model share a name, which is harmless: they share
441 a type too, and the type is all this answers.
442 """
443 devices = _enumerate_vulkan_devices()
444 if not devices:
445 return {}
446 return {
447 d.device_name: device_type
448 for d in devices
449 if (device_type := _known_device_type(d.device_type)) is not None
450 }
453def discrete_gpu_from_vendor(vendor_id: int) -> bool | None:
454 """Whether the loader reports a discrete adapter from *vendor_id*.
456 ``None`` when the loader cannot be reached, which is a different answer from
457 "no": a caller deciding whether to fail loud must not read silence as proof
458 that a card is absent, nor as proof that one is present.
459 """
460 devices = _enumerate_vulkan_devices()
461 if not devices:
462 return None
463 return any(
464 d.vendor_id == vendor_id and d.device_type == VkDeviceType.DISCRETE_GPU for d in devices
465 )
468# Vendors whose PCI display controllers are always dedicated cards. AMD and
469# Intel both ship integrated parts under their own IDs, so their presence says
470# nothing about whether a discrete card exists; NVIDIA's desktop and laptop
471# parts are discrete without exception here.
472_DISCRETE_ONLY_VENDORS: frozenset[int] = frozenset({PCIVendorID.NVIDIA})
475def host_has_no_discrete_gpu() -> bool:
476 """Whether the Vulkan loader can see adapters and none of them is discrete.
478 The vendor-neutral answer to a question CUDA and ROCm cannot be asked
479 through text: their ``--list-devices`` lines carry no device type, so an AMD
480 APU and a Jetson enumerate exactly like a discrete card while reporting
481 system RAM as their memory. Every such part also ships a Vulkan driver, and
482 a machine whose loader reports adapters but no discrete one has no discrete
483 GPU for CUDA or ROCm to be enumerating.
485 The verdict rests on an integrated adapter actually being there. Software
486 rasterizers report through the loader on any host with mesa installed, even
487 with no vendor ICD present at all, which is ordinary on headless CUDA boxes
488 and in containers; concluding from a list that holds only those would mark a
489 real discrete card as sharing the host's memory and shrink its budget.
491 False when the loader is unreachable, when any discrete adapter exists, or
492 when nothing but rasterizers answered, so a host with a real card is never
493 talked into the shared-memory budget. A host holding both a discrete card
494 and an APU also answers False, which leaves the APU sized as dedicated;
495 correlating individual devices across two backends' naming needs more than
496 the type.
498 The loader is not the only witness, and on a hybrid laptop it is the wrong
499 one. Optimus and its equivalents leave the discrete card powered down until
500 something asks for it through prime-run, so the loader enumerates the
501 integrated adapter alone while a dedicated card sits on the PCI bus. Taking
502 that list at its word marked a real 4 GB card as sharing system memory,
503 which is the exact outcome the paragraph above promises never to reach. PCI
504 settles it: a vendor that only ever ships discrete parts, present in the
505 device tree but absent from the loader's list, means the loader is telling
506 an incomplete story rather than a complete one.
507 """
508 types = set(vulkan_device_types_by_name().values())
509 if VkDeviceType.DISCRETE_GPU in types:
510 return False
511 if _DISCRETE_ONLY_VENDORS & installed_gpu_vendor_ids():
512 return False
513 return VkDeviceType.INTEGRATED_GPU in types
516def _known_device_type(value: int) -> VkDeviceType | None:
517 """The enum member for a raw ``deviceType``, ``None`` for a value vk.h doesn't define."""
518 try:
519 return VkDeviceType(value)
520 except ValueError:
521 return None
524def enumerate_in_process() -> list[VulkanDevice] | None:
525 """Open libvulkan, create a throwaway instance, enumerate adapters.
527 Returns ``None`` if the loader can't be found or any Vulkan call
528 fails; empty list ("loader present, no adapters") is a distinct
529 outcome and propagates back.
531 Runs the loader in whatever process calls it, which is why
532 :func:`_enumerate_vulkan_devices` calls it in a child rather than directly:
533 ``vkCreateInstance`` loads every vendor ICD on the host, and a faulting one
534 raises no exception, it raises a signal.
535 """
536 lib = _load_vulkan_loader()
537 if lib is None:
538 return None
539 try:
540 devices = _list_devices_with_instance(lib)
541 return _deduplicate_by_uuid(_drop_devices_the_engine_refuses(devices))
542 except OSError:
543 # ctypes argument / call-site errors land here; treat as
544 # "probe failed" rather than crashing the host process.
545 return None
548def _run_probe_child() -> tuple[str, int, str]:
549 """Run the enumeration in a child; returns ``(stdout, returncode, stderr)``."""
550 from lilbee.providers.fleet.gpu_env import engine_environ
551 from lilbee.providers.fleet.proc import run_bounded
553 argv = [sys.executable, "-m", _PROBE_MODULE]
554 stdout, returncode = run_bounded(
555 argv,
556 env=engine_environ(),
557 timeout_s=_PROBE_TIMEOUT_S,
558 kill_wait_s=_PROBE_KILL_WAIT_S,
559 label="vulkan-probe",
560 )
561 return stdout, returncode, ""
564def _enumerate_vulkan_devices() -> list[VulkanDevice] | None:
565 """The adapters the Vulkan loader reports, or ``None`` for no opinion.
567 Asked of a short-lived child. ``vkCreateInstance`` pre-loads every vendor ICD
568 on the host, and a broken or conflicting one faults inside the loader; a
569 fault is a signal, so no ``except`` here could keep the daemon alive. In a
570 child, dying is simply an answer. Every failure reads the same as an
571 unreachable loader, which is the state the callers were written for.
573 An empty list still means "loader present, no adapters", which is a
574 different fact and propagates back intact.
576 Uncached, and each caller decides for itself whether to hold the answer: the
577 device types are a property of the machine and are cached, while free memory
578 is a live number that is read fresh every time it is asked for.
579 """
580 from lilbee.providers.base import ProviderError
581 from lilbee.providers.fleet.vulkan_probe import from_json
583 try:
584 stdout, returncode, stderr = _run_probe_child()
585 except (ProviderError, OSError, subprocess.SubprocessError) as exc:
586 log.debug("Vulkan probe child could not be run: %s", exc)
587 return None
588 if returncode != 0:
589 log.debug("Vulkan probe child exited %s: %s", returncode, stderr.strip() or stdout.strip())
590 return None
591 try:
592 return from_json(json.loads(stdout))
593 except (ValueError, KeyError, TypeError) as exc:
594 log.debug("Vulkan probe child printed no usable device list: %s", exc)
595 return None
598def _drop_devices_the_engine_refuses(devices: list[VulkanDevice]) -> list[VulkanDevice]:
599 """Drop adapters ggml's Vulkan backend would exclude from its device pool.
601 ``ggml_vk_device_is_supported`` gates on exactly one feature,
602 ``storageBuffer16BitAccess``, and excludes devices without it silently, with
603 no error anywhere. Some Adreno parts are the documented case. Keeping such a
604 device means placement sizes a fleet against VRAM the engine will never
605 touch, and the engine quietly runs on the CPU or another adapter instead.
607 Only a definite ``False`` drops a device: a loader too old to be asked
608 reports ``None``, and that is not a refusal.
609 """
610 return [d for d in devices if d.storage_buffer_16bit is not False]
613def _deduplicate_by_uuid(devices: list[VulkanDevice]) -> list[VulkanDevice]:
614 """Collapse adapters that share a ``deviceUUID`` into one, keeping the first.
616 Two ICDs able to drive the same card (RADV beside AMDVLK is the case ggml's
617 own dedup names) enumerate it twice. ggml counts it once, so without this
618 lilbee plans a two-GPU fleet on one piece of silicon and tensor-splits a
619 model across a card and itself.
621 ggml breaks the same tie with a driver-priority table, picking which
622 driver's entry survives. Lowest index is enough here because nothing lilbee
623 reads off a device tells the two entries apart: the type, the name and the
624 device-local heap size describe the silicon, not the driver, and no caller
625 pins by the raw enumeration index any more.
627 Devices with no UUID are all kept, since "the loader would not say" is not
628 evidence that two adapters are one.
629 """
630 seen: set[bytes] = set()
631 unique: list[VulkanDevice] = []
632 for device in devices:
633 if device.device_uuid and device.device_uuid in seen:
634 log.debug(
635 "Vulkan device %d (%s) is device %s under a second driver; ignoring the duplicate",
636 device.index,
637 device.device_name,
638 next(d.index for d in unique if d.device_uuid == device.device_uuid),
639 )
640 continue
641 if device.device_uuid:
642 seen.add(device.device_uuid)
643 unique.append(device)
644 return unique
647def _load_vulkan_loader() -> ctypes.CDLL | None:
648 """Locate and load the Vulkan loader for the current platform.
650 Returns ``None`` when the loader isn't installed, which is the
651 expected outcome on stock macOS (we ship a Metal wheel there) and
652 on hosts without a Vulkan-capable driver.
653 """
654 candidates: tuple[str, ...]
655 if sys.platform == "win32":
656 candidates = ("vulkan-1.dll",)
657 elif sys.platform == "darwin":
658 # MoltenVK exposes a different ABI than libvulkan; lilbee's
659 # macOS wheel uses Metal directly, so skipping the probe on
660 # Darwin is correct.
661 return None
662 else:
663 candidates = ("libvulkan.so.1", "libvulkan.so")
665 for name in candidates:
666 try:
667 return ctypes.CDLL(name)
668 except OSError:
669 continue
670 # ctypes.util.find_library is a last-resort fallback for distros
671 # where the soname isn't directly loadable.
672 resolved = ctypes.util.find_library("vulkan")
673 if resolved is not None:
674 try:
675 return ctypes.CDLL(resolved)
676 except OSError:
677 return None
678 return None
681def _create_probe_instance(create_instance: ctypes._FuncPointer) -> tuple[c_void_p | None, int]:
682 """Create the throwaway instance, asking for 1.1 and settling for 1.0.
684 Returns the instance and the API version it was created with. 1.1 makes
685 ``vkGetPhysicalDeviceProperties2`` core, which is where the device UUID
686 lives; a 1.0-only loader rejects the request outright, so the 1.0 retry is
687 what keeps the probe working there at all rather than silently reporting no
688 adapters.
689 """
690 for api_version in (_VK_API_VERSION_1_1, _VK_API_VERSION_1_0):
691 app_info = _VkApplicationInfo(
692 sType=_VK_STRUCTURE_TYPE_APPLICATION_INFO,
693 pNext=None,
694 pApplicationName=b"lilbee-gpu-probe",
695 applicationVersion=0,
696 pEngineName=b"lilbee",
697 engineVersion=0,
698 apiVersion=api_version,
699 )
700 create_info = _VkInstanceCreateInfo(
701 sType=_VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
702 pNext=None,
703 flags=0,
704 pApplicationInfo=ctypes.pointer(app_info),
705 enabledLayerCount=0,
706 ppEnabledLayerNames=None,
707 enabledExtensionCount=0,
708 ppEnabledExtensionNames=None,
709 )
710 instance = c_void_p()
711 result = create_instance(byref(create_info), None, byref(instance))
712 if result == _VK_SUCCESS and instance.value:
713 return instance, api_version
714 return None, 0
717def _resolve_properties2(lib: ctypes.CDLL) -> ctypes._FuncPointer | None:
718 """``vkGetPhysicalDeviceProperties2`` with argtypes stamped, ``None`` if absent."""
719 try:
720 get_properties2 = lib.vkGetPhysicalDeviceProperties2
721 except AttributeError:
722 return None
723 get_properties2.argtypes = [c_void_p, POINTER(_VkPhysicalDeviceProperties2)]
724 get_properties2.restype = None
725 return get_properties2
728def _resolve_memory_budget(
729 lib: ctypes.CDLL,
730) -> tuple[ctypes._FuncPointer, ctypes._FuncPointer] | None:
731 """``(vkGetPhysicalDeviceMemoryProperties2, vkEnumerateDeviceExtensionProperties)``."""
732 try:
733 get_memory2 = lib.vkGetPhysicalDeviceMemoryProperties2
734 enum_extensions = lib.vkEnumerateDeviceExtensionProperties
735 except AttributeError:
736 return None
737 get_memory2.argtypes = [c_void_p, POINTER(_VkPhysicalDeviceMemoryProperties2)]
738 get_memory2.restype = None
739 enum_extensions.argtypes = [
740 c_void_p,
741 c_char_p,
742 POINTER(c_uint32),
743 POINTER(_VkExtensionProperties),
744 ]
745 enum_extensions.restype = c_uint32
746 return get_memory2, enum_extensions
749def _supports_memory_budget(handle: c_void_p, enum_extensions: ctypes._FuncPointer) -> bool:
750 """Whether the device advertises ``VK_EXT_memory_budget``.
752 Asked rather than assumed: chaining the budget struct onto a device that
753 does not support it leaves it zeroed, and zero budget is indistinguishable
754 from a full card.
755 """
756 count = c_uint32(0)
757 if enum_extensions(handle, None, byref(count), None) != _VK_SUCCESS or count.value == 0:
758 return False
759 props = (_VkExtensionProperties * count.value)()
760 if enum_extensions(handle, None, byref(count), props) != _VK_SUCCESS:
761 return False
762 return any(props[i].extensionName == _VK_EXT_MEMORY_BUDGET for i in range(count.value))
765def _free_device_local_bytes(
766 handle: c_void_p, memory_budget: tuple[ctypes._FuncPointer, ctypes._FuncPointer] | None
767) -> int | None:
768 """Device-local memory still available, or ``None`` when it cannot be asked.
770 ``None`` rather than the heap size on purpose. Reporting capacity as free is
771 how a desktop holding gigabytes of compositor and browser VRAM was planned
772 as an empty card.
773 """
774 if memory_budget is None:
775 return None
776 get_memory2, enum_extensions = memory_budget
777 if not _supports_memory_budget(handle, enum_extensions):
778 return None
779 budget = _VkPhysicalDeviceMemoryBudgetPropertiesEXT(
780 sType=_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT, pNext=None
781 )
782 props2 = _VkPhysicalDeviceMemoryProperties2(
783 sType=_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_PROPERTIES_2,
784 pNext=ctypes.cast(ctypes.pointer(budget), c_void_p),
785 )
786 get_memory2(handle, byref(props2))
787 mem = props2.memoryProperties
788 free = 0
789 for i in range(mem.memoryHeapCount):
790 if mem.memoryHeaps[i].flags & _VK_MEMORY_HEAP_DEVICE_LOCAL_BIT:
791 free += max(0, int(budget.heapBudget[i]) - int(budget.heapUsage[i]))
792 return free
795def _resolve_features2(lib: ctypes.CDLL) -> ctypes._FuncPointer | None:
796 """``vkGetPhysicalDeviceFeatures2`` with argtypes stamped, ``None`` if absent."""
797 try:
798 get_features2 = lib.vkGetPhysicalDeviceFeatures2
799 except AttributeError:
800 return None
801 get_features2.argtypes = [c_void_p, POINTER(_VkPhysicalDeviceFeatures2)]
802 get_features2.restype = None
803 return get_features2
806def _storage_buffer_16bit(
807 handle: c_void_p, get_features2: ctypes._FuncPointer | None
808) -> bool | None:
809 """Whether the adapter supports ``storageBuffer16BitAccess``, ``None`` if unasked.
811 The one feature ggml's Vulkan backend requires before it will put a device
812 in its pool, and it drops devices that lack it silently. Some Adreno parts
813 expose ``uniformAndStorageBuffer16BitAccess`` without it, so the two are not
814 interchangeable and only the first flag answers the question.
815 """
816 if get_features2 is None:
817 return None
818 storage = _VkPhysicalDevice16BitStorageFeatures(
819 sType=_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_16BIT_STORAGE_FEATURES, pNext=None
820 )
821 features2 = _VkPhysicalDeviceFeatures2(
822 sType=_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
823 pNext=ctypes.cast(ctypes.pointer(storage), c_void_p),
824 )
825 get_features2(handle, byref(features2))
826 return bool(storage.storageBuffer16BitAccess)
829def _device_uuid(handle: c_void_p, get_properties2: ctypes._FuncPointer | None) -> bytes:
830 """The adapter's ``deviceUUID``, empty when it cannot be asked for.
832 An all-zero UUID is returned as empty too: it is what a driver leaves behind
833 when it ignores the chained struct, and treating it as a real identity would
834 collapse every such adapter into one.
835 """
836 if get_properties2 is None:
837 return b""
838 id_props = _VkPhysicalDeviceIDProperties(
839 sType=_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ID_PROPERTIES, pNext=None
840 )
841 props2 = _VkPhysicalDeviceProperties2(
842 sType=_VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,
843 pNext=ctypes.cast(ctypes.pointer(id_props), c_void_p),
844 )
845 get_properties2(handle, byref(props2))
846 uuid = bytes(id_props.deviceUUID)
847 return b"" if not any(uuid) else uuid
850def _list_devices_with_instance(lib: ctypes.CDLL) -> list[VulkanDevice]:
851 """Create a temporary VkInstance, enumerate physical devices, destroy.
853 Mirrors what ``vulkaninfo --summary`` does internally. The
854 instance is short-lived (created and destroyed in the same call)
855 so the probe leaves no driver state behind.
856 """
857 (
858 create_instance,
859 destroy_instance,
860 enum_physical,
861 get_properties,
862 get_memory,
863 ) = _resolve_vk_symbols(lib)
865 instance, api_version = _create_probe_instance(create_instance)
866 if instance is None:
867 return []
868 core_1_1 = api_version >= _VK_API_VERSION_1_1
869 get_properties2 = _resolve_properties2(lib) if core_1_1 else None
870 get_features2 = _resolve_features2(lib) if core_1_1 else None
871 memory_budget = _resolve_memory_budget(lib) if core_1_1 else None
873 try:
874 count = c_uint32(0)
875 result = enum_physical(instance, byref(count), None)
876 if result != _VK_SUCCESS or count.value == 0:
877 return []
878 handles = (c_void_p * count.value)()
879 result = enum_physical(instance, byref(count), handles)
880 if result != _VK_SUCCESS:
881 return []
882 devices: list[VulkanDevice] = []
883 for i in range(count.value):
884 # Indexing the array yields a bare address; the queries below take a
885 # handle, so make it one rather than widen every signature to int.
886 handle = c_void_p(handles[i])
887 props = _VkPhysicalDeviceProperties()
888 get_properties(handle, byref(props))
889 mem = _VkPhysicalDeviceMemoryProperties()
890 get_memory(handle, byref(mem))
891 devices.append(
892 VulkanDevice(
893 index=i,
894 device_type=int(props.deviceType),
895 device_name=props.deviceName.decode("utf-8", errors="replace"),
896 vendor_id=int(props.vendorID),
897 vram_bytes=_device_local_vram(mem),
898 device_uuid=_device_uuid(handle, get_properties2),
899 storage_buffer_16bit=_storage_buffer_16bit(handle, get_features2),
900 free_bytes=_free_device_local_bytes(handle, memory_budget),
901 )
902 )
903 return devices
904 finally:
905 destroy_instance(instance, None)
908def _resolve_vk_symbols(
909 lib: ctypes.CDLL,
910) -> tuple[
911 ctypes._FuncPointer,
912 ctypes._FuncPointer,
913 ctypes._FuncPointer,
914 ctypes._FuncPointer,
915 ctypes._FuncPointer,
916]:
917 """Look up the five Vulkan symbols this probe needs and stamp argtypes.
919 All argtypes / restypes are set here so ctypes uses the same
920 calling convention as the C ABI; missing this on Windows produces
921 silent stack corruption.
922 """
923 create_instance = lib.vkCreateInstance
924 create_instance.argtypes = [
925 POINTER(_VkInstanceCreateInfo),
926 c_void_p,
927 POINTER(c_void_p),
928 ]
929 create_instance.restype = c_uint32
931 destroy_instance = lib.vkDestroyInstance
932 destroy_instance.argtypes = [c_void_p, c_void_p]
933 destroy_instance.restype = None
935 enum_physical = lib.vkEnumeratePhysicalDevices
936 enum_physical.argtypes = [c_void_p, POINTER(c_uint32), POINTER(c_void_p)]
937 enum_physical.restype = c_uint32
939 get_properties = lib.vkGetPhysicalDeviceProperties
940 get_properties.argtypes = [c_void_p, POINTER(_VkPhysicalDeviceProperties)]
941 get_properties.restype = None
943 get_memory = lib.vkGetPhysicalDeviceMemoryProperties
944 get_memory.argtypes = [c_void_p, POINTER(_VkPhysicalDeviceMemoryProperties)]
945 get_memory.restype = None
947 return create_instance, destroy_instance, enum_physical, get_properties, get_memory
950def _device_local_vram(mem_props: _VkPhysicalDeviceMemoryProperties) -> int:
951 """Sum the device-local heap sizes (bytes), the cross-vendor VRAM signal."""
952 total = 0
953 for i in range(mem_props.memoryHeapCount):
954 heap = mem_props.memoryHeaps[i]
955 if heap.flags & _VK_MEMORY_HEAP_DEVICE_LOCAL_BIT:
956 total += int(heap.size)
957 return total
960# Single-vendor boxes don't need a pin -- only that vendor's ICD loads,
961# no cross-vendor collision possible.
962_MIN_VENDORS_FOR_CONFLICT = 2
964# Pin priority on dual-vendor hosts. NVIDIA wins because the documented
965# crash signature is AMDVLK alongside NVIDIA (Khronos forum,
966# SHARK-Studio#1636) and NVIDIA is the more common dGPU on those boxes.
967# AMD-then-Intel covers AMD-discrete + Intel-iGPU laptops.
968_PREFERRED_VENDOR_ORDER: tuple[PCIVendorID, ...] = (
969 PCIVendorID.NVIDIA,
970 PCIVendorID.AMD,
971 PCIVendorID.INTEL,
972)
975def _icds_to_disable(best: PCIVendorID, all_vendors: set[PCIVendorID]) -> list[str]:
976 """Return the manifest globs for every known vendor except *best*."""
977 globs: list[str] = []
978 for vendor in sorted(all_vendors, key=int):
979 if vendor is best:
980 continue
981 globs.extend(_VENDOR_ICD_GLOBS[vendor])
982 return globs
985def _classify_manifest_vendor(manifest_filename: str) -> PCIVendorID | None:
986 """Map a manifest filename to its GPU vendor via ``_VENDOR_ICD_GLOBS``."""
987 name = manifest_filename.lower()
988 for vendor, globs in _VENDOR_ICD_GLOBS.items():
989 for glob in globs:
990 if fnmatch.fnmatchcase(name, glob.lower()):
991 return vendor
992 return None
995def _vulkan_vendors_present() -> set[PCIVendorID]:
996 """Vendors with at least one installed Vulkan ICD on this host."""
997 vendors: set[PCIVendorID] = set()
998 for manifest_path in iter_vulkan_manifest_paths():
999 # ntpath.basename splits on both '\\' and '/', so it handles
1000 # Windows-registry paths and Linux Path.__str__() output uniformly.
1001 filename = ntpath.basename(manifest_path)
1002 vendor = _classify_manifest_vendor(filename)
1003 if vendor is not None:
1004 vendors.add(vendor)
1005 return vendors
1008def _select_best_vendor(vendors: set[PCIVendorID]) -> PCIVendorID | None:
1009 """First match against ``_PREFERRED_VENDOR_ORDER``, or ``None`` if empty."""
1010 for vendor in _PREFERRED_VENDOR_ORDER:
1011 if vendor in vendors:
1012 return vendor
1013 return None
1016def _vendors_with_hardware(vendors: set[PCIVendorID]) -> set[PCIVendorID]:
1017 """The subset of *vendors* whose silicon is in this host's device tree.
1019 An installed ICD proves a driver is present, not a card: Mesa ships
1020 ``radeon_icd`` beside ``intel_icd`` on every Linux desktop, so an Intel-only
1021 laptop reads as dual-vendor and the preference order would pick AMD and
1022 disable the one ICD that drives the machine. Empty when the device tree
1023 cannot be read, which leaves the choice to the manifests alone.
1024 """
1025 present = installed_gpu_vendor_ids()
1026 return {vendor for vendor in vendors if vendor in present}
1029def _platform_supports_icd_pin() -> bool:
1030 """True on Windows + Linux, where dual-vendor ICD crashes are documented."""
1031 return sys.platform == "win32" or sys.platform.startswith("linux")
1034# References for the dual-vendor ICD mitigation below:
1035# - Khronos Vulkan-Loader env var spec (VK_LOADER_DRIVERS_DISABLE / VK_DRIVER_FILES):
1036# https://github.com/KhronosGroup/Vulkan-Loader/blob/main/docs/LoaderInterfaceArchitecture.md
1037# - ICD manifest filename conventions and Windows registry discovery order:
1038# https://github.com/KhronosGroup/Vulkan-Loader/blob/main/docs/LoaderDriverInterface.md
1039# - "Failure in one ICD causes total failure of vkEnumeratePhysicalDevices":
1040# https://github.com/KhronosGroup/Vulkan-Loader/issues/1467
1041# - Khronos forum: amdvlk64.dll crashes in vkCreateInstance on mixed-vendor hosts:
1042# https://community.khronos.org/t/crash-in-amdvlk64-dll-during-vkcreateinstance/105022
1043# - SHARK-Studio #1636 (the same crash hits another Python ML inference tool):
1044# https://github.com/nod-ai/SHARK-Studio/issues/1636
1045# - Steam overlay multi-VkDevice crash on Linux (ValveSoftware/steam-for-linux#9120):
1046# https://github.com/ValveSoftware/steam-for-linux/issues/9120
1047# - Mesa RADV pipeline-creation heap corruption (ggml-org/llama.cpp#22128):
1048# https://github.com/ggml-org/llama.cpp/issues/22128
1049# - NVIDIA help article 5182, dual-vendor Vulkan apps on notebooks:
1050# https://nvidia.custhelp.com/app/answers/detail/a_id/5182/
1051# - Heroic Games Launcher ICD-selection issue (same mitigation pattern in prod):
1052# https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/issues/3796
1053# - Blender Vulkan backend startup failure on dual-vendor hosts:
1054# https://projects.blender.org/blender/blender/issues/129917
1055def _icd_pin_is_ours_to_make() -> bool:
1056 """Whether lilbee may set the ICD disable list at all.
1058 Not on a platform with no documented dual-vendor crash class, not when the
1059 caller has already set any of the loader's own variables, and not when a
1060 gpu_devices pin has already named the hardware to use. Each is somebody
1061 else's decision arriving first.
1062 """
1063 from lilbee.core.config import cfg
1065 if not _platform_supports_icd_pin():
1066 return False
1067 if any(os.environ.get(env_var) for env_var in VulkanIcdEnvVar):
1068 return False
1069 return not cfg.gpu_devices
1072def disable_conflicting_vulkan_icds() -> str | None:
1073 """Manifest-filename glob list of non-preferred ICDs to disable, or ``None``.
1075 Preferred-vendor order is NVIDIA > AMD > Intel, applied to the vendors whose
1076 hardware this host actually has, so the survivor is always a driver for a card
1077 that is present. Returns ``None`` when the user has pinned a GPU, when fewer
1078 than two vendors are installed, or when the platform has no documented
1079 dual-vendor crash class. Discovery reads manifests from disk (registry on
1080 Windows, XDG on Linux) and the device tree from the OS; enumerating via
1081 ``vkCreateInstance`` would pre-load every vendor's ICD before the disable lands.
1082 """
1083 if not _icd_pin_is_ours_to_make():
1084 return None
1085 vendors = _vulkan_vendors_present()
1086 if len(vendors) < _MIN_VENDORS_FOR_CONFLICT:
1087 return None
1088 with_hardware = _vendors_with_hardware(vendors)
1089 if not with_hardware:
1090 # The device tree could not be read, so nothing here is known to be
1091 # present. Ranking the manifests alone is how a host whose /sys is
1092 # masked, or WSL2, or an ARM SoC whose GPU is not on the PCI bus, could
1093 # have its only working ICD disabled by a static vendor order. Silence is
1094 # the safe answer: an extra ICD risks the crash class this avoids, while
1095 # disabling the wrong one costs the GPU outright.
1096 log.debug(
1097 "Not disabling any Vulkan ICD: none of the %d manifest vendors could be "
1098 "confirmed present in this host's device tree, so which one drives this "
1099 "machine is unknown.",
1100 len(vendors),
1101 )
1102 return None
1103 best = _select_best_vendor(with_hardware)
1104 if best is None: # pragma: no cover - invariant: with_hardware is non-empty here
1105 return None
1106 return ",".join(_icds_to_disable(best, vendors))