Coverage for src/lilbee/providers/fleet/devices.py: 100%

201 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Enumerate and pin GPUs using the llama-server binary's own device view. 

2 

3The hazard this avoids: a device index from one API (Vulkan) is meaningless to 

4another (CUDA); the same ordinal can be a different physical card. So both 

5enumeration and pinning go through the binary's native backend index space, 

6obtained from ``llama-server --list-devices``. The Vulkan VRAM probe is only a 

7fallback when the binary can't enumerate. See docs/architecture.md. 

8""" 

9 

10from __future__ import annotations 

11 

12import logging 

13import os 

14import re 

15import subprocess 

16from dataclasses import dataclass 

17from pathlib import Path 

18 

19from lilbee.providers.base import ProviderError, ProviderErrorKind 

20from lilbee.providers.fleet.gpu_select import USABLE_VULKAN_TYPES, VkDeviceType 

21from lilbee.providers.fleet.proc import run_bounded 

22 

23log = logging.getLogger(__name__) 

24 

25_PROVIDER = "llama-server" 

26_LIST_DEVICES_TIMEOUT_S = 60.0 

27# How long to wait for a killed probe to be reaped before abandoning it: a child 

28# wedged in uninterruptible GPU-driver I/O ignores even SIGKILL. 

29_PROBE_KILL_WAIT_S = 5.0 

30# How much of the probe's own output to quote in a diagnostic. Enough to carry 

31# the driver's error line, short enough to stay a readable message. 

32_PROBE_TAIL_CHARS = 400 

33_TOPO_TIMEOUT_S = 15.0 

34_GPU_LABEL_RE = re.compile(r"^GPU(\d+)$") 

35# llama-server prints this before the device loop, so a run that lists no GPUs 

36# still prints it. Its absence means the binary never got as far as enumerating. 

37_DEVICE_LIST_HEADER = "Available devices:" 

38# nvidia-smi emits SGR escapes (e.g. an underlined header) even when stdout is 

39# not a tty; strip them or the header's GPU labels never match. 

40_ANSI_SGR_RE = re.compile(r"\x1b\[[0-9;]*m") 

41# A topo-matrix header is 2+ leading GPU labels; a data row has exactly one. And 

42# a link needs at least two GPUs to exist between. 

43_TOPO_MIN_GPUS = 2 

44MIB = 1024 * 1024 

45# Per-backend visible-devices env vars (the probe inherits them; the children 

46# re-emit them, composed through any parent restriction). 

47_CUDA_VISIBLE_VAR = "CUDA_VISIBLE_DEVICES" 

48_CUDA_ORDER_VAR = "CUDA_DEVICE_ORDER" 

49_PCI_BUS_ID_ORDER = "PCI_BUS_ID" 

50_ROCR_VISIBLE_VAR = "ROCR_VISIBLE_DEVICES" 

51_HIP_VISIBLE_VAR = "HIP_VISIBLE_DEVICES" 

52# ROCm's third numeric visibility variable, filtering exactly as the other two do. 

53_GPU_DEVICE_ORDINAL_VAR = "GPU_DEVICE_ORDINAL" 

54_VK_VISIBLE_VAR = "GGML_VK_VISIBLE_DEVICES" 

55# " CUDA0: NVIDIA GeForce RTX 3090 (24268 MiB, 23500 MiB free)" 

56_DEVICE_RE = re.compile( 

57 r"^\s*([A-Za-z]+)(\d+):\s*(.+?)\s*\((\d+)\s*MiB(?:,\s*(\d+)\s*MiB\s*free)?\)\s*$" 

58) 

59# Pin priority when a build reports more than one GPU backend: a real GPU 

60# backend always wins over Vulkan, which wins over CPU. 

61# The engine's own name for the backend. Vendor-agnostic, so several rules key 

62# on it: a Vulkan device's type has to be asked of the loader, and its util 

63# source is chosen by the vendor in its device name rather than by the backend. 

64VULKAN_BACKEND = "Vulkan" 

65_BACKEND_RANK = {"CUDA": 3, "ROCm": 3, "HIP": 3, "MTL": 3, "Metal": 3, "SYCL": 2, VULKAN_BACKEND: 1} 

66# Backends whose memory is always the host's: Apple Silicon reports a working-set 

67# slice of system RAM, never a dedicated pool. 

68_UNIFIED_BACKENDS = frozenset({"MTL", "Metal"}) 

69# Below this, a reported total is a BIOS carveout rather than a card's own pool. 

70# An APU hands out a fixed slice of system RAM as "VRAM", often a few hundred 

71# MiB, and planned as a dedicated device that size it refuses every role while 

72# the machine has the whole system's memory to share. No real discrete GPU worth 

73# serving from ships with less. 

74_DEDICATED_VRAM_FLOOR = 2 * 1024 * MIB 

75 

76 

77@dataclass(frozen=True) 

78class FleetDevice: 

79 """One GPU as the binary's backend enumerates it (native index space).""" 

