gpu-burn 2.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gpu_burn/__init__.py +3 -0
- gpu_burn/__main__.py +8 -0
- gpu_burn/cli.py +998 -0
- gpu_burn-2.2.1.dist-info/METADATA +193 -0
- gpu_burn-2.2.1.dist-info/RECORD +9 -0
- gpu_burn-2.2.1.dist-info/WHEEL +5 -0
- gpu_burn-2.2.1.dist-info/entry_points.txt +2 -0
- gpu_burn-2.2.1.dist-info/licenses/LICENSE +22 -0
- gpu_burn-2.2.1.dist-info/top_level.txt +1 -0
gpu_burn/__init__.py
ADDED
gpu_burn/__main__.py
ADDED
gpu_burn/cli.py
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
"""Run and manage adaptive per-user PaddlePaddle GPU workloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import multiprocessing as mp
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, TextIO
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PROGRAM_NAME = "gpu-burn"
|
|
23
|
+
DEFAULT_UTILIZATION = 100.0
|
|
24
|
+
DEFAULT_STOP_TIMEOUT = 15.0
|
|
25
|
+
DEFAULT_PAUSE_PERCENT = 5.0
|
|
26
|
+
DEFAULT_RESUME_PERCENT = 2.0
|
|
27
|
+
DEFAULT_IDLE_MINUTES = 5.0
|
|
28
|
+
DEFAULT_POLL_INTERVAL = 1.0
|
|
29
|
+
NVIDIA_SMI = os.environ.get("GPU_BURN_NVIDIA_SMI", "nvidia-smi")
|
|
30
|
+
SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Spinner:
|
|
34
|
+
def __init__(self, message: str, stream: TextIO = sys.stdout) -> None:
|
|
35
|
+
self.message = message
|
|
36
|
+
self.stream = stream
|
|
37
|
+
self.frame = 0
|
|
38
|
+
self.active = False
|
|
39
|
+
disabled = os.environ.get("GPU_BURN_NO_SPINNER", "").lower()
|
|
40
|
+
self.animated = bool(stream.isatty()) and disabled not in {"1", "true", "yes"}
|
|
41
|
+
|
|
42
|
+
def start(self) -> None:
|
|
43
|
+
self.active = True
|
|
44
|
+
if self.animated:
|
|
45
|
+
self.stream.write("\x1b[?25l")
|
|
46
|
+
self.tick()
|
|
47
|
+
else:
|
|
48
|
+
self.stream.write(f"{self.message}\n")
|
|
49
|
+
self.stream.flush()
|
|
50
|
+
|
|
51
|
+
def tick(self) -> None:
|
|
52
|
+
if not self.active or not self.animated:
|
|
53
|
+
return
|
|
54
|
+
glyph = SPINNER_FRAMES[self.frame % len(SPINNER_FRAMES)]
|
|
55
|
+
self.frame += 1
|
|
56
|
+
self.stream.write(f"\r\x1b[2K\x1b[36m{glyph}\x1b[0m {self.message}")
|
|
57
|
+
self.stream.flush()
|
|
58
|
+
|
|
59
|
+
def finish(self, message: str, success: bool = True) -> None:
|
|
60
|
+
if not self.active:
|
|
61
|
+
return
|
|
62
|
+
if self.animated:
|
|
63
|
+
glyph = "✓" if success else "✗"
|
|
64
|
+
color = "32" if success else "31"
|
|
65
|
+
self.stream.write(
|
|
66
|
+
f"\r\x1b[2K\x1b[{color}m{glyph}\x1b[0m {message}\x1b[?25h\n"
|
|
67
|
+
)
|
|
68
|
+
self.stream.flush()
|
|
69
|
+
elif not success:
|
|
70
|
+
self.stream.write(f"{message}\n")
|
|
71
|
+
self.stream.flush()
|
|
72
|
+
self.active = False
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
if self.active and self.animated:
|
|
76
|
+
self.stream.write("\r\x1b[2K\x1b[?25h")
|
|
77
|
+
self.stream.flush()
|
|
78
|
+
self.active = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class WorkerState:
|
|
83
|
+
device_id: int
|
|
84
|
+
process: mp.Process | None = None
|
|
85
|
+
stop_event: Any | None = None
|
|
86
|
+
idle_since: float | None = None
|
|
87
|
+
external_memory_percent: float | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class GPUInfo:
|
|
92
|
+
uuid: str
|
|
93
|
+
total_memory_mib: float
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def state_dir() -> Path:
|
|
97
|
+
override = os.environ.get("GPU_BURN_STATE_DIR")
|
|
98
|
+
if override:
|
|
99
|
+
path = Path(override).expanduser()
|
|
100
|
+
elif os.environ.get("XDG_RUNTIME_DIR"):
|
|
101
|
+
path = Path(os.environ["XDG_RUNTIME_DIR"]) / PROGRAM_NAME
|
|
102
|
+
else:
|
|
103
|
+
path = Path(f"/tmp/{PROGRAM_NAME}-{os.getuid()}")
|
|
104
|
+
|
|
105
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
106
|
+
if path.stat().st_uid != os.getuid():
|
|
107
|
+
raise RuntimeError(f"state directory is not owned by uid {os.getuid()}: {path}")
|
|
108
|
+
path.chmod(0o700)
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def paths() -> dict[str, Path]:
|
|
113
|
+
directory = state_dir()
|
|
114
|
+
return {
|
|
115
|
+
"directory": directory,
|
|
116
|
+
"record": directory / "instance.json",
|
|
117
|
+
"lifetime_lock": directory / "instance.lock",
|
|
118
|
+
"control_lock": directory / "control.lock",
|
|
119
|
+
"log": directory / "gpu-burn.log",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def proc_start_time(pid: int) -> int | None:
|
|
124
|
+
try:
|
|
125
|
+
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
126
|
+
fields_after_name = stat[stat.rfind(")") + 2 :].split()
|
|
127
|
+
return int(fields_after_name[19])
|
|
128
|
+
except (FileNotFoundError, IndexError, OSError, ValueError):
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def pid_matches(pid: int, uid: int, start_time: int) -> bool:
|
|
133
|
+
try:
|
|
134
|
+
process_uid = Path(f"/proc/{pid}").stat().st_uid
|
|
135
|
+
except (FileNotFoundError, OSError):
|
|
136
|
+
return False
|
|
137
|
+
return process_uid == uid == os.getuid() and proc_start_time(pid) == start_time
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def process_matches(record: dict[str, Any]) -> bool:
|
|
141
|
+
try:
|
|
142
|
+
return pid_matches(
|
|
143
|
+
int(record["pid"]), int(record["uid"]), int(record["proc_start_time"])
|
|
144
|
+
)
|
|
145
|
+
except (KeyError, TypeError, ValueError):
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def matching_worker_pids(record: dict[str, Any]) -> list[int]:
|
|
150
|
+
try:
|
|
151
|
+
uid = int(record["uid"])
|
|
152
|
+
return [
|
|
153
|
+
int(worker["pid"])
|
|
154
|
+
for worker in record.get("workers", [])
|
|
155
|
+
if pid_matches(int(worker["pid"]), uid, int(worker["proc_start_time"]))
|
|
156
|
+
]
|
|
157
|
+
except (KeyError, TypeError, ValueError):
|
|
158
|
+
return []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def instance_is_alive(record: dict[str, Any]) -> bool:
|
|
162
|
+
return process_matches(record) or bool(matching_worker_pids(record))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_record(record_path: Path) -> dict[str, Any] | None:
|
|
166
|
+
try:
|
|
167
|
+
value = json.loads(record_path.read_text(encoding="utf-8"))
|
|
168
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
169
|
+
return None
|
|
170
|
+
return value if isinstance(value, dict) else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def remove_stale_record(record_path: Path) -> None:
|
|
174
|
+
record = read_record(record_path)
|
|
175
|
+
if record is not None and instance_is_alive(record):
|
|
176
|
+
return
|
|
177
|
+
try:
|
|
178
|
+
record_path.unlink()
|
|
179
|
+
except FileNotFoundError:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def write_record(record_path: Path, record: dict[str, Any]) -> None:
|
|
184
|
+
temporary = record_path.with_suffix(".tmp")
|
|
185
|
+
temporary.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
|
186
|
+
temporary.chmod(0o600)
|
|
187
|
+
temporary.replace(record_path)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def acquire_lock(path: Path, nonblocking: bool = False):
|
|
191
|
+
lock_file = path.open("a+")
|
|
192
|
+
operation = fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0)
|
|
193
|
+
try:
|
|
194
|
+
fcntl.flock(lock_file.fileno(), operation)
|
|
195
|
+
except BlockingIOError:
|
|
196
|
+
lock_file.close()
|
|
197
|
+
return None
|
|
198
|
+
return lock_file
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def validate_positive(value: float, option: str, allow_zero: bool = False) -> None:
|
|
202
|
+
valid = math.isfinite(value) and (value >= 0 if allow_zero else value > 0)
|
|
203
|
+
if not valid:
|
|
204
|
+
relation = "non-negative" if allow_zero else "greater than zero"
|
|
205
|
+
raise ValueError(f"{option} must be finite and {relation}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def validate_adaptive_thresholds(pause_percent: float, resume_percent: float) -> None:
|
|
209
|
+
if not math.isfinite(pause_percent) or not 0 < pause_percent <= 100:
|
|
210
|
+
raise ValueError("--pause must be greater than 0 and at most 100")
|
|
211
|
+
if not math.isfinite(resume_percent) or not 0 <= resume_percent < pause_percent:
|
|
212
|
+
raise ValueError("--resume must be non-negative and lower than --pause")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate_utilization(value: float) -> None:
|
|
216
|
+
if not math.isfinite(value) or not 0 < value <= 100:
|
|
217
|
+
raise ValueError("--util must be greater than 0 and at most 100")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def calculate_idle_seconds(compute_seconds: float, utilization: float) -> float:
|
|
221
|
+
"""Return the idle time needed to approximate the requested compute duty cycle."""
|
|
222
|
+
validate_utilization(utilization)
|
|
223
|
+
if utilization == 100:
|
|
224
|
+
return 0.0
|
|
225
|
+
return max(0.0, compute_seconds * (100.0 / utilization - 1.0))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def parse_csv_rows(output: str, columns: int) -> list[list[str]]:
|
|
229
|
+
rows: list[list[str]] = []
|
|
230
|
+
for line in output.splitlines():
|
|
231
|
+
if not line.strip():
|
|
232
|
+
continue
|
|
233
|
+
values = [value.strip() for value in line.split(",")]
|
|
234
|
+
if len(values) != columns:
|
|
235
|
+
raise RuntimeError(f"unexpected nvidia-smi output: {line}")
|
|
236
|
+
rows.append(values)
|
|
237
|
+
return rows
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_nvidia_smi(query: str) -> str:
|
|
241
|
+
executable = find_nvidia_smi()
|
|
242
|
+
try:
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
[executable, f"--query-{query}", "--format=csv,noheader,nounits"],
|
|
245
|
+
check=False,
|
|
246
|
+
capture_output=True,
|
|
247
|
+
text=True,
|
|
248
|
+
timeout=10,
|
|
249
|
+
)
|
|
250
|
+
except FileNotFoundError as error:
|
|
251
|
+
raise RuntimeError(f"nvidia-smi not found: {executable}") from error
|
|
252
|
+
except subprocess.TimeoutExpired as error:
|
|
253
|
+
raise RuntimeError("nvidia-smi timed out") from error
|
|
254
|
+
if result.returncode != 0:
|
|
255
|
+
details = result.stderr.strip() or f"exit code {result.returncode}"
|
|
256
|
+
raise RuntimeError(f"nvidia-smi failed: {details}")
|
|
257
|
+
return result.stdout
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def find_nvidia_smi() -> str:
|
|
261
|
+
if NVIDIA_SMI != "nvidia-smi":
|
|
262
|
+
return NVIDIA_SMI
|
|
263
|
+
for candidate in (
|
|
264
|
+
"/usr/bin/nvidia-smi",
|
|
265
|
+
"/usr/local/bin/nvidia-smi",
|
|
266
|
+
"/home/opt/cuda_tools/nvidia-smi",
|
|
267
|
+
):
|
|
268
|
+
if Path(candidate).is_file() and os.access(candidate, os.X_OK):
|
|
269
|
+
return candidate
|
|
270
|
+
return NVIDIA_SMI
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def parse_visible_device_tokens(value: str | None) -> list[str] | None:
|
|
274
|
+
if value is None or not value.strip():
|
|
275
|
+
return None
|
|
276
|
+
tokens = [token.strip() for token in value.split(",") if token.strip()]
|
|
277
|
+
return tokens or None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def query_gpu_info(visible_devices: str | None = None) -> dict[int, GPUInfo]:
|
|
281
|
+
output = run_nvidia_smi("gpu=index,uuid,memory.total")
|
|
282
|
+
try:
|
|
283
|
+
physical = {
|
|
284
|
+
int(index): GPUInfo(uuid, float(total_memory_mib))
|
|
285
|
+
for index, uuid, total_memory_mib in parse_csv_rows(output, 3)
|
|
286
|
+
}
|
|
287
|
+
except ValueError as error:
|
|
288
|
+
raise RuntimeError("nvidia-smi returned invalid GPU information") from error
|
|
289
|
+
if any(info.total_memory_mib <= 0 for info in physical.values()):
|
|
290
|
+
raise RuntimeError("nvidia-smi returned non-positive total GPU memory")
|
|
291
|
+
tokens = parse_visible_device_tokens(visible_devices)
|
|
292
|
+
if tokens is None:
|
|
293
|
+
return physical
|
|
294
|
+
by_uuid = {info.uuid: info for info in physical.values()}
|
|
295
|
+
resolved: dict[int, GPUInfo] = {}
|
|
296
|
+
for logical_index, token in enumerate(tokens):
|
|
297
|
+
if token in by_uuid:
|
|
298
|
+
resolved[logical_index] = by_uuid[token]
|
|
299
|
+
continue
|
|
300
|
+
try:
|
|
301
|
+
resolved[logical_index] = physical[int(token)]
|
|
302
|
+
except (KeyError, ValueError) as error:
|
|
303
|
+
raise RuntimeError(
|
|
304
|
+
f"CUDA_VISIBLE_DEVICES references an unknown GPU: {token}"
|
|
305
|
+
) from error
|
|
306
|
+
return resolved
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def query_gpu_uuids(visible_devices: str | None = None) -> dict[int, str]:
|
|
310
|
+
return {
|
|
311
|
+
device: info.uuid for device, info in query_gpu_info(visible_devices).items()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def query_external_memory_mib(
|
|
316
|
+
device_uuids: dict[int, str], ignored_pids: set[int]
|
|
317
|
+
) -> dict[int, float]:
|
|
318
|
+
by_uuid = {uuid: device_id for device_id, uuid in device_uuids.items()}
|
|
319
|
+
usage = {device_id: 0.0 for device_id in device_uuids}
|
|
320
|
+
output = run_nvidia_smi("compute-apps=gpu_uuid,pid,used_gpu_memory")
|
|
321
|
+
for uuid, pid_text, memory_text in parse_csv_rows(output, 3):
|
|
322
|
+
device_id = by_uuid.get(uuid)
|
|
323
|
+
if device_id is None:
|
|
324
|
+
continue
|
|
325
|
+
try:
|
|
326
|
+
pid = int(pid_text)
|
|
327
|
+
memory_mib = float(memory_text)
|
|
328
|
+
except ValueError as error:
|
|
329
|
+
raise RuntimeError("nvidia-smi returned invalid process memory data") from error
|
|
330
|
+
if pid not in ignored_pids and math.isfinite(memory_mib):
|
|
331
|
+
usage[device_id] += max(0.0, memory_mib)
|
|
332
|
+
return usage
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def query_external_memory_percent(
|
|
336
|
+
device_info: dict[int, GPUInfo], ignored_pids: set[int]
|
|
337
|
+
) -> dict[int, float]:
|
|
338
|
+
usage_mib = query_external_memory_mib(
|
|
339
|
+
{device: info.uuid for device, info in device_info.items()}, ignored_pids
|
|
340
|
+
)
|
|
341
|
+
return {
|
|
342
|
+
device: usage_mib[device] / info.total_memory_mib * 100.0
|
|
343
|
+
for device, info in device_info.items()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def query_external_percent_with_retry(
|
|
348
|
+
device_info: dict[int, GPUInfo], ignored_pids: set[int]
|
|
349
|
+
) -> dict[int, float]:
|
|
350
|
+
first = query_external_memory_percent(device_info, ignored_pids)
|
|
351
|
+
if not any(first.values()):
|
|
352
|
+
return first
|
|
353
|
+
time.sleep(0.1)
|
|
354
|
+
return query_external_memory_percent(device_info, ignored_pids)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def burn_gpu(
|
|
358
|
+
device_id: int,
|
|
359
|
+
matrix_size: int,
|
|
360
|
+
dtype: str,
|
|
361
|
+
log_interval: float,
|
|
362
|
+
utilization: float,
|
|
363
|
+
stop_event,
|
|
364
|
+
) -> None:
|
|
365
|
+
os.environ["FLAGS_allocator_strategy"] = "auto_growth"
|
|
366
|
+
|
|
367
|
+
import paddle
|
|
368
|
+
|
|
369
|
+
paddle.set_device(f"gpu:{device_id}")
|
|
370
|
+
paddle.seed(2026 + device_id)
|
|
371
|
+
x = paddle.randn([matrix_size, matrix_size], dtype=dtype)
|
|
372
|
+
y = paddle.randn([matrix_size, matrix_size], dtype=dtype)
|
|
373
|
+
iterations = 0
|
|
374
|
+
started_at = time.monotonic()
|
|
375
|
+
last_log = started_at
|
|
376
|
+
|
|
377
|
+
print(
|
|
378
|
+
f"[gpu:{device_id}] started pid={os.getpid()} "
|
|
379
|
+
f"shape={matrix_size}x{matrix_size} dtype={dtype} "
|
|
380
|
+
f"utilization={utilization:g}%",
|
|
381
|
+
flush=True,
|
|
382
|
+
)
|
|
383
|
+
while not stop_event.is_set():
|
|
384
|
+
compute_started = time.perf_counter()
|
|
385
|
+
z = paddle.matmul(x, y)
|
|
386
|
+
if iterations % 8 == 7:
|
|
387
|
+
x = paddle.tanh(z)
|
|
388
|
+
paddle.device.synchronize(f"gpu:{device_id}")
|
|
389
|
+
compute_seconds = time.perf_counter() - compute_started
|
|
390
|
+
iterations += 1
|
|
391
|
+
idle_seconds = calculate_idle_seconds(compute_seconds, utilization)
|
|
392
|
+
if idle_seconds > 0:
|
|
393
|
+
stop_event.wait(idle_seconds)
|
|
394
|
+
now = time.monotonic()
|
|
395
|
+
if now - last_log >= log_interval:
|
|
396
|
+
elapsed = now - started_at
|
|
397
|
+
print(
|
|
398
|
+
f"[gpu:{device_id}] iterations={iterations} "
|
|
399
|
+
f"average={iterations / elapsed:.3f} matmul/s",
|
|
400
|
+
flush=True,
|
|
401
|
+
)
|
|
402
|
+
last_log = now
|
|
403
|
+
paddle.device.synchronize(f"gpu:{device_id}")
|
|
404
|
+
print(f"[gpu:{device_id}] stopped after {iterations} iterations", flush=True)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def parse_device_list(spec: str, available: int) -> list[int]:
|
|
408
|
+
devices: list[int] = []
|
|
409
|
+
for part in spec.split(","):
|
|
410
|
+
part = part.strip()
|
|
411
|
+
if "-" in part:
|
|
412
|
+
lo, hi = part.split("-", 1)
|
|
413
|
+
lo_value, hi_value = int(lo.strip()), int(hi.strip())
|
|
414
|
+
if lo_value > hi_value:
|
|
415
|
+
raise ValueError(f"invalid device range: {part}")
|
|
416
|
+
devices.extend(range(lo_value, hi_value + 1))
|
|
417
|
+
else:
|
|
418
|
+
devices.append(int(part))
|
|
419
|
+
unique: list[int] = []
|
|
420
|
+
for device in devices:
|
|
421
|
+
if device < 0 or device >= available:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"device {device} is out of range; available GPUs are 0..{available - 1}"
|
|
424
|
+
)
|
|
425
|
+
if device not in unique:
|
|
426
|
+
unique.append(device)
|
|
427
|
+
if not unique:
|
|
428
|
+
raise ValueError("device list is empty")
|
|
429
|
+
return unique
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def resolve_devices(args: argparse.Namespace, available: int) -> list[int]:
|
|
433
|
+
if available < 1:
|
|
434
|
+
raise RuntimeError("PaddlePaddle did not detect any CUDA GPUs")
|
|
435
|
+
if args.devices is not None:
|
|
436
|
+
return parse_device_list(args.devices, available)
|
|
437
|
+
gpu_count = available if args.gpus is None else args.gpus
|
|
438
|
+
if not 1 <= gpu_count <= available:
|
|
439
|
+
raise ValueError(f"--gpus must be between 1 and {available}, got {gpu_count}")
|
|
440
|
+
return list(range(gpu_count))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def query_paddle_gpu_count() -> int:
|
|
444
|
+
code = (
|
|
445
|
+
"import paddle; "
|
|
446
|
+
"assert paddle.is_compiled_with_cuda(), "
|
|
447
|
+
"'the installed PaddlePaddle package has no CUDA support'; "
|
|
448
|
+
"print(paddle.device.cuda.device_count())"
|
|
449
|
+
)
|
|
450
|
+
try:
|
|
451
|
+
result = subprocess.run(
|
|
452
|
+
[sys.executable, "-c", code],
|
|
453
|
+
check=False,
|
|
454
|
+
capture_output=True,
|
|
455
|
+
text=True,
|
|
456
|
+
timeout=30,
|
|
457
|
+
)
|
|
458
|
+
except subprocess.TimeoutExpired as error:
|
|
459
|
+
raise RuntimeError("PaddlePaddle GPU preflight timed out") from error
|
|
460
|
+
if result.returncode != 0:
|
|
461
|
+
details = result.stderr.strip().splitlines()
|
|
462
|
+
message = details[-1] if details else f"exit code {result.returncode}"
|
|
463
|
+
raise RuntimeError(f"PaddlePaddle GPU preflight failed: {message}")
|
|
464
|
+
try:
|
|
465
|
+
return int(result.stdout.strip().splitlines()[-1])
|
|
466
|
+
except (IndexError, ValueError) as error:
|
|
467
|
+
raise RuntimeError("PaddlePaddle GPU preflight returned an invalid count") from error
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def validate_workload(args: argparse.Namespace) -> list[int]:
|
|
471
|
+
if args.matrix_size <= 0:
|
|
472
|
+
raise ValueError("--size must be greater than zero")
|
|
473
|
+
validate_positive(args.log_interval, "--log-int")
|
|
474
|
+
validate_adaptive_thresholds(args.pause_percent, args.resume_percent)
|
|
475
|
+
validate_positive(args.idle_minutes, "--idle")
|
|
476
|
+
validate_positive(args.poll_interval, "--poll")
|
|
477
|
+
validate_utilization(args.utilization)
|
|
478
|
+
devices = resolve_devices(args, query_paddle_gpu_count())
|
|
479
|
+
detected = query_gpu_info(os.environ.get("CUDA_VISIBLE_DEVICES"))
|
|
480
|
+
missing = [device for device in devices if device not in detected]
|
|
481
|
+
if missing:
|
|
482
|
+
raise RuntimeError(f"nvidia-smi did not report selected devices: {missing}")
|
|
483
|
+
return devices
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def start_worker(state: WorkerState, args: argparse.Namespace, context) -> None:
|
|
487
|
+
stop_event = context.Event()
|
|
488
|
+
process = context.Process(
|
|
489
|
+
target=burn_gpu,
|
|
490
|
+
args=(
|
|
491
|
+
state.device_id,
|
|
492
|
+
args.matrix_size,
|
|
493
|
+
args.dtype,
|
|
494
|
+
args.log_interval,
|
|
495
|
+
args.utilization,
|
|
496
|
+
stop_event,
|
|
497
|
+
),
|
|
498
|
+
name=f"gpu-burn-{state.device_id}",
|
|
499
|
+
)
|
|
500
|
+
process.start()
|
|
501
|
+
state.process = process
|
|
502
|
+
state.stop_event = stop_event
|
|
503
|
+
state.idle_since = None
|
|
504
|
+
state.external_memory_percent = None
|
|
505
|
+
print(f"[gpu:{state.device_id}] worker launched pid={process.pid}", flush=True)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def stop_worker(state: WorkerState, timeout: float = DEFAULT_STOP_TIMEOUT) -> None:
|
|
509
|
+
process = state.process
|
|
510
|
+
if process is None:
|
|
511
|
+
return
|
|
512
|
+
if state.stop_event is not None:
|
|
513
|
+
state.stop_event.set()
|
|
514
|
+
process.join(timeout=timeout)
|
|
515
|
+
if process.is_alive():
|
|
516
|
+
process.terminate()
|
|
517
|
+
process.join(timeout=5)
|
|
518
|
+
if process.is_alive():
|
|
519
|
+
process.kill()
|
|
520
|
+
process.join(timeout=5)
|
|
521
|
+
state.process = None
|
|
522
|
+
state.stop_event = None
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def worker_record(state: WorkerState) -> dict[str, Any] | None:
|
|
526
|
+
process = state.process
|
|
527
|
+
if process is None or process.pid is None or not process.is_alive():
|
|
528
|
+
return None
|
|
529
|
+
start_time = proc_start_time(process.pid)
|
|
530
|
+
if start_time is None:
|
|
531
|
+
return None
|
|
532
|
+
return {
|
|
533
|
+
"device": state.device_id,
|
|
534
|
+
"pid": process.pid,
|
|
535
|
+
"proc_start_time": start_time,
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def update_record(
|
|
540
|
+
record_path: Path, record: dict[str, Any], states: dict[int, WorkerState]
|
|
541
|
+
) -> None:
|
|
542
|
+
workers = [worker_record(state) for state in states.values()]
|
|
543
|
+
record["workers"] = [worker for worker in workers if worker is not None]
|
|
544
|
+
record["device_states"] = {
|
|
545
|
+
str(device): {
|
|
546
|
+
"state": "running" if state.process is not None else "paused",
|
|
547
|
+
"external_memory_percent": state.external_memory_percent,
|
|
548
|
+
"idle_since": state.idle_since,
|
|
549
|
+
}
|
|
550
|
+
for device, state in states.items()
|
|
551
|
+
}
|
|
552
|
+
write_record(record_path, record)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def reconcile_workers(
|
|
556
|
+
states: dict[int, WorkerState],
|
|
557
|
+
external_percent: dict[int, float],
|
|
558
|
+
args: argparse.Namespace,
|
|
559
|
+
context,
|
|
560
|
+
now: float,
|
|
561
|
+
) -> bool:
|
|
562
|
+
failed = False
|
|
563
|
+
for device, state in states.items():
|
|
564
|
+
memory_percent = external_percent.get(device, 0.0)
|
|
565
|
+
process = state.process
|
|
566
|
+
if process is not None and process.exitcode is not None:
|
|
567
|
+
print(
|
|
568
|
+
f"[gpu:{device}] worker exited unexpectedly: {process.exitcode}",
|
|
569
|
+
file=sys.stderr,
|
|
570
|
+
flush=True,
|
|
571
|
+
)
|
|
572
|
+
state.process = None
|
|
573
|
+
state.stop_event = None
|
|
574
|
+
state.idle_since = now
|
|
575
|
+
failed = True
|
|
576
|
+
continue
|
|
577
|
+
|
|
578
|
+
if process is not None and memory_percent >= args.pause_percent:
|
|
579
|
+
print(
|
|
580
|
+
f"[gpu:{device}] pausing: external memory {memory_percent:.3f}% "
|
|
581
|
+
f">= pause threshold {args.pause_percent:g}%",
|
|
582
|
+
flush=True,
|
|
583
|
+
)
|
|
584
|
+
stop_worker(state)
|
|
585
|
+
state.external_memory_percent = memory_percent
|
|
586
|
+
state.idle_since = None
|
|
587
|
+
continue
|
|
588
|
+
|
|
589
|
+
if state.process is None:
|
|
590
|
+
state.external_memory_percent = memory_percent
|
|
591
|
+
if memory_percent > args.resume_percent:
|
|
592
|
+
state.idle_since = None
|
|
593
|
+
else:
|
|
594
|
+
if state.idle_since is None:
|
|
595
|
+
state.idle_since = now
|
|
596
|
+
print(
|
|
597
|
+
f"[gpu:{device}] idle timer started; restart in "
|
|
598
|
+
f"{args.idle_minutes:g} min if memory remains at or below "
|
|
599
|
+
f"{args.resume_percent:g}%",
|
|
600
|
+
flush=True,
|
|
601
|
+
)
|
|
602
|
+
if now - state.idle_since >= args.idle_minutes * 60.0:
|
|
603
|
+
print(f"[gpu:{device}] idle period reached; restarting", flush=True)
|
|
604
|
+
start_worker(state, args, context)
|
|
605
|
+
return failed
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def command_run(args: argparse.Namespace) -> int:
|
|
609
|
+
runtime_paths = paths()
|
|
610
|
+
lifetime_lock = acquire_lock(runtime_paths["lifetime_lock"], nonblocking=True)
|
|
611
|
+
if lifetime_lock is None:
|
|
612
|
+
print(f"{PROGRAM_NAME} is already running for uid {os.getuid()}", file=sys.stderr)
|
|
613
|
+
return 1
|
|
614
|
+
remove_stale_record(runtime_paths["record"])
|
|
615
|
+
states: dict[int, WorkerState] = {}
|
|
616
|
+
record: dict[str, Any] | None = None
|
|
617
|
+
stopping = False
|
|
618
|
+
|
|
619
|
+
def request_stop(_signum=None, _frame=None) -> None:
|
|
620
|
+
nonlocal stopping
|
|
621
|
+
stopping = True
|
|
622
|
+
|
|
623
|
+
signal.signal(signal.SIGINT, request_stop)
|
|
624
|
+
signal.signal(signal.SIGTERM, request_stop)
|
|
625
|
+
context = mp.get_context("spawn")
|
|
626
|
+
try:
|
|
627
|
+
devices = validate_workload(args)
|
|
628
|
+
device_info = {
|
|
629
|
+
device: info
|
|
630
|
+
for device, info in query_gpu_info(
|
|
631
|
+
os.environ.get("CUDA_VISIBLE_DEVICES")
|
|
632
|
+
).items()
|
|
633
|
+
if device in devices
|
|
634
|
+
}
|
|
635
|
+
start_time = proc_start_time(os.getpid())
|
|
636
|
+
if start_time is None:
|
|
637
|
+
raise RuntimeError("could not read this process start time from /proc")
|
|
638
|
+
states = {device: WorkerState(device) for device in devices}
|
|
639
|
+
record = {
|
|
640
|
+
"pid": os.getpid(),
|
|
641
|
+
"uid": os.getuid(),
|
|
642
|
+
"proc_start_time": start_time,
|
|
643
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
644
|
+
"gpus": len(devices),
|
|
645
|
+
"devices": devices,
|
|
646
|
+
"matrix_size": args.matrix_size,
|
|
647
|
+
"dtype": args.dtype,
|
|
648
|
+
"log_interval": args.log_interval,
|
|
649
|
+
"utilization": args.utilization,
|
|
650
|
+
"pause_percent": args.pause_percent,
|
|
651
|
+
"resume_percent": args.resume_percent,
|
|
652
|
+
"idle_minutes": args.idle_minutes,
|
|
653
|
+
"poll_interval": args.poll_interval,
|
|
654
|
+
}
|
|
655
|
+
initial_usage = query_external_percent_with_retry(device_info, {os.getpid()})
|
|
656
|
+
now = time.monotonic()
|
|
657
|
+
for device, state in states.items():
|
|
658
|
+
memory_percent = initial_usage.get(device, 0.0)
|
|
659
|
+
if memory_percent < args.pause_percent:
|
|
660
|
+
start_worker(state, args, context)
|
|
661
|
+
else:
|
|
662
|
+
state.external_memory_percent = memory_percent
|
|
663
|
+
print(
|
|
664
|
+
f"[gpu:{device}] initially paused: external memory "
|
|
665
|
+
f"{memory_percent:.3f}% >= pause threshold {args.pause_percent:g}%",
|
|
666
|
+
flush=True,
|
|
667
|
+
)
|
|
668
|
+
update_record(runtime_paths["record"], record, states)
|
|
669
|
+
print(f"Managing GPU devices {devices}; supervisor pid={os.getpid()}", flush=True)
|
|
670
|
+
|
|
671
|
+
exit_code = 0
|
|
672
|
+
while not stopping:
|
|
673
|
+
time.sleep(args.poll_interval)
|
|
674
|
+
ignored_pids = {os.getpid()} | {
|
|
675
|
+
state.process.pid
|
|
676
|
+
for state in states.values()
|
|
677
|
+
if state.process is not None and state.process.pid is not None
|
|
678
|
+
}
|
|
679
|
+
usage = query_external_percent_with_retry(device_info, ignored_pids)
|
|
680
|
+
now = time.monotonic()
|
|
681
|
+
if reconcile_workers(states, usage, args, context, now):
|
|
682
|
+
exit_code = 1
|
|
683
|
+
update_record(runtime_paths["record"], record, states)
|
|
684
|
+
return exit_code
|
|
685
|
+
finally:
|
|
686
|
+
for state in states.values():
|
|
687
|
+
stop_worker(state)
|
|
688
|
+
current = read_record(runtime_paths["record"])
|
|
689
|
+
if record is not None and current is not None and current.get("pid") == record["pid"]:
|
|
690
|
+
try:
|
|
691
|
+
runtime_paths["record"].unlink()
|
|
692
|
+
except FileNotFoundError:
|
|
693
|
+
pass
|
|
694
|
+
lifetime_lock.close()
|
|
695
|
+
print(f"{PROGRAM_NAME} stopped", flush=True)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def workload_command(command: str, args: argparse.Namespace) -> list[str]:
|
|
699
|
+
result = [sys.executable, "-m", "gpu_burn", command]
|
|
700
|
+
if args.devices is not None:
|
|
701
|
+
result.extend(["--devs", args.devices])
|
|
702
|
+
elif args.gpus is not None:
|
|
703
|
+
result.extend(["--gpus", str(args.gpus)])
|
|
704
|
+
result.extend(
|
|
705
|
+
[
|
|
706
|
+
"--size", str(args.matrix_size),
|
|
707
|
+
"--type", args.dtype,
|
|
708
|
+
"--log-int", str(args.log_interval),
|
|
709
|
+
"--util", str(args.utilization),
|
|
710
|
+
"--pause", str(args.pause_percent),
|
|
711
|
+
"--resume", str(args.resume_percent),
|
|
712
|
+
"--idle", str(args.idle_minutes),
|
|
713
|
+
"--poll", str(args.poll_interval),
|
|
714
|
+
]
|
|
715
|
+
)
|
|
716
|
+
return result
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def print_start_summary(
|
|
720
|
+
record: dict[str, Any], log_path: Path, status: str = "started"
|
|
721
|
+
) -> None:
|
|
722
|
+
devices = record.get("devices", [])
|
|
723
|
+
device_states = record.get("device_states", {})
|
|
724
|
+
worker_pids = {
|
|
725
|
+
int(worker["device"]): int(worker["pid"])
|
|
726
|
+
for worker in record.get("workers", [])
|
|
727
|
+
if "device" in worker and "pid" in worker
|
|
728
|
+
}
|
|
729
|
+
running = sum(
|
|
730
|
+
1
|
|
731
|
+
for device in devices
|
|
732
|
+
if device_states.get(str(device), {}).get("state") == "running"
|
|
733
|
+
)
|
|
734
|
+
paused = len(devices) - running
|
|
735
|
+
|
|
736
|
+
print(f"\n{PROGRAM_NAME} {__version__} {status}")
|
|
737
|
+
print(f" supervisor : pid {record['pid']}")
|
|
738
|
+
print(
|
|
739
|
+
f" devices : {len(devices)} selected, {running} running, {paused} paused"
|
|
740
|
+
)
|
|
741
|
+
for device in devices:
|
|
742
|
+
state = device_states.get(str(device), {})
|
|
743
|
+
if state.get("state") == "running":
|
|
744
|
+
pid = worker_pids.get(device)
|
|
745
|
+
detail = "running" if pid is None else f"running (pid {pid})"
|
|
746
|
+
else:
|
|
747
|
+
percent = state.get("external_memory_percent")
|
|
748
|
+
detail = "paused"
|
|
749
|
+
if percent is not None:
|
|
750
|
+
detail += f" (external memory {percent:.3f}%)"
|
|
751
|
+
print(f" gpu:{device:<6} : {detail}")
|
|
752
|
+
print(
|
|
753
|
+
f" workload : {record['matrix_size']}x{record['matrix_size']} "
|
|
754
|
+
f"{record['dtype']}, util {record.get('utilization', 100):g}%"
|
|
755
|
+
)
|
|
756
|
+
print(
|
|
757
|
+
f" adaptive : pause >= {record.get('pause_percent', 0):g}%, "
|
|
758
|
+
f"resume <= {record.get('resume_percent', 0):g}%, "
|
|
759
|
+
f"idle {record.get('idle_minutes', 0):g} min, "
|
|
760
|
+
f"poll {record.get('poll_interval', 0):g}s"
|
|
761
|
+
)
|
|
762
|
+
print(f" log : {log_path}\n")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def command_start(args: argparse.Namespace) -> int:
|
|
766
|
+
runtime_paths = paths()
|
|
767
|
+
control_lock = acquire_lock(runtime_paths["control_lock"])
|
|
768
|
+
assert control_lock is not None
|
|
769
|
+
try:
|
|
770
|
+
record = read_record(runtime_paths["record"])
|
|
771
|
+
if record is not None and instance_is_alive(record):
|
|
772
|
+
print_start_summary(record, runtime_paths["log"], "already running")
|
|
773
|
+
return 0
|
|
774
|
+
remove_stale_record(runtime_paths["record"])
|
|
775
|
+
spinner = Spinner(f"Starting {PROGRAM_NAME} {__version__} in the background")
|
|
776
|
+
spinner.start()
|
|
777
|
+
log_handle = runtime_paths["log"].open("a", encoding="utf-8")
|
|
778
|
+
try:
|
|
779
|
+
process = subprocess.Popen(
|
|
780
|
+
workload_command("run", args),
|
|
781
|
+
stdin=subprocess.DEVNULL,
|
|
782
|
+
stdout=log_handle,
|
|
783
|
+
stderr=subprocess.STDOUT,
|
|
784
|
+
start_new_session=True,
|
|
785
|
+
close_fds=True,
|
|
786
|
+
)
|
|
787
|
+
finally:
|
|
788
|
+
log_handle.close()
|
|
789
|
+
deadline = time.monotonic() + args.start_timeout
|
|
790
|
+
try:
|
|
791
|
+
while time.monotonic() < deadline:
|
|
792
|
+
record = read_record(runtime_paths["record"])
|
|
793
|
+
if record is not None and process_matches(record):
|
|
794
|
+
spinner.finish(f"{PROGRAM_NAME} is ready")
|
|
795
|
+
print_start_summary(record, runtime_paths["log"])
|
|
796
|
+
return 0
|
|
797
|
+
return_code = process.poll()
|
|
798
|
+
if return_code is not None:
|
|
799
|
+
message = f"{PROGRAM_NAME} failed to start (exit code {return_code})"
|
|
800
|
+
spinner.finish(message, success=False)
|
|
801
|
+
print(f"See log: {runtime_paths['log']}", file=sys.stderr)
|
|
802
|
+
return 1
|
|
803
|
+
spinner.tick()
|
|
804
|
+
time.sleep(0.1)
|
|
805
|
+
process.send_signal(signal.SIGTERM)
|
|
806
|
+
message = (
|
|
807
|
+
f"{PROGRAM_NAME} was not ready within {args.start_timeout:g}s"
|
|
808
|
+
)
|
|
809
|
+
spinner.finish(message, success=False)
|
|
810
|
+
print(f"See log: {runtime_paths['log']}", file=sys.stderr)
|
|
811
|
+
return 1
|
|
812
|
+
finally:
|
|
813
|
+
spinner.close()
|
|
814
|
+
finally:
|
|
815
|
+
control_lock.close()
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def command_stop(args: argparse.Namespace, quiet: bool = False) -> int:
|
|
819
|
+
runtime_paths = paths()
|
|
820
|
+
control_lock = acquire_lock(runtime_paths["control_lock"])
|
|
821
|
+
assert control_lock is not None
|
|
822
|
+
try:
|
|
823
|
+
record = read_record(runtime_paths["record"])
|
|
824
|
+
if record is None or not instance_is_alive(record):
|
|
825
|
+
remove_stale_record(runtime_paths["record"])
|
|
826
|
+
if not quiet:
|
|
827
|
+
print(f"{PROGRAM_NAME} is not running")
|
|
828
|
+
return 0
|
|
829
|
+
pid = int(record["pid"])
|
|
830
|
+
targets = matching_worker_pids(record)
|
|
831
|
+
if process_matches(record):
|
|
832
|
+
targets.append(pid)
|
|
833
|
+
for target in targets:
|
|
834
|
+
try:
|
|
835
|
+
os.kill(target, signal.SIGTERM)
|
|
836
|
+
except ProcessLookupError:
|
|
837
|
+
pass
|
|
838
|
+
deadline = time.monotonic() + args.timeout
|
|
839
|
+
while time.monotonic() < deadline and instance_is_alive(record):
|
|
840
|
+
time.sleep(0.1)
|
|
841
|
+
if instance_is_alive(record):
|
|
842
|
+
if not args.force:
|
|
843
|
+
print(
|
|
844
|
+
f"{PROGRAM_NAME} did not stop within {args.timeout:g}s; "
|
|
845
|
+
"retry with stop --force",
|
|
846
|
+
file=sys.stderr,
|
|
847
|
+
)
|
|
848
|
+
return 1
|
|
849
|
+
force_targets = matching_worker_pids(record)
|
|
850
|
+
if process_matches(record):
|
|
851
|
+
force_targets.append(pid)
|
|
852
|
+
for target in force_targets:
|
|
853
|
+
try:
|
|
854
|
+
os.kill(target, signal.SIGKILL)
|
|
855
|
+
except ProcessLookupError:
|
|
856
|
+
pass
|
|
857
|
+
force_deadline = time.monotonic() + 5
|
|
858
|
+
while time.monotonic() < force_deadline and instance_is_alive(record):
|
|
859
|
+
time.sleep(0.1)
|
|
860
|
+
if instance_is_alive(record):
|
|
861
|
+
print(f"failed to stop {PROGRAM_NAME}; managed processes remain", file=sys.stderr)
|
|
862
|
+
return 1
|
|
863
|
+
remove_stale_record(runtime_paths["record"])
|
|
864
|
+
if not quiet:
|
|
865
|
+
print(f"{PROGRAM_NAME} stopped")
|
|
866
|
+
return 0
|
|
867
|
+
finally:
|
|
868
|
+
control_lock.close()
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def command_status(_args: argparse.Namespace) -> int:
|
|
872
|
+
runtime_paths = paths()
|
|
873
|
+
record = read_record(runtime_paths["record"])
|
|
874
|
+
if record is None or not instance_is_alive(record):
|
|
875
|
+
remove_stale_record(runtime_paths["record"])
|
|
876
|
+
print(f"status: stopped\nstate: {runtime_paths['directory']}")
|
|
877
|
+
return 3
|
|
878
|
+
active_workers = matching_worker_pids(record)
|
|
879
|
+
device_states = record.get("device_states", {})
|
|
880
|
+
managed = sum(
|
|
881
|
+
1 for state in device_states.values() if state.get("state") == "running"
|
|
882
|
+
)
|
|
883
|
+
status = "running" if process_matches(record) else "degraded"
|
|
884
|
+
print(f"status: {status}")
|
|
885
|
+
print(f"pid: {record['pid']}")
|
|
886
|
+
print(f"started: {record['started_at']}")
|
|
887
|
+
print(f"workers: {len(active_workers)}/{record['gpus']} ({managed} running)")
|
|
888
|
+
print(f"worker_pids: {' '.join(str(pid) for pid in active_workers)}")
|
|
889
|
+
for device in record.get("devices", []):
|
|
890
|
+
state = device_states.get(str(device), {})
|
|
891
|
+
memory = state.get("external_memory_percent")
|
|
892
|
+
suffix = "" if memory is None else f", external_memory={memory:.3f}%"
|
|
893
|
+
print(f"device {device}: {state.get('state', 'unknown')}{suffix}")
|
|
894
|
+
print(
|
|
895
|
+
f"workload: {record['matrix_size']}x{record['matrix_size']} {record['dtype']}, "
|
|
896
|
+
f"utilization {record.get('utilization', 100):g}%"
|
|
897
|
+
)
|
|
898
|
+
print(
|
|
899
|
+
f"adaptive: pause {record.get('pause_percent', 0):g}%, "
|
|
900
|
+
f"resume {record.get('resume_percent', 0):g}%, "
|
|
901
|
+
f"idle {record.get('idle_minutes', 0):g} min"
|
|
902
|
+
)
|
|
903
|
+
print(f"log: {runtime_paths['log']}")
|
|
904
|
+
return 0
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def command_logs(args: argparse.Namespace) -> int:
|
|
908
|
+
log_path = paths()["log"]
|
|
909
|
+
if not log_path.exists():
|
|
910
|
+
print(f"no log file: {log_path}", file=sys.stderr)
|
|
911
|
+
return 1
|
|
912
|
+
command = ["tail", "-n", str(args.lines)]
|
|
913
|
+
if args.follow:
|
|
914
|
+
command.append("-f")
|
|
915
|
+
command.append(str(log_path))
|
|
916
|
+
try:
|
|
917
|
+
return subprocess.run(command, check=False).returncode
|
|
918
|
+
except KeyboardInterrupt:
|
|
919
|
+
return 130
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def command_restart(args: argparse.Namespace) -> int:
|
|
923
|
+
stop_args = argparse.Namespace(timeout=args.timeout, force=args.force)
|
|
924
|
+
if command_stop(stop_args, quiet=True) != 0:
|
|
925
|
+
return 1
|
|
926
|
+
return command_start(args)
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def add_workload_arguments(parser: argparse.ArgumentParser) -> None:
|
|
930
|
+
group = parser.add_mutually_exclusive_group()
|
|
931
|
+
group.add_argument("-g", "--gpus", type=int, default=None, help="GPU count (default: all)")
|
|
932
|
+
group.add_argument(
|
|
933
|
+
"-d", "--devs", dest="devices", type=str, default=None,
|
|
934
|
+
help="GPU IDs or ranges, e.g. '0,2,5' or '1-3,7'",
|
|
935
|
+
)
|
|
936
|
+
parser.add_argument("-m", "--size", dest="matrix_size", type=int, default=16384)
|
|
937
|
+
parser.add_argument("-t", "--type", dest="dtype", choices=("float16", "float32"), default="float16")
|
|
938
|
+
parser.add_argument("-l", "--log-int", dest="log_interval", type=float, default=30.0)
|
|
939
|
+
parser.add_argument(
|
|
940
|
+
"-u", "--util", dest="utilization", type=float, default=DEFAULT_UTILIZATION,
|
|
941
|
+
metavar="PERCENT", help=f"compute utilization percentage (default: {DEFAULT_UTILIZATION:g})",
|
|
942
|
+
)
|
|
943
|
+
parser.add_argument(
|
|
944
|
+
"-P", "--pause", dest="pause_percent", type=float, default=DEFAULT_PAUSE_PERCENT,
|
|
945
|
+
metavar="PERCENT", help=f"pause at this external memory percentage (default: {DEFAULT_PAUSE_PERCENT:g})",
|
|
946
|
+
)
|
|
947
|
+
parser.add_argument(
|
|
948
|
+
"-R", "--resume", dest="resume_percent", type=float, default=DEFAULT_RESUME_PERCENT,
|
|
949
|
+
metavar="PERCENT", help=f"start idle timer at or below this percentage (default: {DEFAULT_RESUME_PERCENT:g})",
|
|
950
|
+
)
|
|
951
|
+
parser.add_argument(
|
|
952
|
+
"-i", "--idle", dest="idle_minutes", type=float, default=DEFAULT_IDLE_MINUTES,
|
|
953
|
+
metavar="MINUTES", help=f"continuous idle minutes before restart (default: {DEFAULT_IDLE_MINUTES:g})",
|
|
954
|
+
)
|
|
955
|
+
parser.add_argument(
|
|
956
|
+
"-p", "--poll", dest="poll_interval", type=float, default=DEFAULT_POLL_INTERVAL,
|
|
957
|
+
metavar="SECONDS", help=f"GPU memory polling interval (default: {DEFAULT_POLL_INTERVAL:g})",
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
962
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
963
|
+
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
964
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
965
|
+
run_parser = subparsers.add_parser("run", help="run in the foreground")
|
|
966
|
+
add_workload_arguments(run_parser)
|
|
967
|
+
run_parser.set_defaults(handler=command_run)
|
|
968
|
+
start_parser = subparsers.add_parser("start", help="start in the background")
|
|
969
|
+
add_workload_arguments(start_parser)
|
|
970
|
+
start_parser.add_argument("--start-wait", dest="start_timeout", type=float, default=30.0)
|
|
971
|
+
start_parser.set_defaults(handler=command_start)
|
|
972
|
+
stop_parser = subparsers.add_parser("stop", help="stop this user's instance")
|
|
973
|
+
stop_parser.add_argument("-T", "--wait", dest="timeout", type=float, default=DEFAULT_STOP_TIMEOUT)
|
|
974
|
+
stop_parser.add_argument("-f", "--force", action="store_true")
|
|
975
|
+
stop_parser.set_defaults(handler=command_stop)
|
|
976
|
+
status_parser = subparsers.add_parser("status", help="show instance status")
|
|
977
|
+
status_parser.set_defaults(handler=command_status)
|
|
978
|
+
logs_parser = subparsers.add_parser("logs", help="show this user's log")
|
|
979
|
+
logs_parser.add_argument("-n", "--lines", type=int, default=20)
|
|
980
|
+
logs_parser.add_argument("-f", "--follow", action="store_true")
|
|
981
|
+
logs_parser.set_defaults(handler=command_logs)
|
|
982
|
+
restart_parser = subparsers.add_parser("restart", help="stop and start an instance")
|
|
983
|
+
add_workload_arguments(restart_parser)
|
|
984
|
+
restart_parser.add_argument("--start-wait", dest="start_timeout", type=float, default=30.0)
|
|
985
|
+
restart_parser.add_argument("-T", "--wait", dest="timeout", type=float, default=DEFAULT_STOP_TIMEOUT)
|
|
986
|
+
restart_parser.add_argument("-f", "--force", action="store_true")
|
|
987
|
+
restart_parser.set_defaults(handler=command_restart)
|
|
988
|
+
return parser
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def main() -> int:
|
|
992
|
+
parser = build_parser()
|
|
993
|
+
args = parser.parse_args()
|
|
994
|
+
try:
|
|
995
|
+
return args.handler(args)
|
|
996
|
+
except (OSError, RuntimeError, ValueError) as error:
|
|
997
|
+
print(f"{PROGRAM_NAME}: {error}", file=sys.stderr)
|
|
998
|
+
return 1
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gpu-burn
|
|
3
|
+
Version: 2.2.1
|
|
4
|
+
Summary: Control GPU compute utilization with a managed PaddlePaddle workload
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Source, https://github.com/cangtianhuang/gpu-burn
|
|
7
|
+
Project-URL: Issues, https://github.com/cangtianhuang/gpu-burn/issues
|
|
8
|
+
Project-URL: PaddlePaddle, https://www.paddlepaddle.org.cn/
|
|
9
|
+
Keywords: gpu,paddlepaddle,cuda,stress-test,utilization
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: System :: Hardware
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: paddlepaddle-gpu<4.0.0,>=2.6.2
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# gpu-burn
|
|
27
|
+
|
|
28
|
+
`gpu-burn` runs a configurable matrix-multiplication workload on one or more
|
|
29
|
+
NVIDIA GPUs using PaddlePaddle. It dynamically yields each GPU when another
|
|
30
|
+
process starts using a configured percentage of device memory, then resumes after
|
|
31
|
+
that GPU has remained idle for the configured period.
|
|
32
|
+
|
|
33
|
+
This project is not related to the CUDA/C++ project
|
|
34
|
+
[`wilicc/gpu-burn`](https://github.com/wilicc/gpu-burn). It does not validate
|
|
35
|
+
calculation results and should not be used as a hardware fault or ECC diagnostic.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Linux and Python 3.10 or newer
|
|
40
|
+
- NVIDIA GPU and a compatible CUDA driver
|
|
41
|
+
- **PaddlePaddle with CUDA support**
|
|
42
|
+
|
|
43
|
+
The PyPI package explicitly depends on `paddlepaddle-gpu>=2.6.2,<4.0.0`.
|
|
44
|
+
PaddlePaddle publishes different packages and indexes for some CUDA versions, so
|
|
45
|
+
install the build recommended by the
|
|
46
|
+
[official PaddlePaddle installation guide](https://www.paddlepaddle.org.cn/install/quick)
|
|
47
|
+
when the default PyPI build does not match your CUDA environment.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Install from PyPI:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python -m pip install gpu-burn
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For a CUDA-specific PaddlePaddle build, install PaddlePaddle first and then avoid
|
|
58
|
+
letting pip replace it:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Install the appropriate paddlepaddle-gpu build from the official Paddle index.
|
|
62
|
+
python -m pip install --no-deps gpu-burn
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Install from a source checkout:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m pip install .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Run on all detected GPUs in the foreground at full utilization:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
gpu-burn run
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Select devices and target an approximate compute duty cycle:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
gpu-burn run --devs 0,2-3 --util 60
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Configure adaptive yielding with hysteresis. This pauses only the affected GPU
|
|
86
|
+
when external compute processes use at least 5% of its total memory. Its worker
|
|
87
|
+
restarts after usage remains at or below 2% for 5 continuous minutes:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
gpu-burn run -d 0,2-3 -u 60 -P 5 -R 2 -i 5 -p 1
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Run as a per-user background process:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
gpu-burn start --gpus 2 --util 80
|
|
97
|
+
gpu-burn status
|
|
98
|
+
gpu-burn logs -f
|
|
99
|
+
gpu-burn stop
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
With no options, `gpu-burn start` selects every Paddle-visible GPU and uses:
|
|
103
|
+
|
|
104
|
+
- `16384x16384` float16 matrix multiplication
|
|
105
|
+
- 100% compute duty-cycle target
|
|
106
|
+
- pause at 5% external memory, resume at or below 2%
|
|
107
|
+
- 5 continuous idle minutes before restart
|
|
108
|
+
- 1-second monitoring interval
|
|
109
|
+
|
|
110
|
+
After the supervisor becomes ready, startup output includes the resolved GPU
|
|
111
|
+
count, each device's running or paused state, worker PIDs, workload, adaptive
|
|
112
|
+
policy, supervisor PID, and log path:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
✓ gpu-burn is ready
|
|
116
|
+
|
|
117
|
+
gpu-burn 2.2.1 started
|
|
118
|
+
supervisor : pid 42000
|
|
119
|
+
devices : 2 selected, 2 running, 0 paused
|
|
120
|
+
gpu:0 : running (pid 42010)
|
|
121
|
+
gpu:1 : running (pid 42011)
|
|
122
|
+
workload : 16384x16384 float16, util 100%
|
|
123
|
+
adaptive : pause >= 5%, resume <= 2%, idle 5 min, poll 1s
|
|
124
|
+
log : /tmp/gpu-burn-1000/gpu-burn.log
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
On an interactive terminal, the first line is an animated spinner while Paddle
|
|
128
|
+
and NVIDIA devices are checked. Redirected output automatically uses plain text.
|
|
129
|
+
Set `GPU_BURN_NO_SPINNER=1` to disable animation explicitly.
|
|
130
|
+
|
|
131
|
+
The main workload options are:
|
|
132
|
+
|
|
133
|
+
| Option | Meaning | Default |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `-g`, `--gpus N` | Use the first `N` visible GPUs | all |
|
|
136
|
+
| `-d`, `--devs LIST` | Device IDs/ranges such as `0,2-3` | all |
|
|
137
|
+
| `-u`, `--util PERCENT` | Approximate compute duty cycle in `(0, 100]` | `100` |
|
|
138
|
+
| `-m`, `--size N` | Square matrix dimension | `16384` |
|
|
139
|
+
| `-t`, `--type TYPE` | `float16` or `float32` | `float16` |
|
|
140
|
+
| `-l`, `--log-int SECONDS` | Worker progress log interval | `30` |
|
|
141
|
+
| `-P`, `--pause PERCENT` | Pause at this external memory percentage | `5` |
|
|
142
|
+
| `-R`, `--resume PERCENT` | Start the idle timer at or below this percentage | `2` |
|
|
143
|
+
| `-i`, `--idle MINUTES` | Continuous idle minutes before restart | `5` |
|
|
144
|
+
| `-p`, `--poll SECONDS` | Memory polling interval | `1` |
|
|
145
|
+
|
|
146
|
+
`--gpus` and `--devs` are mutually exclusive. Lower utilization is
|
|
147
|
+
implemented by alternating synchronized matrix multiplication with idle time, so
|
|
148
|
+
the value observed by monitoring tools can vary with sampling intervals and GPU
|
|
149
|
+
power-management behavior.
|
|
150
|
+
|
|
151
|
+
The supervisor queries `nvidia-smi` compute applications and excludes its own
|
|
152
|
+
worker PIDs. Memory from other compute processes is summed per selected GPU and
|
|
153
|
+
divided by that GPU's total physical memory. At `--pause`, only that GPU's
|
|
154
|
+
worker is stopped, which releases its PaddlePaddle tensors and GPU memory. Once
|
|
155
|
+
usage reaches `--resume` or lower, it must remain there for `--idle` minutes
|
|
156
|
+
before a new worker starts. `--resume` must be lower than `--pause`; this
|
|
157
|
+
hysteresis prevents repeated stop/start cycles near one threshold. Display-only
|
|
158
|
+
contexts absent from NVIDIA's compute-app list are not considered.
|
|
159
|
+
Numeric and UUID-based `CUDA_VISIBLE_DEVICES` remapping is honored, so CLI device
|
|
160
|
+
IDs remain the same logical IDs that PaddlePaddle exposes.
|
|
161
|
+
|
|
162
|
+
## State and cleanup
|
|
163
|
+
|
|
164
|
+
Only one managed instance is allowed per user. Runtime state and logs are stored
|
|
165
|
+
under `$GPU_BURN_STATE_DIR`, `$XDG_RUNTIME_DIR/gpu-burn`, or
|
|
166
|
+
`/tmp/gpu-burn-$UID`, in that order. `gpu-burn stop --force` sends `SIGKILL` if a
|
|
167
|
+
worker does not stop before the configured timeout.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
The PEP 517 build requirement uses a compatible lower bound
|
|
172
|
+
(`setuptools>=77`) instead of pinning one exact backend release. An exact pin
|
|
173
|
+
makes builds byte-for-byte easier to reproduce, but also forces every source
|
|
174
|
+
installer to download that precise release and can unnecessarily break offline
|
|
175
|
+
or constrained environments. This pure-Python project does not need `wheel` in
|
|
176
|
+
`build-system.requires`; setuptools implements the wheel build hook itself.
|
|
177
|
+
Release CI pins the user-facing `build` and `twine` tools separately.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python -m pip install --no-deps -e .
|
|
181
|
+
python -m pytest
|
|
182
|
+
python -m build
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Run the opt-in adaptive lifecycle test on a real GPU:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
GPU_BURN_TEST_DEVICE=0 python tests/real_gpu_dynamic.py
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
gpu_burn/__init__.py,sha256=DNInW4C8-cRGoEVAkZI82RBdmodZVoPVLXsMhdg2E0M,68
|
|
2
|
+
gpu_burn/__main__.py,sha256=XiQVCf1vRCFHcKwUEtDlvg8ZoL0INNbirGwZ7_009D8,121
|
|
3
|
+
gpu_burn/cli.py,sha256=Q_BnaJ2bOaefDyu99_fUS_y-Egu1Np0AyE5uhSx54rY,37222
|
|
4
|
+
gpu_burn-2.2.1.dist-info/licenses/LICENSE,sha256=DfEWpDJkKpy0NF2NBXgCHShN0qh8qG0EI-FtytAxf3w,1079
|
|
5
|
+
gpu_burn-2.2.1.dist-info/METADATA,sha256=fFNndy567rhDY8q9BYBXnUX3DLNvhksNhAb5OHNz6ow,6943
|
|
6
|
+
gpu_burn-2.2.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
7
|
+
gpu_burn-2.2.1.dist-info/entry_points.txt,sha256=F298jeoHF2kwKhjHztcAoek7ks6tqzwbyQrHXAVZ8kg,47
|
|
8
|
+
gpu_burn-2.2.1.dist-info/top_level.txt,sha256=J6_AYXWsvy93qHY6NGhvJWnzLPuWn3o_nDdk38er9w0,9
|
|
9
|
+
gpu_burn-2.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gpu-burn contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gpu_burn
|