GPUs follow the engine's own CUDA_VISIBLE_DEVICES

Its entries are the cards present, and a local child's allocation is
named through them, so under =2,3 card 0 is device 2. Remote workers
keep mapping against their own mask.
This commit is contained in:
2026-10-15 09:53:38 +02:00
parent 290b9fa705
commit f2a823218a
5 changed files with 69 additions and 10 deletions
+1 -1
View File
@@ -979,7 +979,7 @@ class FlowController:
with placer.claim( # type: ignore[union-attr]
wanted, device=device, policy=policy, node=node_id, run=run_id
) as (target, allocation):
env = derive_env(wanted, allocation)
env = derive_env(wanted, allocation, local=target.worker is None)
if target.worker is None:
return pool.for_env(env).run( # type: ignore[union-attr]
owner,
+31 -3
View File
@@ -96,15 +96,32 @@ def warn_oversubscribed(limits: dict[str, int]) -> None:
)
def visible_gpus() -> list[str] | None:
"""The cards the engine's own ``CUDA_VISIBLE_DEVICES`` lists, if it is set.
Indices, UUIDs or MIG instances, kept as written: a child is handed the
same names, so whatever the driver took here it takes there.
"""
mask = os.environ.get("CUDA_VISIBLE_DEVICES")
if mask is None:
return None
return [part.strip() for part in mask.split(",") if part.strip()]
def machine_gpus() -> int:
"""Cards a node may be given here, when nobody said.
The device nodes the NVIDIA driver creates, counted — no vendor tool, so
The engine's own ``CUDA_VISIBLE_DEVICES`` where it has one, since a card
the process can see but was not given is not its to hand out. Otherwise
the device nodes the NVIDIA driver creates, counted — no vendor tool, so
the one dependency does not become two. It answers for the case that
actually bit people (a box with cards, an engine told nothing, every GPU
node clamped to zero and running at once) and for nothing else: another
vendor, or a card this process cannot see, is still `--gpus N`.
"""
visible = visible_gpus()
if visible is not None:
return len(visible)
return len(glob.glob("/dev/nvidia[0-9]*"))
@@ -229,7 +246,9 @@ class ResourceAccountant:
# -----------------------------------------------------------------------------
def derive_env(wanted: Resources, allocation: Allocation) -> dict[str, str]:
def derive_env(
wanted: Resources, allocation: Allocation, local: bool = True
) -> dict[str, str]:
"""The environment a worker running this node is started with.
Precedence is engine environment, then what the allocation implies, then
@@ -240,10 +259,19 @@ def derive_env(wanted: Resources, allocation: Allocation) -> dict[str, str]:
contents depend on the version installed, so writing it here would silently
replace whatever the author had put there. It travels in ``resources.env``,
where it is visible.
``local`` is whether the allocation is this machine's. A child's
``CUDA_VISIBLE_DEVICES`` replaces the engine's rather than narrowing it, so
here a card is named the way the engine's own list names it: under ``=2,3``
card 0 is device 2. A remote worker does the same against its own list.
"""
env = {var: str(allocation.cpus) for var in THREAD_VARS}
if wanted.gpus:
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(index) for index in allocation.gpus)
names = (visible_gpus() if local else None) or []
env["CUDA_VISIBLE_DEVICES"] = ",".join(
names[index] if index < len(names) else str(index)
for index in allocation.gpus
)
env.update(wanted.env)
return env
+27 -1
View File
@@ -33,6 +33,7 @@ def test_the_cards_are_counted_from_the_devices_the_driver_makes(monkeypatch):
"""
from fluksio.flow import resources
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
monkeypatch.setattr(
resources.glob,
"glob",
@@ -150,12 +151,37 @@ def test_the_share_becomes_the_thread_limit():
assert "CUDA_VISIBLE_DEVICES" not in env
def test_a_gpu_node_is_told_which_card_is_its():
def test_a_gpu_node_is_told_which_card_is_its(monkeypatch):
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
env = derive_env(Resources(cpus=1, gpus=2), Allocation(cpus=1, gpus=(1, 3)))
assert env["CUDA_VISIBLE_DEVICES"] == "1,3"
@pytest.mark.parametrize(
("mask", "cards", "named"),
[(None, 2, "0"), ("2,3", 2, "2"), ("GPU-a1b2, MIG-c3d4", 2, "GPU-a1b2")],
)
def test_the_engines_own_mask_is_the_cards_it_has(monkeypatch, mask, cards, named):
"""Under ``CUDA_VISIBLE_DEVICES=2,3`` card 0 is device 2, not device 0.
A child's mask replaces the engine's, so an unmapped 0 would be a card
the engine was never given. A remote worker maps against its own mask.
"""
from fluksio.flow import resources
monkeypatch.setattr(resources.glob, "glob", lambda pattern: ["/dev/nvidia0"] * 2)
if mask is None:
monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
else:
monkeypatch.setenv("CUDA_VISIBLE_DEVICES", mask)
wanted, allocation = Resources(cpus=1, gpus=1), Allocation(cpus=1, gpus=(0,))
assert machine_gpus() == cards
assert derive_env(wanted, allocation)["CUDA_VISIBLE_DEVICES"] == named
assert derive_env(wanted, allocation, local=False)["CUDA_VISIBLE_DEVICES"] == "0"
def test_what_the_node_asked_for_wins():
"""A declaration outranks what the allocation would imply — it is deliberate."""
wanted = Resources(