80 

81 backend: str 

82 index: int 

83 name: str 

84 total_bytes: int 

85 free_bytes: int 

86 # Whether this device's memory is the host's memory. An integrated GPU or an 

87 # Apple Silicon Mac has no dedicated VRAM, so its reported total is a slice 

88 # of the same RAM the OS and every other process is using, and placement 

89 # must stay inside the system budget rather than treating it as headroom. 

90 unified: bool = False 

91 # Whether this device came from the host's Vulkan loader rather than from the 

92 # engine's own listing. Its index is then a raw loader ordinal, which is a 

93 # different space from the one the engine names its devices in, so it can be 

94 # sized against but never pinned by. 

95 from_loader: bool = False 

96 

97 

98@dataclass(frozen=True) 

99class DeviceProbe: 

100 """The device probe's parsed devices plus its raw output for diagnostics.""" 

101 

102 devices: list[FleetDevice] 

103 output: str 

104 # Whether the engine answered --list-devices at all: exited cleanly and 

105 # printed the header it always prints. False means the binary does not speak 

106 # this protocol (a build predating the flag prints usage text and exits 

107 # non-zero), so its silence about devices is not a statement that there are 

108 # none. Defaults False so a probe that never ran is never mistaken for one 

109 # that ran and found nothing. 

110 spoke_protocol: bool = False 

111 # Whether the engine listed GPU devices and every one was rejected. Distinct 

112 # from a host that simply has none: the engine will still pick one of those 

113 # devices at launch unless it is told not to. 

114 refused_all: bool = False 

115 

116 

117def _parse_topo_matrix(topo_text: str) -> tuple[set[int], set[frozenset[int]]]: 

118 """GPU row indices and NVLink-joined pairs from ``nvidia-smi topo -m`` output. 

119 

120 The matrix header row labels the GPU columns; each ``GPU<r>`` row lists the 

121 link type to each column (``NV#`` is NVLink; ``PIX``/``PHB``/``SYS`` are PCIe). 

122 """ 

123 header_cols: list[int] = [] 

124 gpu_rows: set[int] = set() 

125 pairs: set[frozenset[int]] = set() 

126 for line in _ANSI_SGR_RE.sub("", topo_text).splitlines(): 

127 tokens = line.split() 

128 # Leading run of GPU-label tokens: the header is all labels (>=2), a data 

129 # row is one label ("GPU3") followed by link-type cells. split() strips the 

130 # header's leading whitespace, so this run length is what tells them apart. 

131 leading_labels: list[int] = [] 

132 for token in tokens: 

133 match = _GPU_LABEL_RE.match(token) 

134 if match is None: 

135 break 

136 leading_labels.append(int(match.group(1))) 

137 if len(leading_labels) >= _TOPO_MIN_GPUS: 

138 header_cols = leading_labels 

139 elif len(leading_labels) == 1: 

140 row_idx = leading_labels[0] 

141 gpu_rows.add(row_idx) 

142 for col_idx, cell in zip(header_cols, tokens[1:], strict=False): 

143 if row_idx != col_idx and cell.startswith("NV"): 

144 pairs.add(frozenset({row_idx, col_idx})) 

145 return gpu_rows, pairs 

146 

147 

148def host_lacks_nvlink() -> bool: 

