unsloth/studio/backend/tests/test_diffusion_device.py
Maheswar Kumar e7ea3cb979
studio: honour the gpu selection for image and video loads (#8645)
* studio: honour the gpu selection for image and video loads

The GPU picker was wired into the chat runtime and training only. Neither /images/load nor
/video/load carried gpu_ids at all, so both engines placed every module on ordinal 0 whatever
the user selected. On a mixed box that is frequently the smaller card, and for a checkpoint
that only fits on the larger one it decides whether the load runs at all.

Both request models, both routes and both begin_load chains now take gpu_ids, matching the
LLM path. Neither engine shards a diffusion checkpoint -- diffusers places whole components
and sd.cpp assigns whole modules per backend device -- so a selection of several cards
resolves to one, and the most free VRAM wins, the rule auto_select_gpu_ids already applies to
training. Picking the first id would land back on ordinal 0 whenever a user selects
everything, which is the case this exists to serve. An index this host does not have is
refused at the route, before the arbiter evicts chat.

For diffusers the index is a new DiffusionDeviceTarget field rather than part of device.
The memory, speed, attention and engine-routing policies all compare that string by value
against "cuda", so folding the index into it would make every one of them take its non-CUDA
branch and load the model with those optimisations silently disabled. Placement reads the new
torch_device; the policies keep reading device. The pin itself is torch.cuda.set_device,
applied where the target is built: it is thread-local, and the load, generate, ControlNet and
re-activation paths each resolve on their own thread. It is also the lever that moves the
offload budget, since diffusion_memory reads mem_get_info() for the current device, so the
weights and the budget they are sized against stay on the same card. The capability probe is
asked of the selected card too, or a mixed-generation box picks its dtype from the wrong GPU.

For sd.cpp the selection becomes --backend diffusion=CUDA<n>,te=<n>,vae=<n>, built after the
offload policy so --clip-on-cpu / --vae-on-cpu keep those modules on the CPU: they are the
deprecated spellings of te=cpu / vae=cpu, and the parser is last-wins, so pinning over them
would undo low_vram. Device names come from the load's own binary via --list-devices, and only
CUDA / ROCm names are matched, since Vulkan ordinals are a separate namespace with no defined
mapping to a physical index.

The image and video Advanced panels gain a GPU control, shown only where there is more than
one pinnable CUDA / ROCm device. A pick whose card has since disappeared falls back to
automatic rather than sending an index the backend would refuse.

Part of #8636.

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

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

* import pinnableGpuContext instead of relying on the re-export

The name was only re-exported from gpu-selection, not imported, so useDiffusionGpuChoices
failed the frontend build with TS2552.

* resolve the gpu selection once per load and pin every worker to it

The first cut resolved lazily from a candidate list, in the wrong id space, and pinned too
late. Each of those is a separate way to end up on the wrong card.

Physical ids now go through the hardware layer that owns the visibility mask.
resolve_requested_gpu_ids validates against the parent-visible set and get_parent_visible_gpu_ids
gives the order torch enumerates, so a physical id maps to its position in that list. Comparing
against torch.cuda.device_count() was wrong twice over: under CUDA_VISIBLE_DEVICES=4,5 it rejects
both valid picks, and under a reordered mask like 1,0 it accepts a pick that targets the other
card. Free VRAM is read on the translated ordinal for the same reason.

The winner is decided once, in begin_load, and carried to the worker as gpu_ordinal. Re-deriving
it per target meant the ranking changed the moment the checkpoint landed: the load picked the
emptiest card, filled it, and a generate-time resolution then moved the current device to a
different GPU while the pipeline stayed put. The resolved ordinal is committed onto _LoadState
with the pipeline, so a load in flight no longer moves the resident model's card, and a load that
fails before teardown leaves it alone.

Every worker that touches a loaded pipeline now pins through _state_device_target before it
builds any device object. torch.cuda.set_device is thread-local, so the load thread's pin did
nothing for the generate thread: image generation placed a freshly downloaded ControlNet with
state.device ("cuda", un-indexed) before the late pin, landing it on a different card than the
base pipeline, and the video generate worker never resolved a target at all, so its H3 memory
probe and every torch.Generator resolved against its own default device.

The precision gate runs against the selected card. assert_precision_available was reached before
the selection was known, so on heterogeneous GPUs it judged an explicit quantization scheme
against the default card: a valid Ampere-or-newer pick could be refused because GPU 0 is older,
or an unsupported one could pass preflight and fail only after eviction.

The sd.cpp --backend pin is built where the flags are handed to a binary, not once up front. A
deferred accelerator install, the post-download re-resolve, and a server start that falls back to
one-shot can all replace the build after the offload policy is computed, and the ggml device
names come from whichever one actually runs.

Both routes only validate a selection on a CUDA or ROCm target. Physical ids have no applicator
on XPU, MPS or CPU, and the request contract says to ignore them there, so validating turned a
documented no-op into a 400.

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

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

* judge every precision preflight and the h3 plan against the selected gpu

The rework carried the resolved ordinal inside begin_load, but three decisions upstream of it
still read the default card.

Both routes now resolve the selection once, through a shared _selected_gpu_ordinal that only runs
the CUDA resolver when the target reports a CUDA device, and pass the ordinal into every
assert_precision_available and assert_video_precision_available call, including the download-plan
preflights and the engine-switch re-check. Judging an explicit quantization scheme against the
default card refuses a pick the selected card supports, or passes one it cannot and fails after
eviction. The video load route also resolves the ordinal before its precision gate rather than
after it.

The MiniMax-H3 auto denoiser planner takes the ordinal too. It picks the file set and the memory
policy from device capacity, so against the wrong card it either chooses the pinned hosted INT8
denoiser without the dense fallback the destination cannot do without, or stages roughly 66 GB of
dense shards nothing opens.

The Advanced GPU control reads the torch inventory rather than the inference-backend-selected
list. On a host running a Vulkan llama.cpp build for chat with CUDA torch for diffusers, the
Vulkan branch returned entries marked diffusionPinnable false, so the control never appeared even
though the image and video routes honour those physical ids. The two runtimes are independent, so
the diffusion picker asks torch.

* scope the device pin and carry the ordinal into every remaining decision

Resolving a target with an ordinal is not the same as making that card current, and several
probes read the current device rather than the target.

The pin is now scoped where it has to be. Route preflights run on the asyncio.to_thread executor,
whose threads are reused, so an unrestored torch.cuda.set_device left this request's card current
for the next one, including an automatic load with no selection at all. diffusion_device_scope
restores the previous device, and both precision preflights use it around their probes, which
reach argument-less CUDA calls and bare "cuda" allocations. _resolve_device_target no longer pins
as a side effect: the dedicated load and generate workers take the permanent pin explicitly.

The ROCm dtype probe asks about the selected card. is_bf16_supported() takes no device argument,
so the only way to ask is to make that card current for the call; without it a bf16-capable pick
behind an older default was promoted to fp32, and the reverse attempted bf16 on a card that
cannot run it.

The route hands its ranked ordinal to begin_load instead of the raw list. Both were ranking from
live free VRAM, with the network preflight, engine activation and arbiter eviction in between, so
a scheme could be approved against one card and the checkpoint placed on another.

Prefetch and plan decisions read the selection too: the dense-quant probe and the pre-cast
text-encoder resolver take it from the load kwargs, the H3 auto denoiser planner takes it on the
download-plan path as well as the load path, and both plan routes pass it through. These size the
file set from device capacity, so against the wrong card they stage tens of GB the selected card
would replace, or omit what it needs and force an inline fallback after eviction.

The image and video plan requests send gpu_ids. Staging precedes loading, so a plan asked without
the pick was preflighted against the default card and could refuse a precision the selected card
supports before the correctly pinned load was ever reached.

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

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

* accept the ordinal in the native engine and stop the scope swallowing refusals

Two of these were regressions from the previous commit.

The route passes gpu_ordinal to whichever engine it activated, but the native sd.cpp backend
still accepted only gpu_ids, so every native GGUF image load raised an unexpected-keyword
TypeError before the background load started and the route answered 500. It takes the resolved
ordinal now, and stops re-ranking the raw list for itself.

diffusion_device_scope caught around its own yield, so an exception raised by the body was
swallowed and the generator yielded a second time, which contextlib turns into "generator did not
stop after throw()". An explicit-precision refusal reached the route as that instead of its real
message. Only entering the context is guarded now, and the body's exceptions travel untouched
while the previous device is still restored in a finally.

The rest are the same ordinal reaching further: the video pre-cast text-encoder resolver takes it
on both the plan and prefetch paths, the image DiT artifact planner reads it from the load kwargs
it already receives, and the H3 plan sizing uses the restoring scope rather than the permanently
pinning helper, since the plan route reaches it on a pooled executor thread.

The transformer-quant smoke cache is keyed by the card the probe ran on rather than by "cuda". A
pass on one selected GPU was standing in for a card that was never tested, and a failure on an
older card rejected the same scheme on a capable one.

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

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

* rank the download-plan gpu once per request

Both plan routes resolved the ordinal twice, once for the precision preflight and again for
download_plan. The preflight's smoke probe allocates on the card it tests and other workloads
move free VRAM too, so the second ranking could pick a different card and the plan then sized
its file set for a GPU the requested precision was never validated on.

* scope planning probes to the selected card and rank gpus only when training is idle

Removing the pin from _resolve_device_target left the planning helpers building an indexed
target without making that card current, and their selectors read the current device.

_dense_quant_prefetch_needed and _dit_prequant_plan_source now run their whole decision inside
diffusion_device_scope, so select_transformer_quant_scheme, resolve_dense_quant_candidate and
the memory snapshot all measure the card the load will use. The H3 plan scope had the same shape
of mistake: it closed right after building the target, leaving the capacity reader and the
encoder-scheme probe on the pooled thread's default card, so it now encloses the whole sizing
call.

The compile-cache fingerprint reads the current device instead of hardcoding index 0. A load
pinned to another card compiles for that architecture, and keying the bundle by GPU 0 let two
cards share or overwrite each other's artifacts.

Both plan routes resolve the GPU only after the training guard has answered. Ranking reads free
VRAM per candidate and opens a CUDA context on each, which is exactly what those routes refuse
to do while a training subprocess owns the cards.

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

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

* Studio: keep the diffusion GPU pin out of the offload policy

Two problems the device pin introduced into the native (sd.cpp) path, both
only visible once a card is actually selected.

The CPU-backend restart stopped working. `_restart_server_on_cpu_backend`
relaunches with `state.offload_flags` plus `--backend cpu`, and sd.cpp
declares `--backend` with `concat = ','`, so repeated values are JOINED into
one spec rather than replacing. An explicit per-module entry then outranks the
bare `cpu` default, so `diffusion=CUDA0,te=CUDA0,vae=CUDA0,cpu` still runs the
whole graph on the card that just aborted. The recovery that exists to survive
a ggml op the device cannot run became a silent no-op, and the abort recurs
immediately. Confirmed against the pinned prebuilt
(master-813-bfbef5b-u13b9d92), which logs the joined spec verbatim.

And the status lied about the offload. `status()` and the saved recipe derive
"was anything offloaded?" from whether the flag tuple is empty, which is how a
`fast` load reports `none`. The pin lives in the same tuple, so picking a GPU
reported CPU offload as on for a load that offloads nothing.

Both go through one helper, `without_device_backend_flags`, so the pin is
dropped where the flags are read as a policy and kept where they are argv.

Also: the native backend now resolves a bare `gpu_ids` itself, the way the
diffusers and video backends already do. The routes rank the selection and
pass the winner, so this only affects a direct caller, but it was the one
engine that dropped such a pick silently, and it leaves
`resolve_selected_cuda_ordinal` imported and unused.

Tests: the CPU restart argv, the `fast` and offloaded status pair, the native
self-resolve, both download-plan routes forwarding the selection and refusing a
bad index with a 400 (they had no coverage for this field, and they are the
routes that size a file set against a card), and the frontend rule behind the
Advanced control (single card offers nothing, Vulkan and XPU cards are not
offered, mixed namespaces are never one pool, a stale pick falls back to auto).

* Studio: update the Vulkan picker contract for the diffusion inventory

The contract pins the exact source line that gates the Vulkan branch, and this
branch deliberately scoped that gate: an image or video load runs on torch
rather than llama-server, so it reads the torch inventory even on a Vulkan chat
build. The assertion still holds the property it was written for, now against
`!forDiffusion && inference?.backend === "vulkan"`.

Fixes the Repo tests (CPU) failure on this PR.

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

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

* Studio: put a shared generate worker back on its own card

Three items from this round, two taken.

A pooled worker kept the previous model's GPU. `/images/generate` runs on an
`asyncio.to_thread` worker, and those threads are reused, so a load pinned to a
non-default card leaves that thread set to it permanently. The next model
loaded automatically has no ordinal, so nothing re-pinned the worker, and its
bare `cuda` Generators and allocations then targeted the previous card while
the weights sat on the default one: a cross-device error, or an allocation on a
GPU the user did not choose. The card the weights actually landed on is now
recorded with the pipeline as `placed_ordinal` and applied by every worker that
touches it. Kept separate from `gpu_ordinal`, so an automatic load still
resolves a bare, un-indexed device and reports exactly what it did before.

The download-plan routes dropped the selection entirely while training ran. The
training guard is there to keep a CUDA context from being opened, which only
the free-VRAM ranking does; validating and translating physical ids reads the
environment mask and nvidia-smi and opens nothing. Skipping all of it let the
plan answer 200 for a GPU the load would then refuse, and size its file set for
the default card, after tens of gigabytes had been staged. The ranking still
waits for the trainer; the validation no longer does, so the single-card
selection the UI sends resolves either way and a bad one is refused at the plan.

Not taken: refusing the load when the ggml device name cannot be resolved. The
fallback is deliberate. sd.cpp treats an unknown argument as fatal, and
`--list-devices` only arrived in upstream #1734, so a build older than that (a
user's own SD_CLI_PATH copy included) would go from "the selection is not
honoured here" to "this model cannot load at all" the moment a card is picked.
It runs on the build's own device, which is what every native load does today.
It now says so in the log rather than dropping the pick in silence.

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

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

* Studio: give the CPU-offload hooks the card the load was pinned to

Three of the four items from this round.

The offload hooks went to GPU 0 whatever was selected. apply_memory_plan hands
diffusers a bare "cuda", and enable_model_cpu_offload reads the index off that
device: with none, `_offload_gpu_id = gpu_id or torch_device.index or 0` settles
on 0 and the hooks onload every module to cuda:0 (pipeline_utils.py, diffusers
0.39, confirmed against the installed copy). Generation runs on the selected
card, so a low_vram or group-fallback load paged its modules onto the wrong GPU
and either failed across devices or filled the card the selection existed to
avoid. Placement now takes an indexed device string; the bare one stays for
anything reading it as a policy.

The GPU choices array changed identity on every render. pinnableGpuContext
builds a fresh filtered array per call, so the hook returned a new array each
time, which fed the load-advanced snapshot, which fed the download-footprint
resolver the GGUF picker's effect depends on: every status poll cleared the
companion sizes and re-POSTed a download plan per variant, discarding whatever
was in flight. Memoized on the device list.

And the pick did not survive a reload. Every other Advanced select is reseeded
from the loaded build; this one cannot be, because the status reports the device
a pipeline is on and not which physical card, so a refresh reset it to Auto
while the model stayed put and the next Reapply moved it to the default GPU. It
persists now, and the existing staleness guard still drops an id whose card has
gone rather than sending one the backend would refuse.

Not taken: sizing the picker's fit recommendations for the selected card. That
budget comes from the shared model-picker heuristic that chat and training also
use, and it is advisory: the decisions that actually gate a load (the precision
preflight, the memory plan, the capacity gate) all read the selected card
already. Rewiring the shared picker is a feature, not a fix for this branch.

* Tighten the device-selection comments

* Tighten the device-selection test comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-08-13 07:45:04 -07:00

962 lines
36 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Hermetic, CPU-only tests for the diffusion device/dtype resolver.
`torch` is stubbed via a fake module so no GPU/torch is needed, and
`utils.hardware` is either stubbed (studio-layer path) or forced to fail
(torch-probe fallback path). Both paths are asserted.
"""
from __future__ import annotations
import sys
import types
from typing import Optional
import pytest
from core.inference import diffusion_device as dd
# ── Fakes ─────────────────────────────────────────────────────────────
class _FakeDtype:
def __init__(self, name: str) -> None:
self.name = name
def __eq__(self, other: object) -> bool:
return isinstance(other, _FakeDtype) and other.name == self.name
def __hash__(self) -> int:
return hash(self.name)
def __repr__(self) -> str: # str(dtype) -> "torch.bfloat16"
return f"torch.{self.name}"
BF16 = _FakeDtype("bfloat16")
FP16 = _FakeDtype("float16")
FP32 = _FakeDtype("float32")
class _FiniteResult:
def __init__(self, finite: bool) -> None:
self._finite = finite
def all(self) -> "_FiniteResult":
return self
def item(self) -> bool:
return self._finite
class _FakeTensor:
def __init__(self, finite: bool = True) -> None:
self._finite = finite
def __add__(self, other: object) -> "_FakeTensor":
return self
def float(self) -> "_FakeTensor":
return self
def _make_torch(
*,
cuda_available: bool = False,
capability = (8, 0),
capability_raises: bool = False,
bf16_supported: bool = False,
hip = None,
mps_available: bool = False,
mps_probe: str = "pass", # "pass" | "raise" | "nonfinite"
xpu_available = None, # None -> no xpu attr; True/False -> present
xpu_bf16: bool = False,
device_count: int = 1,
# Free VRAM per physical index, for the multi-card pick. Absent -> 0.
free_vram_by_index: Optional[dict] = None,
# Records every torch.cuda.set_device() this fake receives, so a test can assert the pin.
set_device_calls: Optional[list] = None,
) -> types.ModuleType:
torch = types.ModuleType("torch")
torch.bfloat16 = BF16
torch.float16 = FP16
torch.float32 = FP32
torch.version = types.SimpleNamespace(hip = hip)
free_vram_by_index = free_vram_by_index or {}
def _set_device(index):
if set_device_calls is not None:
set_device_calls.append(index)
# Same optional-device signature as torch's own, so a probe of a SELECTED card is answered rather than raising.
def _get_cap(device = None):
if capability_raises:
raise RuntimeError("no capability")
return capability
torch.cuda = types.SimpleNamespace(
is_available = lambda: cuda_available,
get_device_capability = _get_cap,
is_bf16_supported = lambda: bf16_supported,
device_count = lambda: device_count,
mem_get_info = lambda index = None: (free_vram_by_index.get(index, 0), 0),
set_device = _set_device,
)
mps_ns = types.SimpleNamespace(is_available = lambda: mps_available)
torch.backends = types.SimpleNamespace(mps = mps_ns)
def _ones(*_a, **_k):
if mps_probe == "raise":
raise RuntimeError("bf16 unsupported on this MPS")
return _FakeTensor(finite = (mps_probe == "pass"))
torch.ones = _ones
torch.isfinite = lambda t: _FiniteResult(getattr(t, "_finite", True))
if xpu_available is not None:
torch.xpu = types.SimpleNamespace(
is_available = lambda: xpu_available,
is_bf16_supported = lambda: xpu_bf16,
)
return torch
def _install(
monkeypatch,
torch,
*,
studio_device = None,
is_rocm = False,
hardware_fails = False,
):
"""Install the fake torch and either a fake or failing `utils.hardware`."""
monkeypatch.setitem(sys.modules, "torch", torch)
if hardware_fails:
# Force `from utils.hardware import ...` to raise, exercising the torch-probe fallback.
monkeypatch.setitem(sys.modules, "utils.hardware", None)
return
class _DT:
CUDA = "cuda"
XPU = "xpu"
MLX = "mlx"
CPU = "cpu"
fake_uh = types.ModuleType("utils.hardware")
fake_uh.DeviceType = _DT
fake_uh.get_device = lambda: studio_device
fake_uh.hardware = types.SimpleNamespace(IS_ROCM = is_rocm)
monkeypatch.setitem(sys.modules, "utils.hardware", fake_uh)
# ── Studio-layer path ─────────────────────────────────────────────────
def test_cuda_ampere_bf16(monkeypatch):
torch = _make_torch(cuda_available = True, capability = (8, 0))
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.dtype, t.backend, t.vendor) == ("cuda", BF16, "cuda", "nvidia")
assert (
t.supports_model_cpu_offload
and t.supports_default_torch_compile
and t.supports_pinned_transfer
)
def test_cuda_pre_ampere_fp16(monkeypatch):
torch = _make_torch(cuda_available = True, capability = (7, 5), bf16_supported = True)
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
# is_bf16_supported() is True (emulated) but capability < 8, so fp16.
assert t.dtype == FP16 and t.backend == "cuda"
def test_cuda_capability_raises_falls_back_fp16(monkeypatch):
torch = _make_torch(cuda_available = True, capability_raises = True)
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
assert t.dtype == FP16 and t.device == "cuda"
def test_cuda_studio_says_cuda_but_unavailable_is_cpu(monkeypatch):
torch = _make_torch(cuda_available = False)
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
assert t.device == "cpu" and t.dtype == FP32
def test_rocm_target(monkeypatch):
torch = _make_torch(cuda_available = True, bf16_supported = True)
_install(monkeypatch, torch, studio_device = "cuda", is_rocm = True)
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor) == ("cuda", "rocm", "amd")
assert t.dtype == BF16
assert t.supports_default_torch_compile is False # ROCm disables default compile
def test_rocm_without_bf16_uses_fp16(monkeypatch):
torch = _make_torch(cuda_available = True, bf16_supported = False)
_install(monkeypatch, torch, studio_device = "cuda", is_rocm = True)
t = dd.resolve_diffusion_device_target()
assert t.dtype == FP16 and t.backend == "rocm"
def test_xpu_bf16(monkeypatch):
torch = _make_torch(xpu_available = True, xpu_bf16 = True)
_install(monkeypatch, torch, studio_device = "xpu")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor, t.dtype) == ("xpu", "xpu", "intel", BF16)
assert (
t.supports_model_cpu_offload
and not t.supports_default_torch_compile
and not t.supports_pinned_transfer
)
def test_xpu_without_bf16_fp16(monkeypatch):
torch = _make_torch(xpu_available = True, xpu_bf16 = False)
_install(monkeypatch, torch, studio_device = "xpu")
t = dd.resolve_diffusion_device_target()
assert t.device == "xpu" and t.dtype == FP16
def test_mps_probe_pass_bf16(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "pass")
_install(monkeypatch, torch, studio_device = "mlx")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor, t.dtype) == ("mps", "mps", "apple", BF16)
assert not t.supports_model_cpu_offload
def test_mps_probe_raises_uses_fp32_not_fp16(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "raise")
_install(monkeypatch, torch, studio_device = "mlx")
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == FP32 # strict: never silent fp16
def test_mps_probe_nonfinite_uses_fp32(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "nonfinite")
_install(monkeypatch, torch, studio_device = "mlx")
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == FP32
def test_studio_cpu_on_apple_prefers_mps(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "pass")
_install(monkeypatch, torch, studio_device = "cpu") # Studio reports CPU (no mlx pkg)
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == BF16
def test_cpu_when_nothing_available(monkeypatch):
torch = _make_torch(mps_available = False)
_install(monkeypatch, torch, studio_device = "cpu")
t = dd.resolve_diffusion_device_target()
assert (t.device, t.backend, t.vendor, t.dtype) == ("cpu", "cpu", None, FP32)
assert not any(
(t.supports_model_cpu_offload, t.supports_default_torch_compile, t.supports_pinned_transfer)
)
# ── torch-probe fallback path (utils.hardware import fails) ────────────
def test_fallback_cuda(monkeypatch):
torch = _make_torch(cuda_available = True, capability = (9, 0))
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "cuda" and t.dtype == BF16 and t.backend == "cuda"
def test_fallback_rocm_via_torch_hip(monkeypatch):
torch = _make_torch(cuda_available = True, bf16_supported = True, hip = "6.2")
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.backend == "rocm" and t.vendor == "amd"
def test_fallback_xpu(monkeypatch):
torch = _make_torch(cuda_available = False, xpu_available = True, xpu_bf16 = True)
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "xpu" and t.dtype == BF16
def test_fallback_mps(monkeypatch):
torch = _make_torch(cuda_available = False, mps_available = True, mps_probe = "pass")
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "mps" and t.dtype == BF16
def test_fallback_cpu(monkeypatch):
torch = _make_torch(cuda_available = False, mps_available = False)
_install(monkeypatch, torch, hardware_fails = True)
t = dd.resolve_diffusion_device_target()
assert t.device == "cpu" and t.dtype == FP32
# ── from-torch-device reconstruction + public dict ────────────────────
def test_from_torch_device_cuda(monkeypatch):
torch = _make_torch()
monkeypatch.setitem(sys.modules, "torch", torch)
t = dd.diffusion_device_target_from_torch_device("cuda:0", FP32)
assert (t.device, t.backend, t.vendor, t.dtype) == ("cuda", "cuda", "nvidia", FP32)
assert t.is_cuda_torch_device
def test_from_torch_device_mps_and_cpu(monkeypatch):
torch = _make_torch()
monkeypatch.setitem(sys.modules, "torch", torch)
mps = dd.diffusion_device_target_from_torch_device("mps", FP16)
assert mps.device == "mps" and not mps.supports_model_cpu_offload
cpu = dd.diffusion_device_target_from_torch_device("cpu", FP32)
assert cpu.device == "cpu" and cpu.vendor is None
@pytest.mark.parametrize(
"dtype,expected", [(BF16, "bfloat16"), (FP16, "float16"), (FP32, "float32")]
)
def test_public_dict_dtype_string(dtype, expected):
t = dd.DiffusionDeviceTarget(
device = "cuda",
dtype = dtype,
backend = "cuda",
vendor = "nvidia",
supports_model_cpu_offload = True,
supports_default_torch_compile = True,
supports_pinned_transfer = True,
)
d = t.as_public_dict()
assert d["dtype"] == expected and "torch." not in d["dtype"]
# -- float64 capability + the RoPE demotion it drives -------------------------------------------
def test_only_mps_lacks_float64(monkeypatch):
torch = _make_torch(mps_available = True, mps_probe = "pass")
_install(monkeypatch, torch)
assert dd.resolve_diffusion_device_target().supports_float64 is False
for device in ("cuda", "xpu", "cpu"):
assert dd.diffusion_device_target_from_torch_device(device, FP32).supports_float64 is True
assert dd.diffusion_device_target_from_torch_device("mps", FP32).supports_float64 is False
class _RopeModule:
def __init__(self, double_precision = True):
self.double_precision = double_precision
class _Component:
def __init__(self, *mods):
self._mods = mods
def modules(self):
return iter(self._mods)
class _Pipe:
def __init__(self, **components):
self.components = components
def _mps_target():
return dd.diffusion_device_target_from_torch_device("mps", FP32)
def _cuda_target():
return dd.diffusion_device_target_from_torch_device("cuda", FP32)
def test_force_float32_rope_demotes_every_component_on_mps():
# Two components, several modules each: the connectors and the transformer both carry RoPE,
# so demoting only the first one found would still crash inside the denoise loop.
conn, dit_a, dit_b = _RopeModule(), _RopeModule(), _RopeModule()
pipe = _Pipe(connectors = _Component(conn), transformer = _Component(dit_a, dit_b))
assert dd.force_float32_rope(pipe, _mps_target()) == 3
assert not any(m.double_precision for m in (conn, dit_a, dit_b))
def test_force_float32_rope_leaves_float64_devices_untouched():
rope = _RopeModule()
pipe = _Pipe(transformer = _Component(rope))
assert dd.force_float32_rope(pipe, _cuda_target()) == 0
assert rope.double_precision is True
def test_force_float32_rope_skips_modules_without_the_flag():
already_off = _RopeModule(double_precision = False)
plain = object()
pipe = _Pipe(vae = _Component(already_off, plain))
assert dd.force_float32_rope(pipe, _mps_target()) == 0
def test_force_float32_rope_tolerates_non_module_components():
# Pipelines carry schedulers and tokenizers with no .modules(); they must not abort the walk.
rope = _RopeModule()
pipe = _Pipe(scheduler = object(), tokenizer = None, transformer = _Component(rope))
assert dd.force_float32_rope(pipe, _mps_target()) == 1
assert rope.double_precision is False
def test_the_video_loader_demotes_rope():
# The tests above prove the helper works, not that anything calls it: deleting the call site
# leaves every one of them green while LTX-2 goes back to raising on Metal. Where in the
# loader is not asserted -- the flag is read when a pipeline first builds its frequency
# tables, after load_pipeline returns -- but reaching it unconditionally is, since the helper
# already no-ops on a float64 device and a guard here could only ever get the polarity wrong.
#
# Asserted as "reached with no condition above it" rather than by rejecting `if`: a guard can
# equally be written `target.supports_float64 and force_float32_rope(...)` or as a ternary,
# and naming the shapes only rejects the ones already thought of.
import ast
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "core/inference/video.py").read_text(
encoding = "utf-8"
)
loader = next(
n
for n in ast.walk(ast.parse(src))
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == "load_pipeline"
)
def _is_rope_call(node):
return (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "force_float32_rope"
)
# Everything a condition could skip, whatever syntax expresses it.
conditional = {
id(inner)
for node in ast.walk(loader)
if isinstance(node, (ast.If, ast.IfExp, ast.BoolOp))
for inner in ast.walk(node)
}
assert any(
isinstance(n, ast.Expr) and _is_rope_call(n.value) and id(n) not in conditional
for n in ast.walk(loader)
), (
"load_pipeline does not reach force_float32_rope unconditionally, so the demotion is "
"either gone or behind a guard -- and a guard here can only be wrong, since the helper "
"already no-ops wherever float64 works"
)
# ── Pressure-gated decoder sync ───────────────────────────────────────
def _target(device: str) -> dd.DiffusionDeviceTarget:
return dd.DiffusionDeviceTarget(
device = device,
dtype = FP32,
backend = device,
vendor = None,
supports_model_cpu_offload = False,
supports_default_torch_compile = False,
supports_pinned_transfer = False,
)
class _FakeDecoder:
"""A VAE decoder module, following nn.Module: a hook returning non-None replaces the output."""
def __init__(self) -> None:
self.hooks: list = []
def register_forward_hook(self, hook):
self.hooks.append(hook)
def decode(self, calls: int) -> list:
outputs = []
for index in range(calls):
out = f"out{index}"
for hook in self.hooks:
replacement = hook(self, (), out)
if replacement is not None:
out = replacement
outputs.append(out)
return outputs
def _pipe_with(decoder) -> types.SimpleNamespace:
return types.SimpleNamespace(vae = types.SimpleNamespace(decoder = decoder))
def _mps_torch(used = 0, recommended = 100) -> types.ModuleType:
"""torch whose mps backend counts synchronize() calls over a settable memory reading."""
torch = types.ModuleType("torch")
torch.syncs = 0
torch.used = used
def _bump():
torch.syncs += 1
torch.mps = types.SimpleNamespace(
synchronize = _bump,
recommended_max_memory = lambda: recommended,
driver_allocated_memory = lambda: torch.used,
)
return torch
@pytest.mark.parametrize("device", ["cuda", "xpu", "cpu"])
def test_decoder_sync_is_metal_only(monkeypatch, device):
monkeypatch.setitem(sys.modules, "torch", _mps_torch())
decoder = _FakeDecoder()
assert dd.install_decoder_sync(_pipe_with(decoder), _target(device)) is False
assert decoder.hooks == []
def test_decoder_sync_idle_while_memory_is_plentiful(monkeypatch):
# The whole point of the gate: a decode that fits pays nothing at all.
torch = _mps_torch(recommended = 100, used = 10)
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
assert dd.install_decoder_sync(_pipe_with(decoder), _target("mps")) is True
decoder.decode(5)
assert torch.syncs == 0
def test_decoder_sync_runs_once_per_decoder_call_above_the_threshold(monkeypatch):
torch = _mps_torch(recommended = 100, used = 10)
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
dd.install_decoder_sync(_pipe_with(decoder), _target("mps"))
decoder.decode(2)
assert torch.syncs == 0
# The growth this bounds is per decoder call, so every call above the threshold syncs.
torch.used = 100 * dd.DECODE_SYNC_FRACTION
decoder.decode(3)
assert torch.syncs == 3
# ...and it stands down again once the allocator has given the memory back.
torch.used = 10
decoder.decode(4)
assert torch.syncs == 3
def test_decoder_sync_threshold_scales_with_the_device(monkeypatch):
# Pins the policy AND that the budget is a fraction of this device's working set rather than a
# fixed byte count -- a decode is only "running out" relative to the machine it runs on.
torch = _mps_torch(recommended = 200, used = 169)
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
dd.install_decoder_sync(_pipe_with(decoder), _target("mps"))
decoder.decode(1)
assert torch.syncs == 0
torch.used = 170
decoder.decode(1)
assert torch.syncs == 1
assert dd.DECODE_SYNC_FRACTION == 0.85
def test_decoder_sync_preserves_the_decoder_output(monkeypatch):
# An nn.Module forward hook that returns non-None REPLACES the output; this one must not.
torch = _mps_torch(recommended = 100, used = 100)
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
dd.install_decoder_sync(_pipe_with(decoder), _target("mps"))
assert decoder.decode(2) == ["out0", "out1"]
assert torch.syncs == 2
@pytest.mark.parametrize("pipe", [types.SimpleNamespace(), _pipe_with(None), _pipe_with(object())])
def test_decoder_sync_no_op_without_a_hookable_decoder(monkeypatch, pipe):
monkeypatch.setitem(sys.modules, "torch", _mps_torch())
assert dd.install_decoder_sync(pipe, _target("mps")) is False
def _mps_torch_without_recommended(used = 0) -> types.ModuleType:
"""torch 2.4's mps surface: driver_allocated_memory and synchronize, no working-set reading.
Verified against torch/mps/__init__.py at v2.4.0 (absent) and v2.5.0 (present), and against
an installed torch 2.4.1.
"""
torch = _mps_torch(used = used)
del torch.mps.recommended_max_memory
return torch
def test_decoder_sync_survives_a_torch_without_the_memory_reading(monkeypatch):
# install.sh keeps an existing venv's torch (>=2.4), and reading a 2.5 API there raised
# AttributeError from inside the video load -- after the download, with no OOM to explain it.
torch = _mps_torch_without_recommended()
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
assert dd.install_decoder_sync(_pipe_with(decoder), _target("mps")) is True
# No budget to compare against, so it must not silently decide the decode is fine: an
# unbounded Wan decode is what grew past 148 GiB.
assert decoder.decode(3) == ["out0", "out1", "out2"]
assert torch.syncs == 3
def test_decoder_sync_survives_a_working_set_reading_that_raises(monkeypatch):
torch = _mps_torch()
def _boom():
raise RuntimeError("MPS backend is not available")
torch.mps.recommended_max_memory = _boom
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
assert dd.install_decoder_sync(_pipe_with(decoder), _target("mps")) is True
decoder.decode(2)
assert torch.syncs == 2
def test_decoder_sync_survives_a_gauge_that_raises_mid_decode(monkeypatch):
torch = _mps_torch(recommended = 100, used = 10)
def _boom():
raise RuntimeError("driver reading unavailable")
torch.mps.driver_allocated_memory = _boom
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
dd.install_decoder_sync(_pipe_with(decoder), _target("mps"))
# The decode survives, and an unreadable gauge takes the safe side rather than skipping.
assert decoder.decode(2) == ["out0", "out1"]
assert torch.syncs == 2
def test_decoder_sync_survives_a_synchronize_that_raises(monkeypatch):
# The no-budget fallback synchronises every call, so a torch whose mps surface is degraded
# enough to hide recommended_max_memory would then raise on every decoder call. The bound is
# an optimisation; losing the generation to it is not a trade worth making.
torch = _mps_torch_without_recommended()
def _boom():
raise RuntimeError("Torch not compiled with MPS enabled")
torch.mps.synchronize = _boom
monkeypatch.setitem(sys.modules, "torch", torch)
decoder = _FakeDecoder()
assert dd.install_decoder_sync(_pipe_with(decoder), _target("mps")) is True
assert decoder.decode(2) == ["out0", "out1"]
# ── GPU selection ─────────────────────────────────────────────────────
def _mask(
monkeypatch,
visible,
*,
physical_count = None,
):
"""Stub the hardware layer's parent-visible view, the mask `gpu_ids` is expressed against."""
import utils.hardware.hardware as hw
monkeypatch.setattr(
hw,
"_get_parent_visible_gpu_spec",
lambda: {"raw": None, "numeric_ids": list(visible), "supports_explicit_gpu_ids": True},
)
monkeypatch.setattr(
hw, "get_physical_gpu_count", lambda: physical_count or (max(visible) + 1 if visible else 0)
)
def test_no_selection_leaves_the_target_on_the_default_device(monkeypatch):
# The automatic pick must stay byte-for-byte what it was: no index, nothing pinned.
calls: list = []
torch = _make_torch(cuda_available = True, capability = (8, 0), set_device_calls = calls)
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.resolve_diffusion_device_target()
assert t.ordinal is None
assert t.torch_device == "cuda"
dd.apply_diffusion_device_ordinal(t)
assert calls == []
def test_a_single_card_pick_is_honoured_exactly(monkeypatch):
calls: list = []
torch = _make_torch(
cuda_available = True, capability = (8, 0), device_count = 2, set_device_calls = calls
)
_install(monkeypatch, torch, studio_device = "cuda")
_mask(monkeypatch, [0, 1])
t = dd.resolve_diffusion_device_target(ordinal = dd.resolve_selected_cuda_ordinal([1]))
assert t.ordinal == 1
# The device string stays BARE: is_cuda / memory / speed / attention all compare it by value.
assert t.device == "cuda"
assert t.is_cuda_torch_device is True
assert t.torch_device == "cuda:1"
dd.apply_diffusion_device_ordinal(t)
assert calls == [1]
def test_physical_ids_are_translated_through_the_visibility_mask(monkeypatch):
# CUDA_VISIBLE_DEVICES=4,5: physical 4 and 5 are the valid picks and torch sees 0 and 1.
# Validating against torch.cuda.device_count() would reject both.
torch = _make_torch(cuda_available = True, capability = (8, 0), device_count = 2)
_install(monkeypatch, torch, studio_device = "cuda")
_mask(monkeypatch, [4, 5], physical_count = 8)
assert dd.resolve_selected_cuda_ordinal([4]) == 0
assert dd.resolve_selected_cuda_ordinal([5]) == 1
with pytest.raises(ValueError):
dd.resolve_selected_cuda_ordinal([0])
# A REORDERED mask: physical 1 is torch ordinal 0, so the order matters, not just membership.
_mask(monkeypatch, [1, 0], physical_count = 2)
assert dd.resolve_selected_cuda_ordinal([1]) == 0
assert dd.resolve_selected_cuda_ordinal([0]) == 1
def test_several_cards_resolve_to_the_one_with_the_most_free_vram(monkeypatch):
# The mixed box this exists for: ordinal 0 is the SMALL card, so taking the first id lands on the GPU that cannot hold the checkpoint.
torch = _make_torch(
cuda_available = True,
capability = (8, 0),
device_count = 2,
free_vram_by_index = {0: 6 * 1024**3, 1: 15 * 1024**3},
)
_install(monkeypatch, torch, studio_device = "cuda")
_mask(monkeypatch, [0, 1])
assert dd.resolve_selected_cuda_ordinal([0, 1]) == 1
# Equal cards take the lowest ordinal, so the same selection always resolves the same way.
torch.cuda.mem_get_info = lambda index = None: (8 * 1024**3, 0)
assert dd.resolve_selected_cuda_ordinal([0, 1]) == 0
def test_free_vram_is_read_on_the_torch_ordinal_not_the_physical_id(monkeypatch):
# Under a mask the two differ, and querying the physical id would rank the wrong cards.
seen: list = []
torch = _make_torch(cuda_available = True, capability = (8, 0), device_count = 2)
torch.cuda.mem_get_info = lambda index = None: (seen.append(index), 1 << 30)[1:]
_install(monkeypatch, torch, studio_device = "cuda")
_mask(monkeypatch, [4, 5], physical_count = 8)
dd.resolve_selected_cuda_ordinal([4, 5])
assert seen == [0, 1]
def test_an_unreadable_card_sorts_last_rather_than_failing_the_load(monkeypatch):
torch = _make_torch(
cuda_available = True,
capability = (8, 0),
device_count = 3,
free_vram_by_index = {2: 4 * 1024**3},
)
_install(monkeypatch, torch, studio_device = "cuda")
_mask(monkeypatch, [0, 1, 2])
assert dd.resolve_selected_cuda_ordinal([0, 2]) == 2
# Nothing readable at all: a stable answer, not an exception.
assert dd.resolve_selected_cuda_ordinal([0, 1]) == 0
def test_an_index_this_host_does_not_have_is_refused(monkeypatch):
torch = _make_torch(cuda_available = True, capability = (8, 0), device_count = 2)
_install(monkeypatch, torch, studio_device = "cuda")
_mask(monkeypatch, [0, 1])
with pytest.raises(ValueError):
dd.resolve_selected_cuda_ordinal([5])
with pytest.raises(ValueError):
dd.resolve_selected_cuda_ordinal([-1])
# Empty and None are "automatic", never a refusal.
assert dd.resolve_selected_cuda_ordinal([]) is None
assert dd.resolve_selected_cuda_ordinal(None) is None
def test_the_capability_probe_asks_about_the_selected_card(monkeypatch):
# Ordinal 0 is pre-Ampere and ordinal 1 is not, so an index-less probe picks the wrong dtype.
torch = _make_torch(cuda_available = True, device_count = 2)
seen: list = []
_NOTHING = object()
def _cap(device = _NOTHING):
seen.append(device)
return (7, 5) if device in (_NOTHING, 0) else (8, 9)
torch.cuda.get_device_capability = _cap
_install(monkeypatch, torch, studio_device = "cuda")
assert dd.resolve_diffusion_device_target(ordinal = 1).dtype == BF16
assert seen == [1]
# No selection probes with NO argument: a stub or older build that takes none would
# otherwise raise into the fp16 fallback.
seen.clear()
assert dd.resolve_diffusion_device_target().dtype == FP16
assert seen == [_NOTHING]
def test_an_indexed_override_string_keeps_its_card(monkeypatch):
# _pick_device_and_dtype hands back the indexed string; rebuilding must not drop to ordinal 0.
torch = _make_torch(cuda_available = True, capability = (8, 0))
_install(monkeypatch, torch, studio_device = "cuda")
t = dd.diffusion_device_target_from_torch_device("cuda:1", BF16)
assert (t.device, t.ordinal, t.torch_device) == ("cuda", 1, "cuda:1")
assert dd.diffusion_device_target_from_torch_device("cuda", BF16).ordinal is None
def test_a_selection_is_ignored_where_physical_indices_mean_nothing(monkeypatch):
# MPS has one device and no applicator for an index; the pick must not become a refusal.
torch = _make_torch(mps_available = True)
_install(monkeypatch, torch, studio_device = "mlx")
assert dd.resolve_diffusion_device_target(ordinal = 1).ordinal is None
def test_the_device_scope_restores_the_previous_card(monkeypatch):
# Route preflights run on a pooled executor, so a pin left set there is inherited by the
# NEXT request on that thread, including an automatic one.
calls: list = []
torch = _make_torch(cuda_available = True, device_count = 2, set_device_calls = calls)
class _Scope:
def __init__(self, index):
calls.append(("enter", index))
def __enter__(self):
return self
def __exit__(self, *_exc):
calls.append(("exit", None))
return False
torch.cuda.device = _Scope
monkeypatch.setitem(sys.modules, "torch", torch)
with dd.diffusion_device_scope(1):
pass
assert calls == [("enter", 1), ("exit", None)]
# No selection is a plain no-op, so the automatic path never touches the current device.
calls.clear()
with dd.diffusion_device_scope(None):
pass
assert calls == []
def test_the_rocm_bf16_probe_asks_about_the_selected_card(monkeypatch):
# is_bf16_supported() takes no device argument, so asking about the selected card means
# making it current; otherwise a bf16-capable pick behind an older default goes to fp32.
torch = _make_torch(cuda_available = True, hip = "6.0", device_count = 2)
scoped: list = []
class _Scope:
def __init__(self, index):
scoped.append(index)
def __enter__(self):
return self
def __exit__(self, *_exc):
return False
torch.cuda.device = _Scope
torch.cuda.is_bf16_supported = lambda: bool(scoped and scoped[-1] == 1)
_install(monkeypatch, torch, studio_device = "cuda", is_rocm = True)
assert dd.resolve_diffusion_device_target(ordinal = 1).dtype == BF16
assert scoped == [1]
def test_the_device_scope_lets_the_body_exception_through(monkeypatch):
# Catching around the yield made contextlib raise "generator didn't stop after throw()",
# replacing a precision refusal with an error the route maps to the wrong status.
torch = _make_torch(cuda_available = True, device_count = 2)
class _Scope:
def __init__(self, index):
pass
def __enter__(self):
return self
def __exit__(self, *_exc):
return False
torch.cuda.device = _Scope
monkeypatch.setitem(sys.modules, "torch", torch)
with pytest.raises(RuntimeError, match = "the real refusal"):
with dd.diffusion_device_scope(1):
raise RuntimeError("the real refusal")
def test_the_device_scope_still_runs_the_body_on_an_unusable_index(monkeypatch):
# Entering may fail on a stale index; the probe then runs unpinned rather than not at all.
torch = _make_torch(cuda_available = True, device_count = 2)
def _boom(_index):
raise RuntimeError("invalid device ordinal")
torch.cuda.device = _boom
monkeypatch.setitem(sys.modules, "torch", torch)
ran = []
with dd.diffusion_device_scope(9):
ran.append(True)
assert ran == [True]
def test_the_placed_ordinal_records_the_card_an_automatic_load_used(monkeypatch):
# /images/generate runs on a pooled worker, so a pinned load leaves that thread on its card
# for good and a later automatic load has no ordinal to re-pin with. The card it landed on is
# recorded separately and puts the worker back.
current = [3]
torch = _make_torch(cuda_available = True, device_count = 4)
torch.cuda.current_device = lambda: current[0]
torch.cuda.set_device = lambda index: current.__setitem__(0, index)
monkeypatch.setitem(sys.modules, "torch", torch)
_install(monkeypatch, torch, studio_device = "cuda")
automatic = dd.resolve_diffusion_device_target()
assert automatic.ordinal is None # the target itself stays un-indexed
assert dd.placed_cuda_ordinal(automatic) == 3 # but the card is known
selected = dd.resolve_diffusion_device_target(ordinal = 1)
assert dd.placed_cuda_ordinal(selected) == 1 # a selection needs no observation
# Nothing to record off CUDA: there is no thread-local device to put back.
cpu_torch = _make_torch(cuda_available = False)
monkeypatch.setitem(sys.modules, "torch", cpu_torch)
_install(monkeypatch, cpu_torch, studio_device = "cpu")
assert dd.placed_cuda_ordinal(dd.resolve_diffusion_device_target()) is None
def test_pinning_an_automatic_load_puts_a_shared_worker_back(monkeypatch):
current = [0]
torch = _make_torch(cuda_available = True, device_count = 4)
torch.cuda.current_device = lambda: current[0]
torch.cuda.set_device = lambda index: current.__setitem__(0, index)
monkeypatch.setitem(sys.modules, "torch", torch)
_install(monkeypatch, torch, studio_device = "cuda")
# A pinned load runs here first and leaves the thread on its card.
dd.apply_diffusion_device_ordinal(dd.resolve_diffusion_device_target(ordinal = 2))
assert current == [2]
# The next model loaded automatically; its weights are on 0, so the worker goes back to 0.
dd.pin_cuda_ordinal(0)
assert current == [0]
# And a None never moves anything.
dd.pin_cuda_ordinal(None)
assert current == [0]
def test_a_multi_card_pick_declines_to_rank_when_ranking_is_barred(monkeypatch):
# The plan routes must not open a CUDA context while a trainer holds the cards; validating
# and translating the ids costs none, so a bad pick is still refused at the plan.
torch = _make_torch(cuda_available = True, device_count = 4, free_vram_by_index = {0: 1, 1: 2})
probed: list = []
torch.cuda.mem_get_info = lambda index = None: (probed.append(index), (1, 2))[1]
monkeypatch.setitem(sys.modules, "torch", torch)
_install(monkeypatch, torch, studio_device = "cuda")
import utils.hardware.hardware as hw
monkeypatch.setattr(hw, "get_parent_visible_gpu_ids", lambda: [0, 1, 2, 3])
monkeypatch.setattr(hw, "get_physical_gpu_count", lambda: 4)
assert dd.resolve_selected_cuda_ordinal([2], allow_ranking = False) == 2
assert probed == [] # no free-VRAM probe, so no CUDA context
assert dd.resolve_selected_cuda_ordinal([0, 1], allow_ranking = False) is None
assert probed == []
with pytest.raises(ValueError):
dd.resolve_selected_cuda_ordinal([9], allow_ranking = False)