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 ADDED
@@ -0,0 +1,3 @@
1
+ """PaddlePaddle GPU utilization workload."""
2
+
3
+ __version__ = "2.2.1"
gpu_burn/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ sys.argv[0] = "gpu-burn"
8
+ raise SystemExit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gpu-burn = gpu_burn.cli:main
@@ -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