149 """Whether this host's GPUs are joined only by PCIe (no NVLink anywhere). 

150 

151 Tensor-splitting a large model across PCIe-only cards is all-reduce bound and 

152 much slower than over NVLink. Deliberately a host-level claim: the fleet's 

153 device indices live in the serving binary's backend index space, which does 

154 not map onto ``nvidia-smi``'s physical numbering under a visible-devices 

155 restriction (the very hazard this module exists to avoid), so per-pair 

156 verdicts against plan indices would be unreliable. Returns False (no claim) 

157 when the probe fails or reports fewer than two GPUs, so a non-NVIDIA or 

158 single-card host stays silent rather than warning wrongly. 

159 """ 

160 try: 

161 stdout, _ = run_bounded( 

162 ["nvidia-smi", "topo", "-m"], 

163 timeout_s=_TOPO_TIMEOUT_S, 

164 kill_wait_s=_PROBE_KILL_WAIT_S, 

165 label="nvidia-smi topo", 

166 ) 

167 except (OSError, subprocess.SubprocessError): 

168 return False 

169 gpu_rows, pairs = _parse_topo_matrix(stdout) 

170 return len(gpu_rows) >= _TOPO_MIN_GPUS and not pairs 

171 

172 

173def _probe_env() -> dict[str, str]: 

174 """Env for the probe: stable PCI ordering so CUDA indices match what we pin. 

175 

176 A preset ``CUDA_DEVICE_ORDER`` is respected; ``visible_env`` re-emits the same 

177 order var, so the probe and the spawned servers see one device ordering. 

178 """ 

179 env = dict(os.environ) 

180 env.setdefault(_CUDA_ORDER_VAR, _PCI_BUS_ID_ORDER) 

181 return env 

182 

183 

184def probe_devices(binary: Path, *, timeout_s: float = _LIST_DEVICES_TIMEOUT_S) -> DeviceProbe: 

185 """Parse ``<binary> --list-devices``; empty devices when unavailable/unparseable. 

186 

187 Filtered to a single GPU backend (the highest-ranked one present) so device 

188 indices are unambiguous when a build exposes several backends. A probe that 

189 does not respond within *timeout_s* raises a ``ProviderError`` naming the 

190 stuck probe: that is a wedged GPU driver, not a GPU-less host, and treating 

191 it as "no devices" would silently plan a CPU fleet on a GPU box. 

192 """ 

193 try: 

194 output, returncode = _run_list_devices(binary, timeout_s) 

195 except (OSError, subprocess.SubprocessError) as exc: 

196 # Silently returning an empty probe here made an unrunnable binary look 

197 # exactly like a host with no GPU, and the fleet planned for CPU with 

198 # nothing said. The reason is the whole diagnosis: a wrong architecture, 

199 # a missing loader, a permission denial. 

200 log.warning( 

201 "Could not run the GPU device probe (%s --list-devices): %s. Continuing " 

202 "as though this host has no GPU; check that the engine binary is " 

203 "executable and built for this machine.", 

204 binary.name, 

205 exc, 

206 ) 

207 return DeviceProbe([], "") 

208 parsed = _parse_devices(output) 

209 selected = _select_backend(parsed) 

210 offered = [d for d in parsed if d.backend in _BACKEND_RANK] 

211 answered = _DEVICE_LIST_HEADER in output 

212 spoke = returncode == 0 and answered 

213 if not spoke and answered: 

214 # It knew the flag and started answering, then died. Blaming the flag 

215 # here sent the reader looking for the wrong engine build, when what 

216 # they have is a crash partway through enumeration. 

217 log.warning( 

218 "%s --list-devices printed its device header then crashed (exit %d), so the " 

219 "device list may be incomplete. This is usually a GPU driver or ICD fault " 

220 "during enumeration. The probe reported: %s", 

221 binary.name, 

222 returncode, 

223 _probe_tail(output), 

224 ) 

225 elif not spoke: 

226 log.warning( 

227 "%s --list-devices exited %d without printing its device header, so it " 

228 "does not appear to support the flag. Falling back to the host's Vulkan " 

229 "loader to find GPUs; set %s if this is not the engine you meant to use.", 

230 binary.name, 

231 returncode, 

232 "LILBEE_ENGINE_DIR", 

233 ) 

234 return DeviceProbe( 

235 selected, output, spoke_protocol=spoke, refused_all=bool(offered) and not selected 

236 ) 

237 

238 

239def _run_list_devices(binary: Path, timeout_s: float) -> tuple[str, int]: 

