Skip to content

Commit f4ef8b4

Browse files
authored
Merge pull request #6512 from hpcaitech/fix/ci-container-gpu-access
[ci] Preserve GPU access and isolate container scratch files
2 parents 69c9ff5 + 9382593 commit f4ef8b4

8 files changed

Lines changed: 454 additions & 9 deletions

File tree

‎.github/scripts/ci/container_batch.py‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import os
1010
import re
1111
import signal
12+
import stat
1213
import subprocess
1314
import uuid
15+
import xml.etree.ElementTree as ET
1416
from pathlib import Path
1517

1618
OWNER_LABEL = "org.colossalai.ci.owner"
@@ -63,6 +65,44 @@ def validate_scope(scope):
6365
return scope
6466

6567

68+
def gpu_device_paths(gpu_uuids):
69+
"""Resolve allocated UUIDs to character devices, including shared CUDA controls."""
70+
inventory = subprocess.run(
71+
# minor_number is present in the XML inventory but is not a supported
72+
# --query-gpu field on all drivers (including the H20 runner's driver).
73+
["nvidia-smi", "-q", "-x"],
74+
check=True,
75+
capture_output=True,
76+
text=True,
77+
timeout=15,
78+
).stdout
79+
minors = {}
80+
try:
81+
entries = ET.fromstring(inventory).findall("gpu")
82+
except ET.ParseError as error:
83+
raise RuntimeError("Invalid GPU XML inventory from nvidia-smi") from error
84+
for entry in entries:
85+
gpu = entry.findtext("uuid", "").strip()
86+
minor = entry.findtext("minor_number", "").strip()
87+
if not gpu or gpu in minors or not minor.isdecimal():
88+
raise RuntimeError("Invalid GPU device inventory from nvidia-smi")
89+
minors[gpu] = int(minor)
90+
if any(gpu not in minors for gpu in gpu_uuids):
91+
raise RuntimeError("An allocated GPU UUID is missing from the device inventory")
92+
selected_minors = [minors[gpu] for gpu in gpu_uuids]
93+
if len(set(selected_minors)) != len(selected_minors):
94+
raise RuntimeError("Allocated GPU UUIDs resolve to duplicate device minors")
95+
devices = [Path(f"/dev/nvidia{minor}") for minor in selected_minors]
96+
devices += [Path("/dev/nvidiactl"), Path("/dev/nvidia-uvm"), Path("/dev/nvidia-uvm-tools")]
97+
for index, device in enumerate(devices):
98+
metadata = device.stat()
99+
if not stat.S_ISCHR(metadata.st_mode):
100+
raise RuntimeError(f"GPU device is not a character device: {device}")
101+
if index < len(selected_minors) and os.minor(metadata.st_rdev) != selected_minors[index]:
102+
raise RuntimeError(f"GPU device minor does not match the allocation: {device}")
103+
return devices
104+
105+
66106
def create_command(name, scope, image_id, source, output, gpu_uuids, batch, env):
67107
validate_scope(scope)
68108
if not IMAGE_ID.fullmatch(image_id):
@@ -114,6 +154,10 @@ def create_command(name, scope, image_id, source, output, gpu_uuids, batch, env)
114154
shm,
115155
"--pids-limit=4096",
116156
"--ulimit=memlock=-1:-1",
157+
# Keep disposable source, HOME and compiler caches out of overlay2.
158+
# tmpfs pages count towards the existing container memory limit.
159+
"--tmpfs",
160+
f"/tmp:rw,exec,nosuid,nodev,size={memory},mode=1777",
117161
"--gpus",
118162
'"device=' + ",".join(gpu_uuids) + '"',
119163
"--mount",
@@ -123,6 +167,11 @@ def create_command(name, scope, image_id, source, output, gpu_uuids, batch, env)
123167
"--workdir",
124168
"/tmp",
125169
]
170+
# The legacy NVIDIA hook's cgroup permissions can disappear after a
171+
# container/systemd update. Explicit devices are retained by runc. Resolve
172+
# UUIDs to actual minors rather than assuming host indices equal minors.
173+
for device in gpu_device_paths(gpu_uuids):
174+
command.extend(["--device", f"{device}:{device}:rw"])
126175
container_env = {
127176
"NVIDIA_VISIBLE_DEVICES": ",".join(gpu_uuids),
128177
"NVIDIA_DRIVER_CAPABILITIES": "compute,utility",
@@ -185,6 +234,7 @@ def interrupted(signum, frame):
185234
raise RuntimeError(f"Container batch interrupted by signal {signum}")
186235

187236
old_handlers = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGTERM, signal.SIGINT)}
237+
execution_error = None
188238
try:
189239
try:
190240
subprocess.run(command, check=True, capture_output=True, text=True, timeout=60)
@@ -194,13 +244,18 @@ def interrupted(signum, frame):
194244
client_code = stream(["docker", "start", "--attach", name], output / "pytest.log", env)
195245
state = json.loads(docker("inspect", "--format", "{{json .State}}", name))
196246
result["container_state"] = state
247+
result["container_exit_code"] = state.get("ExitCode")
197248
if state.get("Running") or state.get("Status") != "exited":
198249
raise RuntimeError("Docker client exited before the container finished")
199250
if state.get("OOMKilled"):
200251
raise RuntimeError("Container exceeded its configured memory limit")
201252
if state.get("Error"):
202253
raise RuntimeError(f"Container runtime error: {state['Error']}")
203254
return state["ExitCode"] or client_code
255+
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
256+
execution_error = error
257+
result["container_execution_error"] = str(error)
258+
raise
204259
finally:
205260
# Also runs when create times out: the daemon might have created the
206261
# container despite losing the client's response. The job's always()
@@ -212,7 +267,12 @@ def interrupted(signum, frame):
212267
result["container_cleanup"] = "completed"
213268
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error:
214269
result["container_cleanup"] = f"failed: {error}"
215-
raise
270+
# Retain the original execution exception when both paths fail;
271+
# cleanup errors are still recorded and the job cannot pass.
272+
if execution_error is None:
273+
raise RuntimeError(
274+
f"Container exited with code {result.get('container_exit_code', 'unknown')}; cleanup failed: {error}"
275+
) from error
216276
finally:
217277
for sig, handler in old_handlers.items():
218278
signal.signal(sig, handler)