240 """Run the probe with a bounded reap; raise on timeout. 

241 

242 A probe wedged in uninterruptible GPU-driver I/O would otherwise hang the 

243 caller forever, since ``subprocess.run``'s timeout waits unbounded for the 

244 reap; ``run_bounded`` abandons an unkillable child after a short wait. 

245 

246 The probe holds a device context and writes no state file, so nothing can reap 

247 it later by record. It is the one caller that opts into the lifetime binding, 

248 where the kernel offers one, and it is killed on the way out of every abort, 

249 not just the timeout. 

250 """ 

251 try: 

252 return run_bounded( 

253 [str(binary), "--list-devices"], 

254 timeout_s=timeout_s, 

255 kill_wait_s=_PROBE_KILL_WAIT_S, 

256 env=_probe_env(), 

257 merge_stderr=True, 

258 label=f"{binary.name} --list-devices", 

259 bind_lifetime=True, 

260 ) 

261 except subprocess.TimeoutExpired as exc: 

262 # Whatever the probe managed to print before it wedged says more than any 

263 # fixed advice can, and the fixed advice named one vendor's tool at a host 

264 # that may have neither that vendor nor that tool. 

265 raise ProviderError( 

266 f"The GPU device probe ({binary.name} --list-devices) did not respond " 

267 f"within {timeout_s:.0f}s, so the engine cannot start. The GPU driver is " 

268 "most likely wedged; check that your vendor's tool responds (nvidia-smi, " 

269 "rocm-smi, xpu-smi) and reboot the host if it hangs.\n" 

270 f"The probe reported: {_probe_tail(_decoded_output(exc.output))}", 

271 provider=_PROVIDER, 

272 kind=ProviderErrorKind.SERVER, 

273 ) from None 

274 

275 

276def _decoded_output(output: object) -> str: 

277 """Partial child output from a timeout, which arrives as bytes even under text mode.""" 

278 if isinstance(output, bytes): 

279 return output.decode(errors="replace") 

280 return output if isinstance(output, str) else "" 

281 

282 

283def _probe_tail(output: str) -> str: 

284 """The tail of what the probe printed, for a message that has to stay readable.""" 

285 text = output.strip() 

286 return text[-_PROBE_TAIL_CHARS:] if text else "(nothing)" 

287 

288 

289def _parse_devices(text: str) -> list[FleetDevice]: 

290 devices: list[FleetDevice] = [] 

291 # Sampled at most once per parse, and only when a line actually needs it: 

292 # free memory is live, so it is read fresh here rather than cached, and the 

293 # loader must not be opened once per device line to answer the same question. 

294 loader_free: dict[str, int] | None = None 

295 for line in text.splitlines(): 

296 match = _DEVICE_RE.match(line) 

297 if match is None: 

298 continue 

299 backend, index, name, total_mib, free_mib = match.groups() 

300 total = int(total_mib) * MIB 

301 if total == 0: 

302 # No memory is not a small GPU, it is one that cannot hold a model: 

303 # a driver listing an adapter before its memory is queryable. Kept, it 

304 # is the smallest card in the fleet and collapses every budget sized 

305 # against the smallest, while the non-empty list switches off the 

306 # shared-memory budget a host with no usable GPU depends on. 

307 log.warning( 

308 "Ignoring GPU %s%s (%s): it reports no memory, so nothing can be " 

309 "placed on it. Check the GPU driver if this device should be usable.", 

310 backend, 

311 index, 

312 name.strip(), 

313 ) 

314 continue 

315 if free_mib: 

316 free = int(free_mib) * MIB 

317 else: 

318 if loader_free is None: 

319 loader_free = _loader_free_bytes(backend) 

320 free = loader_free.get(name.strip(), total) 

321 devices.append( 

322 FleetDevice( 

323 backend, 

324 int(index), 

325 name.strip(), 

326 total, 

327 free, 

328 unified=_is_unified(backend, name.strip()) or total < _DEDICATED_VRAM_FLOOR, 

329 ) 

330 ) 

331 return devices 

332 

333 

334# Mesa and friends expose CPU rasterizers through the Vulkan loader, and 

335# llama.cpp's Vulkan backend enumerates them exactly like a GPU: same 

336# "VulkanN: <name> (<total> MiB, <free> MiB free)" shape, with system RAM 

337# reported as VRAM. Planning against one is worse than having no GPU at all, 

338# because the "VRAM" looks enormous: a host with a real iGPU beside lavapipe 

339# can be planned as a two-GPU machine and tensor-split across a real adapter 

340# and a software renderer, which runs orders of magnitude slower than either 

341# CPU inference or the iGPU alone. 

342_SOFTWARE_RENDERER_MARKERS = ("llvmpipe", "lavapipe", "softpipe", "swiftshader") 

343 

344 

345def _is_software_renderer(device: FleetDevice) -> bool: 

346 """Whether *device* is a CPU rasterizer masquerading as a GPU. 

347 

348 A name test, so it only recognizes the rasterizers it already knows, and a 

349 renamed or newly written one walks past it. It stays as the answer for hosts 

350 where the Vulkan loader can't be opened from this process and the device 

351 type is therefore unavailable; where the type is available, 

352 ``_is_unusable_vulkan`` decides and this never gets the chance to be wrong. 

353 """ 

354 name = device.name.casefold() 

355 return any(marker in name for marker in _SOFTWARE_RENDERER_MARKERS) 

356 

357 

358def _loader_free_bytes(backend: str) -> dict[str, int]: 

359 """Live free memory per device name, for a listing that printed no free figure. 

360 

361 ggml omits the figure when the driver has no ``VK_EXT_memory_budget``, and 

362 treating the omission as "all of it" is how a desktop holding gigabytes of 

363 compositor and browser VRAM was planned as an empty card. The loader exposes 

364 that extension to this process even when the engine build cannot use it, so 

365 it is asked directly; a name it cannot speak for keeps the heap size. 

366 

367 Empty for any other backend: the Vulkan loader knows nothing about the 

368 devices a CUDA or ROCm listing names. 

369 """ 

370 if backend != VULKAN_BACKEND: 

371 return {} 

372 from lilbee.providers.fleet.gpu_select import vulkan_free_bytes_by_name 

373 

374 return vulkan_free_bytes_by_name() 

375 

376 

377def _vulkan_device_type(name: str) -> VkDeviceType | None: 

378 """The loader's type for the Vulkan adapter the engine printed as *name*. 

379 

380 ``None`` when the loader can't be reached or reports no adapter by that 

381 name, which reads as "no opinion": the device is kept and assumed dedicated, 

382 preserving the behaviour of hosts that never had a type to consult. 

383 """ 

384 from lilbee.providers.fleet.gpu_select import vulkan_device_types_by_name 

385 

386 return vulkan_device_types_by_name().get(name) 

387 

388 

389def _is_unusable_vulkan(device: FleetDevice) -> bool: 

390 """Whether *device* is a Vulkan adapter ggml would not choose to run on. 

391 

392 ggml's Vulkan backend builds its device pool from discrete and integrated 

393 adapters only, and falls back to the first non-CPU adapter when it finds 

394 neither. In a VM that fallback is a paravirtual adapter (VMware SVGA, 

395 VirtIO-GPU Venus, QXL), which reports guest RAM as VRAM and is typically 

396 compute-incomplete or fails at allocation. Planning a fleet onto one costs 

397 more than planning no GPU at all, since a non-empty device list also turns 

398 off the shared-RAM budget. 

399 

400 Only a positive claim counts. VIRTUAL_GPU and CPU are the loader naming what 

401 the adapter is; OTHER is it declining to, and refusing on a shrug took the 

402 GPU away from real hardware whose driver simply does not classify itself. 

403 """ 

404 if device.backend != VULKAN_BACKEND: 

405 return False 

406 device_type = _vulkan_device_type(device.name) 

407 if device_type is None or device_type in USABLE_VULKAN_TYPES: 

408 return False 

409 # OTHER is the loader shrugging, not an accusation. The spec's own wording is 

410 # "does not match any other available types", which a driver reaches for when 

411 # it cannot classify itself, and some real adapters do. Refusing on it took a 

412 # working GPU away from a machine the engine had already listed one for. 

413 # VIRTUAL_GPU and CPU are positive claims and keep their veto. 

414 return device_type is not VkDeviceType.OTHER 

415 

416 

417def _is_unified(backend: str, name: str) -> bool: 