‎.github/scripts/ci/cuda_probe.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Check actual computation on every container-visible CUDA device."""
2+
3+
4+
def probe_devices(torch, expected):
5+
if expected < 1:
6+
raise RuntimeError("The CUDA probe requires at least one allocated GPU")
7+
if not torch.cuda.is_available():
8+
raise RuntimeError("CUDA is not available inside the container")
9+
count = torch.cuda.device_count()
10+
if count != expected:
11+
raise RuntimeError(f"Expected {expected} CUDA device(s), found {count}")
12+
names = []
13+
for index in range(expected):
14+
try:
15+
# Enumeration can succeed before device access is lost. Allocate,
16+
# launch a kernel and synchronize so that failures reach this gate.
17+
value = torch.ones(4, dtype=torch.float32, device=f"cuda:{index}").sum().item()
18+
torch.cuda.synchronize(index)
19+
if value != 4:
20+
raise RuntimeError(f"Unexpected CUDA probe result: {value}")
21+
names.append(torch.cuda.get_device_name(index))
22+
except Exception as error:
23+
raise RuntimeError(f"CUDA compute probe failed on cuda:{index}: {error}") from error
24+
return names

‎.github/scripts/ci/run_container_batch.sh‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,20 @@ from pathlib import Path
5050
5151
import torch
5252
53+
sys.path.insert(0, str(Path(os.environ["COLOSSAL_REPO"]) / ".github/scripts/ci"))
54+
from cuda_probe import probe_devices
55+
5356
assert torch.__version__.startswith("2.13."), torch.__version__
5457
assert torch.version.cuda == "13.0", torch.version.cuda
55-
assert torch.cuda.is_available(), "CUDA is not available inside the container"
56-
assert torch.cuda.device_count() == len(os.environ["CUDA_VISIBLE_DEVICES"].split(","))
58+
gpu_names = probe_devices(torch, len(os.environ["CUDA_VISIBLE_DEVICES"].split(",")))
5759
profile = {
5860
"source_sha": os.environ["CI_SOURCE_SHA"],
5961
"image_id": os.environ["CI_IMAGE_ID"],
6062
"python": sys.version,
6163
"torch": torch.__version__,
6264
"cuda": torch.version.cuda,
63-
"gpu_names": [torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())],
65+
"gpu_names": gpu_names,
66+
"cuda_compute_probe": "passed",
6467
}
6568
print(json.dumps(profile, indent=2), flush=True)
6669
Path("/ci-results/runtime.json").write_text(json.dumps(profile, indent=2) + "\n")

‎.github/scripts/ci/test_container_batch.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
import json
33
import os
44
import signal
5+
import stat
56
import subprocess
67
import tempfile
78
import unittest
89
from contextlib import redirect_stdout
910
from pathlib import Path
11+
from types import SimpleNamespace
1012
from unittest.mock import Mock, patch
1113

1214
import container_batch as container
@@ -29,6 +31,19 @@ def setUp(self):
2931
script.touch()
3032
self.output = self.root / "reports"
3133
self.output.mkdir()
34+
devices = patch.object(
35+
container,
36+
"gpu_device_paths",
37+
return_value=[
38+
Path("/dev/nvidia4"),
39+
Path("/dev/nvidia7"),
40+
Path("/dev/nvidiactl"),
41+
Path("/dev/nvidia-uvm"),
42+
Path("/dev/nvidia-uvm-tools"),
43+
],
44+
)
45+
devices.start()
46+
self.addCleanup(devices.stop)
3247

3348
def command(self, **env):
3449
return container.create_command(
@@ -48,6 +63,17 @@ def test_uuid_filter_and_logical_device_mapping(self):
4863
self.assertIn("CUDA_VISIBLE_DEVICES=0,1", command)
4964
self.assertIn(f"NVIDIA_VISIBLE_DEVICES={GPU_A},{GPU_B}", command)
5065
self.assertEqual(command[-2:], ["5", "0,1"])
66+
devices = [command[index + 1] for index, value in enumerate(command) if value == "--device"]
67+
self.assertEqual(
68+
devices,
69+
[
70+
"/dev/nvidia4:/dev/nvidia4:rw",
71+
"/dev/nvidia7:/dev/nvidia7:rw",
72+
"/dev/nvidiactl:/dev/nvidiactl:rw",
73+
"/dev/nvidia-uvm:/dev/nvidia-uvm:rw",
74+
"/dev/nvidia-uvm-tools:/dev/nvidia-uvm-tools:rw",
75+
],
76+
)
5177

5278
def test_only_source_and_reports_are_mounted(self):
5379
command = self.command(COLOSSAL_VENV="/host/private/venv", SECRET_TOKEN="not-for-container")
@@ -75,6 +101,7 @@ def test_resource_limits(self):
75101
("--shm-size", "4g"),
76102
):
77103
self.assertEqual(command[command.index(flag) + 1], expected)
104+
self.assertEqual(command[command.index("--tmpfs") + 1], "/tmp:rw,exec,nosuid,nodev,size=32g,mode=1777")
78105
for env in (
79106
{"COLOSSAL_CI_CPUS": "0"},
80107
{"COLOSSAL_CI_MEMORY": "unlimited"},
@@ -208,6 +235,92 @@ def test_cleanup_failure_cannot_report_success(self):
208235
with self.assertRaisesRegex(RuntimeError, "daemon unavailable"):
209236
self.run_batch(Mock(return_value=0))
210237

238+
def test_cleanup_failure_preserves_execution_error(self):
239+
result = {}
240+
with (
241+
patch.object(container.subprocess, "run"),
242+
patch.object(container, "remove_owned_container", side_effect=RuntimeError("overlay2 removal failed")),
243+
):
244+
with self.assertRaisesRegex(RuntimeError, "original execution failed"):
245+
container.run_container(
246+
"5",
247+
[(4, GPU_A), (7, GPU_B)],
248+
self.root,
249+
self.output,
250+
IMAGE,
251+
{"CI_CONTAINER_SCOPE": SCOPE},
252+
Mock(side_effect=RuntimeError("original execution failed")),
253+
result,
254+
)
255+
self.assertEqual(result["container_execution_error"], "original execution failed")
256+
self.assertIn("overlay2 removal failed", result["container_cleanup"])
257+
258+
259+
class GPUDeviceTests(unittest.TestCase):
260+
@staticmethod
261+
def inventory(*pairs):
262+
return (
263+
"<nvidia_smi_log>"
264+
+ "".join(f"<gpu><uuid>{gpu}</uuid><minor_number>{minor}</minor_number></gpu>" for gpu, minor in pairs)
265+
+ "</nvidia_smi_log>"
266+
)
267+
268+
def test_devices_are_resolved_by_uuid_and_minor_not_inventory_order(self):
269+
metadata = [
270+
SimpleNamespace(st_mode=stat.S_IFCHR, st_rdev=os.makedev(195, 7)),
271+
SimpleNamespace(st_mode=stat.S_IFCHR, st_rdev=os.makedev(195, 4)),
272+
*[SimpleNamespace(st_mode=stat.S_IFCHR, st_rdev=0) for _ in range(3)],
273+
]
274+
with (
275+
patch.object(container.subprocess, "run", return_value=Mock(stdout=self.inventory((GPU_B, 4), (GPU_A, 7)))),
276+
patch.object(Path, "stat", side_effect=metadata),
277+
):
278+
devices = container.gpu_device_paths([GPU_A, GPU_B])
279+
self.assertEqual([str(path) for path in devices[:2]], ["/dev/nvidia7", "/dev/nvidia4"])
280+
self.assertEqual(len(devices), 5)
281+
282+
def test_missing_or_ambiguous_allocations_fail_before_device_access(self):
283+
for inventory in (
284+
self.inventory((GPU_A, 4)),
285+
self.inventory((GPU_A, 4), (GPU_A, 7)),
286+
self.inventory((GPU_A, 4), (GPU_B, 4)),
287+
self.inventory((GPU_A, "N/A")),
288+
"malformed XML",
289+
):
290+
with (
291+
self.subTest(inventory=inventory),
292+
patch.object(container.subprocess, "run", return_value=Mock(stdout=inventory)),
293+
patch.object(Path, "stat") as metadata,
294+
):
295+
with self.assertRaises(RuntimeError):
296+
container.gpu_device_paths([GPU_A, GPU_B])
297+
metadata.assert_not_called()
298+
299+
def test_non_device_and_mismatched_minor_are_rejected(self):
300+
for metadata in (
301+
SimpleNamespace(st_mode=stat.S_IFREG, st_rdev=0),
302+
SimpleNamespace(st_mode=stat.S_IFCHR, st_rdev=os.makedev(195, 9)),
303+
):
304+
with (
305+
self.subTest(metadata=metadata),
306+
patch.object(container.subprocess, "run", return_value=Mock(stdout=self.inventory((GPU_A, 4)))),
307+
patch.object(Path, "stat", return_value=metadata),
308+
):
309+
with self.assertRaises(RuntimeError):
310+
container.gpu_device_paths([GPU_A])
311+
312+
def test_missing_control_device_fails_closed(self):
313+
with (
314+
patch.object(container.subprocess, "run", return_value=Mock(stdout=self.inventory((GPU_A, 4)))),
315+
patch.object(
316+
Path,
317+
"stat",
318+
side_effect=[SimpleNamespace(st_mode=stat.S_IFCHR, st_rdev=os.makedev(195, 4)), FileNotFoundError()],
319+
),
320+
):
321+
with self.assertRaises(FileNotFoundError):
322+
container.gpu_device_paths([GPU_A])
323+
211324

212325
class ContainerOwnershipTests(unittest.TestCase):
213326
def test_absent_container_needs_no_removal(self):
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import unittest
2+
from unittest.mock import Mock, call
3+
4+
from cuda_probe import probe_devices
5+
6+
7+
class CUDAProbeTests(unittest.TestCase):
8+
def setUp(self):
9+
self.torch = Mock()
10+
self.torch.cuda.is_available.return_value = True
11+
self.torch.cuda.device_count.return_value = 2
12+
self.torch.cuda.get_device_name.side_effect = lambda index: f"GPU {index}"
13+
self.torch.ones.return_value.sum.return_value.item.return_value = 4
14+
15+
def test_computes_and_synchronizes_every_allocated_device(self):
16+
self.assertEqual(probe_devices(self.torch, 2), ["GPU 0", "GPU 1"])
17+
self.assertEqual(
18+
self.torch.ones.call_args_list,
19+
[
20+
call(4, dtype=self.torch.float32, device="cuda:0"),
21+
call(4, dtype=self.torch.float32, device="cuda:1"),
22+
],
23+
)
24+
self.assertEqual(self.torch.cuda.synchronize.call_args_list, [call(0), call(1)])
25+
26+
def test_enumeration_success_cannot_hide_allocation_failure(self):
27+
self.torch.ones.side_effect = [self.torch.ones.return_value, RuntimeError("device access denied")]
28+
with self.assertRaisesRegex(RuntimeError, "cuda:1: device access denied"):
29+
probe_devices(self.torch, 2)
30+
self.torch.cuda.synchronize.assert_called_once_with(0)
31+
32+
def test_asynchronous_cuda_failure_is_not_a_pass(self):
33+
self.torch.cuda.synchronize.side_effect = RuntimeError("CUDA device unavailable")
34+
with self.assertRaisesRegex(RuntimeError, "cuda:0: CUDA device unavailable"):
35+
probe_devices(self.torch, 2)
36+
37+
def test_unavailable_or_wrong_device_count_fails_before_computation(self):
38+
self.torch.cuda.is_available.return_value = False
39+
with self.assertRaisesRegex(RuntimeError, "CUDA is not available"):
40+
probe_devices(self.torch, 2)
41+
self.torch.cuda.is_available.return_value = True
42+
with self.assertRaisesRegex(RuntimeError, "Expected 1 CUDA device"):
43+
probe_devices(self.torch, 1)
44+
with self.assertRaisesRegex(RuntimeError, "at least one"):
45+
probe_devices(self.torch, 0)
46+
self.torch.ones.assert_not_called()
47+
48+
49+
if __name__ == "__main__":
50+
unittest.main()

0 commit comments

Comments
 (0)