418 """Whether the device *backend* printed as *name* shares its memory with the host. 

419 

420 Metal is unified by construction on Apple Silicon: the figure it reports is 

421 ``recommendedMaxWorkingSetSize``, a slice of system RAM rather than a 

422 separate pool. For Vulkan the loader knows the device type, so the type is 

423 asked for rather than guessed; a size heuristic cannot work here, since a 

424 24 GB discrete card in a 32 GB host and an Apple GPU reporting two thirds of 

425 RAM are indistinguishable by proportion. 

426 

427 CUDA, ROCm and SYCL print no type at all, and an AMD APU or a Jetson looks 

428 exactly like a discrete card there while reporting system RAM as its memory. 

429 Those fall back to a question about the machine rather than the device: a 

430 host whose Vulkan loader sees adapters but no discrete one has no discrete 

431 GPU for another backend to be enumerating. 

432 """ 

433 if backend in _UNIFIED_BACKENDS: 

434 return True 

435 if backend == VULKAN_BACKEND: 

436 return _vulkan_device_type(name) is VkDeviceType.INTEGRATED_GPU 

437 from lilbee.providers.fleet.gpu_select import host_has_no_discrete_gpu 

438 

439 return host_has_no_discrete_gpu() 

440 

441 

442def _select_backend(devices: list[FleetDevice]) -> list[FleetDevice]: 

443 """Keep one GPU backend's devices: highest rank, then most memory. 

444 

445 Returns a single backend so pinning is unambiguous: ``visible_env`` keys off 

446 one backend, and mixing index spaces is the very hazard this module avoids. 

447 

448 CUDA, ROCm, HIP and Metal all rank alike, and a build that loads several 

449 backends (``ggml_backend_load_all`` does) makes the tie real. Breaking it on 

450 the backend's name meant a host with a 4090 beside an RX 6600 planned onto 

451 the AMD card because "ROCm" sorts after "CUDA", and the NVIDIA card idled 

452 with nothing said. Total memory decides instead; the name is only the last 

453 resort that keeps the choice deterministic. 

454 """ 

455 ranked = [ 

456 d 

457 for d in devices 

458 if d.backend in _BACKEND_RANK 

459 and not _is_software_renderer(d) 

460 and not _is_unusable_vulkan(d) 

461 ] 

462 if not ranked: 

463 return [] 

464 by_backend: dict[str, list[FleetDevice]] = {} 

465 for device in ranked: 

466 by_backend.setdefault(device.backend, []).append(device) 

467 backend, chosen = max(by_backend.items(), key=_backend_preference) 

468 for other, group in by_backend.items(): 

469 if other != backend: 

470 log.info( 

471 "Engine reports %d %s device(s) beside %d %s device(s); planning onto %s, " 

472 "which has more memory. Backends cannot be mixed: their device indexes " 

473 "name different cards.", 

474 len(group), 

475 other, 

476 len(chosen), 

477 backend, 

478 backend, 

479 ) 

480 return chosen 

481 

482 

483def _backend_preference(item: tuple[str, list[FleetDevice]]) -> tuple[int, int, int, str]: 

484 """Sort key for choosing one backend's devices: rank, dedicated bytes, size. 

485 

486 Dedicated bytes come before raw size because the discrete backends all tie at 

487 the same rank, and a shared-heap carveout reports a total that is host RAM 

488 the host budget already counts. Left on raw size, an APU advertising a large 

489 carveout beat a discrete card, which was then discarded and left idle while 

490 the plan double-promised memory it did not have. 

491 """ 

492 backend, group = item 

493 dedicated = sum(d.total_bytes for d in group if not d.unified) 

494 return _BACKEND_RANK[backend], dedicated, sum(d.total_bytes for d in group), backend 

495 

496 

497def _compose_visible(indices: list[int], parent_value: str | None) -> str: 

498 """Visible-devices value naming the same physical devices the probe saw. 

499 

500 When the parent env already restricts the var, the probe's indices are 

501 relative to that comma-separated list (integer or UUID entries), so each 

502 index maps through it; the child's value then names the same physical 

503 devices instead of being re-interpreted as absolute. 

504 """ 

505 if parent_value is None: 

506 return ",".join(str(i) for i in indices) 

507 entries = [entry.strip() for entry in parent_value.split(",") if entry.strip()] 

508 out: list[str] = [] 

509 for i in indices: 

510 if i >= len(entries): 

511 # The probe enumerates devices under the parent restriction, so every 

512 # index must map into it. An out-of-range index is an invariant 

513 # violation; emitting a bare ``str(i)`` would pin an absolute integer 

514 # into a possibly UUID-namespaced list, silently selecting the wrong 

515 # GPU. Fail loudly instead. 

516 raise ValueError( 

517 f"device index {i} is outside the parent visible-devices list " 

518 f"{parent_value!r}; cannot compose a child pin without selecting the wrong GPU" 

519 ) 

520 out.append(entries[i]) 

521 return ",".join(out) 

522 

523 

524def visible_env(devices: tuple[FleetDevice, ...]) -> dict[str, str]: 

525 """Env that pins a child to *devices* via the right var for their backend. 

526 

527 Indices are the backend-native ones from ``probe_devices``, composed through 

528 any parent visible-devices restriction so the child names the same physical 

529 devices the probe enumerated; no cross-API index translation occurs. 

530 """ 

531 if not devices: 

532 return {} 

533 backend = devices[0].backend 

534 indices = [d.index for d in devices] 

535 if backend == "CUDA": 

536 return { 

537 _CUDA_VISIBLE_VAR: _compose_visible(indices, os.environ.get(_CUDA_VISIBLE_VAR)), 

538 _CUDA_ORDER_VAR: os.environ.get(_CUDA_ORDER_VAR, _PCI_BUS_ID_ORDER), 

539 } 

540 if backend in ("ROCm", "HIP"): 

541 return _amd_visible_env(indices) 

542 if backend == VULKAN_BACKEND: 

543 # Deliberately not GGML_VK_VISIBLE_DEVICES. That variable indexes the raw 

544 # loader enumeration, while these indices come from the engine's own 

545 # filtered list, so the two disagree wherever ggml drops or merges a 

546 # device -- two ICDs for one card being the clear case. Setting it also 

547 # disables ggml's type filter, support check and dedup. Vulkan is pinned 

548 # with --device instead, in the same space the names were parsed from. 

549 return {} 

550 if backend == "SYCL": 

551 # Deliberately no ONEAPI_DEVICE_SELECTOR. It is a selector grammar over a 

552 # backend runtime, not the index space --list-devices numbers, so a 

553 # composed level_zero ordinal can name a different physical card than the 

554 # one the probe enumerated. SYCL pins by --device instead, in the space 

555 # the indices were read from. An inherited parent selector still applies: 

556 # the engine enumerated behind it, so its names are already relative to it. 

557 return {} 

558 return {} 

559 

560 

561def amd_visible_var() -> str: 

562 """The one AMD visibility var an index list may be written to. 

563 

564 ``ROCR_VISIBLE_DEVICES`` and ``HIP_VISIBLE_DEVICES`` are applied sequentially: 

565 ROCr filters first, then HIP re-indexes within the survivors. Writing the same 

566 indices to both double-filters and selects the wrong cards, or none at all. 

567 ``GPU_DEVICE_ORDINAL`` is the third and filters the same way, so writing HIP 

568 over an ordinal mask both overrides it and re-exposes cards it had hidden. 

569 

570 So exactly one is ever written: whichever the environment already restricts, 

571 in the runtime's precedence (HIP, then the ordinal, then ROCr), or HIP when 

572 nothing restricts. An empty value means "no devices" rather than "this is the 

573 variable in use", so it does not claim precedence. Every caller writing an AMD 

574 pin asks here; two callers each picking their own would put the pair back. 

575 """ 

576 for name in (_HIP_VISIBLE_VAR, _GPU_DEVICE_ORDINAL_VAR, _ROCR_VISIBLE_VAR): 

577 if os.environ.get(name, "").strip(): 

578 return name 

579 return _HIP_VISIBLE_VAR 

580 

581 

582def _amd_visible_env(indices: list[int]) -> dict[str, str]: 

583 """Pin an AMD ROCm/HIP child to the probe's *indices* with one visibility var. 

584 

585 The probe enumerated a single index space already filtered by whichever var 

586 the parent set, so the chosen var is composed against that parent value and 

587 the other is left inherited untouched. The child inherits the parent env, so 

588 an unset override keeps any inherited sibling var in force. 

589 """ 

590 var = amd_visible_var() 

591 return {var: _compose_visible(indices, os.environ.get(var))}