tuneplane-node 0.3.15__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.
- tuneplane_node/__init__.py +1 -0
- tuneplane_node/allocator.py +596 -0
- tuneplane_node/cli.py +189 -0
- tuneplane_node/daemon.py +497 -0
- tuneplane_node/inventory.py +163 -0
- tuneplane_node/join.py +199 -0
- tuneplane_node/runtime.py +611 -0
- tuneplane_node/settings.py +77 -0
- tuneplane_node/wire.py +85 -0
- tuneplane_node-0.3.15.dist-info/METADATA +12 -0
- tuneplane_node-0.3.15.dist-info/RECORD +13 -0
- tuneplane_node-0.3.15.dist-info/WHEEL +4 -0
- tuneplane_node-0.3.15.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TunePlane node agent: what a machine needs to be a Fleet node."""
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Physical GPU allocator.
|
|
2
|
+
|
|
3
|
+
Who decides "which cards this job gets" when there is no Kubernetes
|
|
4
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
On the kuberay backend kube-scheduler and the NVIDIA device plugin do it: the Pod
|
|
6
|
+
declares `nvidia.com/gpu: 4`, the scheduler guarantees no over-subscription, and the
|
|
7
|
+
device plugin injects the actual cards into the container.
|
|
8
|
+
|
|
9
|
+
The local backend has neither. What their absence costs: **two jobs placed on the
|
|
10
|
+
same card**, both going OOM or slowing each other down, and the symptom is random --
|
|
11
|
+
it depends on which one asks for memory first. That is the most typical failure
|
|
12
|
+
without a device plugin, and the hardest to diagnose, so this layer cannot be
|
|
13
|
+
skipped.
|
|
14
|
+
|
|
15
|
+
Why the source of truth is container labels rather than a table in memory
|
|
16
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
The console restarts (upgrades, crashes, migrations) while a training job runs for
|
|
18
|
+
hours or days. An in-memory table is empty after a restart, so a new job is placed
|
|
19
|
+
on a card already in use -- exactly the failure above.
|
|
20
|
+
|
|
21
|
+
Writing "who holds which card" into a container label (`tuneplane.gpus`) means one
|
|
22
|
+
`docker ps` rebuilds the whole picture after a restart. This is the same judgement
|
|
23
|
+
as "object storage is the truth, no DB table" on the datasets side: state lives
|
|
24
|
+
**where it already belongs**, so there is no second copy to keep in sync.
|
|
25
|
+
|
|
26
|
+
Use from outside the platform can only be seen through nvidia-smi, and that has a
|
|
27
|
+
fatal blind spot
|
|
28
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
Container labels cover only the jobs the platform started. A python somebody ran by
|
|
30
|
+
hand over ssh, or a container another tool started, the platform knows nothing
|
|
31
|
+
about -- only nvidia-smi can be asked.
|
|
32
|
+
|
|
33
|
+
But the NVIDIA driver **does not honour PID namespaces**: `--query-compute-apps`
|
|
34
|
+
reports host PIDs, which do not exist inside the container the console runs in, so
|
|
35
|
+
the process list comes back empty. Which is to say that under a production
|
|
36
|
+
deployment (docker-compose) the "look at the processes" route is effectively off,
|
|
37
|
+
and a full card is handed out as idle. So occupancy detection has to look at
|
|
38
|
+
**memory used** as well (a per-device counter, the same inside a container as
|
|
39
|
+
outside), and when detection is unavailable altogether it has to say so
|
|
40
|
+
(`external_probe_ok`) instead of silently reporting the cards free.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import asyncio
|
|
45
|
+
import contextlib
|
|
46
|
+
import logging
|
|
47
|
+
import shutil
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from typing import Optional
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _raw_series(profile: str, gpu_name: str) -> str:
|
|
53
|
+
"""What a node reports when nobody has told it about hardware series.
|
|
54
|
+
|
|
55
|
+
The registry belongs to the console, so a node hands back the raw strings
|
|
56
|
+
and the console maps them. Lower-cased only, because a series id is
|
|
57
|
+
lower-case everywhere else and a node that shouted would look like a
|
|
58
|
+
different series to a case-sensitive comparison.
|
|
59
|
+
"""
|
|
60
|
+
return (profile or gpu_name or "").strip().lower()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _lock_token() -> str:
|
|
64
|
+
"""A value unique to this holder of the allocation lock.
|
|
65
|
+
|
|
66
|
+
Time-prefixed so a stuck lock says roughly when it was taken. Inlined rather
|
|
67
|
+
than imported from the console's redis helpers: three lines is not worth a
|
|
68
|
+
dependency from the node package back into the control plane.
|
|
69
|
+
"""
|
|
70
|
+
import secrets
|
|
71
|
+
import time as _time
|
|
72
|
+
|
|
73
|
+
return f"{int(_time.time() * 1000)}-{secrets.token_hex(8)}"
|
|
74
|
+
|
|
75
|
+
log = logging.getLogger(__name__)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NoCapacity(RuntimeError):
|
|
79
|
+
"""Not enough free cards.
|
|
80
|
+
|
|
81
|
+
A type of its own because callers have to treat it differently: this is **not an
|
|
82
|
+
error**, it is "not your turn yet". The dequeue loop catches it and leaves the job
|
|
83
|
+
in the queue to try again next round rather than marking it failed.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class GpuOccupancy:
|
|
89
|
+
"""What is physically held at one moment."""
|
|
90
|
+
|
|
91
|
+
total: int
|
|
92
|
+
#: card index -> run_id (the platform's own jobs)
|
|
93
|
+
by_job: dict[int, str] = field(default_factory=dict)
|
|
94
|
+
#: indices of cards held from outside the platform (somebody's hand-run python)
|
|
95
|
+
external: set[int] = field(default_factory=set)
|
|
96
|
+
#: card index -> why it is unhealthy (pending retired pages / uncorrected errors /
|
|
97
|
+
#: hardware slowdown). Such a card exists but must not be handed out: a job placed
|
|
98
|
+
#: on it crashes mid-training or silently runs slow.
|
|
99
|
+
unhealthy: dict[int, str] = field(default_factory=dict)
|
|
100
|
+
#: Whether detection of use from outside the platform worked. When False `external`
|
|
101
|
+
#: is always empty, which **does not mean nobody holds a card** -- it has to be
|
|
102
|
+
#: visible, or the chain "free count inflated -> a full card handed out -> the job
|
|
103
|
+
#: goes OOM" leaves no trace at all.
|
|
104
|
+
external_probe_ok: bool = True
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def busy(self) -> set[int]:
|
|
108
|
+
return set(self.by_job) | self.external | set(self.unhealthy)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def free(self) -> list[int]:
|
|
112
|
+
return [i for i in range(self.total) if i not in self.busy]
|
|
113
|
+
|
|
114
|
+
def explain(self) -> str:
|
|
115
|
+
"""Plain-language explanation for the admission gate and the /scheduling API."""
|
|
116
|
+
parts = [f"{self.total} cards total, {len(self.free)} free"]
|
|
117
|
+
if self.by_job:
|
|
118
|
+
parts.append(f"{len(self.by_job)} held by platform jobs")
|
|
119
|
+
if self.external:
|
|
120
|
+
ext = ",".join(str(i) for i in sorted(self.external))
|
|
121
|
+
parts.append(f"{len(self.external)} held by processes outside the platform (cards {ext})")
|
|
122
|
+
if self.unhealthy:
|
|
123
|
+
bad = "; ".join(f"card {i}: {r}" for i, r in sorted(self.unhealthy.items()))
|
|
124
|
+
parts.append(f"{len(self.unhealthy)} unhealthy ({bad})")
|
|
125
|
+
if not self.external_probe_ok:
|
|
126
|
+
parts.append("cannot probe use from outside the platform (nvidia-smi not found), "
|
|
127
|
+
"so the free count may be too high")
|
|
128
|
+
return "; ".join(parts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _smi(*args: str) -> Optional[str]:
|
|
132
|
+
"""Run nvidia-smi once and return its stdout; None when it cannot be run.
|
|
133
|
+
|
|
134
|
+
★ None and an empty string are two different things: None is "detection is broken,
|
|
135
|
+
I do not know whether anyone holds a card", empty is "asked, and nobody does". The
|
|
136
|
+
occupancy view must keep them apart -- reading "unknown" as "nobody" hands out a
|
|
137
|
+
full card as idle, which is the failure this module exists to prevent.
|
|
138
|
+
"""
|
|
139
|
+
if not shutil.which("nvidia-smi"):
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
proc = await asyncio.create_subprocess_exec(
|
|
143
|
+
"nvidia-smi", *args,
|
|
144
|
+
stdout=asyncio.subprocess.PIPE,
|
|
145
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
146
|
+
)
|
|
147
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
|
|
148
|
+
except (OSError, asyncio.TimeoutError) as e:
|
|
149
|
+
log.debug("nvidia-smi %s failed: %s", " ".join(args), e)
|
|
150
|
+
return None
|
|
151
|
+
if proc.returncode != 0:
|
|
152
|
+
log.debug("nvidia-smi %s exited %s", " ".join(args), proc.returncode)
|
|
153
|
+
return None
|
|
154
|
+
return out.decode(errors="replace")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _nvidia_smi_busy() -> set[int]:
|
|
158
|
+
"""Which cards hold a process that **can be seen**.
|
|
159
|
+
|
|
160
|
+
⚠ This probe has a blind spot inside a container and cannot decide on its own --
|
|
161
|
+
see _nvidia_smi_memory_busy. Its value is that it attributes use to a specific
|
|
162
|
+
process, which is more precise than memory used when the console runs on the host.
|
|
163
|
+
"""
|
|
164
|
+
out = await _smi("--query-compute-apps=gpu_uuid", "--format=csv,noheader")
|
|
165
|
+
if not out:
|
|
166
|
+
return set()
|
|
167
|
+
uuids = {line.strip() for line in out.splitlines() if line.strip()}
|
|
168
|
+
if not uuids:
|
|
169
|
+
return set()
|
|
170
|
+
return await _uuids_to_indexes(uuids)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _uuids_to_indexes(uuids: set[str]) -> set[int]:
|
|
174
|
+
"""GPU UUID -> card index.
|
|
175
|
+
|
|
176
|
+
`--query-compute-apps` gives UUIDs and no index, so the mapping takes a second
|
|
177
|
+
query. The order processes appear in cannot stand in for the index: that is wrong
|
|
178
|
+
as soon as there are several processes, or MIG.
|
|
179
|
+
"""
|
|
180
|
+
out = await _smi("--query-gpu=index,uuid", "--format=csv,noheader")
|
|
181
|
+
if not out:
|
|
182
|
+
return set()
|
|
183
|
+
busy: set[int] = set()
|
|
184
|
+
for line in out.splitlines():
|
|
185
|
+
parts = [p.strip() for p in line.split(",")]
|
|
186
|
+
if len(parts) >= 2 and parts[0].isdigit() and parts[1] in uuids:
|
|
187
|
+
busy.add(int(parts[0]))
|
|
188
|
+
return busy
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
#: Memory used above this (MiB) counts the card as in use. An idle card usually sits
|
|
192
|
+
#: at single-digit MiB while a training process holds tens of GB -- the threshold sits
|
|
193
|
+
#: between them, with room for the driver and ECC baseline.
|
|
194
|
+
_MEM_BUSY_MIB = 512
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def parse_gpu_memory_busy(text: str, threshold_mib: int = _MEM_BUSY_MIB) -> set[int]:
|
|
198
|
+
"""`--query-gpu=index,memory.used --format=...,nounits` output -> indices in use."""
|
|
199
|
+
busy: set[int] = set()
|
|
200
|
+
for line in text.splitlines():
|
|
201
|
+
parts = [p.strip() for p in line.split(",")]
|
|
202
|
+
if len(parts) < 2 or not parts[0].isdigit():
|
|
203
|
+
continue
|
|
204
|
+
used = parts[1].split()[0] if parts[1] else ""
|
|
205
|
+
if used.isdigit() and int(used) >= threshold_mib:
|
|
206
|
+
busy.add(int(parts[0]))
|
|
207
|
+
return busy
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _nvidia_smi_memory_busy() -> Optional[set[int]]:
|
|
211
|
+
"""Which cards have memory in use. None = detection unavailable.
|
|
212
|
+
|
|
213
|
+
Why the process list (_nvidia_smi_busy) is not enough on its own: the NVIDIA driver
|
|
214
|
+
**does not honour PID namespaces**. `--query-compute-apps` returns host PIDs, which
|
|
215
|
+
do not exist inside a container, so the process list is either empty or full of
|
|
216
|
+
`[Not Found]` process names. And the console itself runs in a container (`gpus: all`
|
|
217
|
+
in docker-compose.yml is only there to inject nvidia-smi and the driver libraries),
|
|
218
|
+
so under a production deployment use from outside the platform cannot be seen at
|
|
219
|
+
all -- the platform hands out a card that is already full as idle, the job goes OOM
|
|
220
|
+
the moment it starts, and the symptom is "there are four idle cards, why did it
|
|
221
|
+
insist on the full one".
|
|
222
|
+
|
|
223
|
+
Memory used is a **per-device** counter, unrelated to processes and identical inside
|
|
224
|
+
a container and out, so it has no such blind spot. The cost is that it cannot say
|
|
225
|
+
who is holding the card, which is why the two probes are unioned rather than one
|
|
226
|
+
replacing the other.
|
|
227
|
+
"""
|
|
228
|
+
out = await _smi("--query-gpu=index,memory.used", "--format=csv,noheader,nounits")
|
|
229
|
+
return parse_gpu_memory_busy(out) if out is not None else None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
#: The columns the GPU health probe asks for. A field can be missing depending on the
|
|
233
|
+
#: driver version (its value reads [N/A]), so parsing tolerates that per column.
|
|
234
|
+
_HEALTH_QUERY = (
|
|
235
|
+
"index,retired_pages.pending,ecc.errors.uncorrected.volatile.total,"
|
|
236
|
+
"clocks_throttle_reasons.hw_slowdown"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_gpu_health(text: str) -> dict[int, str]:
|
|
241
|
+
"""nvidia-smi health query output -> {card index: why it is unhealthy}.
|
|
242
|
+
|
|
243
|
+
Only three signals count, all of them **certainly grounds to stop using the card**,
|
|
244
|
+
so that normal variation is not read as a fault:
|
|
245
|
+
- retired_pages.pending=Yes: memory pages are waiting to be retired, which needs
|
|
246
|
+
a GPU reset to take effect
|
|
247
|
+
- uncorrected ECC errors > 0: memory is already producing errors, so the training
|
|
248
|
+
result cannot be trusted
|
|
249
|
+
- hw_slowdown=Active: hardware throttling (heat or power), which makes training
|
|
250
|
+
silently slower
|
|
251
|
+
|
|
252
|
+
This kind of slow-or-broken **raises nothing** -- the job runs as usual, it is only
|
|
253
|
+
the result that is wrong or the speed that is down. Same family of failure as
|
|
254
|
+
"scattered PCIe cards make all-reduce slow": the hardest kind to diagnose.
|
|
255
|
+
"""
|
|
256
|
+
out: dict[int, str] = {}
|
|
257
|
+
for line in text.splitlines():
|
|
258
|
+
parts = [p.strip() for p in line.split(",")]
|
|
259
|
+
if len(parts) < 4 or not parts[0].isdigit():
|
|
260
|
+
continue
|
|
261
|
+
idx = int(parts[0])
|
|
262
|
+
reasons = []
|
|
263
|
+
if parts[1].lower() == "yes":
|
|
264
|
+
reasons.append("pending retired memory pages (needs a GPU reset)")
|
|
265
|
+
if parts[2].isdigit() and int(parts[2]) > 0:
|
|
266
|
+
reasons.append(f"{parts[2]} uncorrected ECC errors")
|
|
267
|
+
if parts[3].lower() == "active":
|
|
268
|
+
reasons.append("hardware slowdown (heat or power)")
|
|
269
|
+
if reasons:
|
|
270
|
+
out[idx] = ", ".join(reasons)
|
|
271
|
+
return out
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _nvidia_smi_unhealthy() -> dict[int, str]:
|
|
275
|
+
"""Which cards are unhealthy. Empty when it cannot be asked -- a health check that
|
|
276
|
+
fails only degrades to "no check", it does not over-subscribe the way a failed
|
|
277
|
+
occupancy probe does, so "unknown" and "none" need not be told apart here."""
|
|
278
|
+
out = await _smi(f"--query-gpu={_HEALTH_QUERY}", "--format=csv,noheader")
|
|
279
|
+
return parse_gpu_health(out) if out else {}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def detect_gpu_count() -> int:
|
|
283
|
+
"""Physical card count on this machine. 0 when it cannot be probed, and the caller
|
|
284
|
+
falls back to the configured value."""
|
|
285
|
+
out = await _smi("--query-gpu=index", "--format=csv,noheader")
|
|
286
|
+
if not out:
|
|
287
|
+
return 0
|
|
288
|
+
return len([x for x in out.splitlines() if x.strip()])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def detect_gpu_name() -> str:
|
|
292
|
+
"""NVML name of the first card (e.g. "NVIDIA H200"). Empty when it cannot be probed.
|
|
293
|
+
|
|
294
|
+
On a single-machine backend every card is the same model (one node), so the first
|
|
295
|
+
one stands for the machine.
|
|
296
|
+
"""
|
|
297
|
+
out = await _smi("--query-gpu=name", "--format=csv,noheader")
|
|
298
|
+
return next((x.strip() for x in (out or "").splitlines() if x.strip()), "")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def pick_gpus(free: list[int], count: int) -> list[int]:
|
|
302
|
+
"""Pick `count` cards out of the free ones, **preferring consecutive indices**.
|
|
303
|
+
|
|
304
|
+
Why consecutive matters: jobs coming and going fragment the free set into shapes
|
|
305
|
+
like {0,3,5,7}. On a fully NVSwitch-connected machine (H200 SXM) it makes no
|
|
306
|
+
difference which cards are picked; on a PCIe machine, bandwidth between cards under
|
|
307
|
+
the same PCIe switch is markedly higher, and a scattered pick makes all-reduce
|
|
308
|
+
slower -- a slowness that raises nothing and leaves people believing training is
|
|
309
|
+
simply this slow.
|
|
310
|
+
|
|
311
|
+
When no consecutive window exists, fall back to taking them in order: running beats
|
|
312
|
+
running fast.
|
|
313
|
+
"""
|
|
314
|
+
if count <= 0:
|
|
315
|
+
return []
|
|
316
|
+
ordered = sorted(free)
|
|
317
|
+
if len(ordered) < count:
|
|
318
|
+
raise NoCapacity(f"not enough free cards: need {count}, available {len(ordered)}")
|
|
319
|
+
|
|
320
|
+
for start in range(len(ordered) - count + 1):
|
|
321
|
+
window = ordered[start:start + count]
|
|
322
|
+
if window[-1] - window[0] == count - 1: # consecutive
|
|
323
|
+
return window
|
|
324
|
+
return ordered[:count]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
#: Key and lease for the cross-process allocation lock. The lease has to cover "read
|
|
328
|
+
#: occupancy + docker create/start" -- a few seconds when the image is already there,
|
|
329
|
+
#: so 120s leaves plenty of room. While the lock is held a background task renews it
|
|
330
|
+
#: every third of the lease, so genuinely slow work is not overtaken, and a process
|
|
331
|
+
#: that crashes releases it after at most 120s.
|
|
332
|
+
_ALLOC_LOCK_KEY = "gpu-alloc:leader"
|
|
333
|
+
_ALLOC_LEASE_MS = 120_000
|
|
334
|
+
_ALLOC_RETRY_S = 0.25
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class GpuAllocator:
|
|
338
|
+
"""Hand physical cards to jobs.
|
|
339
|
+
|
|
340
|
+
Concurrency: the dequeue loop is serial, but the path where the API submits
|
|
341
|
+
directly runs alongside it. Allocation has to be one atomic "read occupancy -> pick
|
|
342
|
+
cards -> start the container", or two jobs pick the same cards. The lock lives on
|
|
343
|
+
the allocator rather than on callers, because the cost of one caller forgetting it
|
|
344
|
+
(silently stealing cards) is far worse than one extra wait.
|
|
345
|
+
|
|
346
|
+
★ Two layers of lock, because concurrency has two sources:
|
|
347
|
+
In-process (asyncio.Lock) -- between direct API submissions inside the web
|
|
348
|
+
process, and between the in-process worker's dequeue loop and those.
|
|
349
|
+
Cross-process (Redis) -- with `TUNEPLANE_INPROCESS_WORKERS=0` the dequeue loop runs
|
|
350
|
+
inside `python -m server.worker --role scheduler`, which builds its own
|
|
351
|
+
LocalExecutor, its own GpuAllocator and its own asyncio.Lock; `uvicorn
|
|
352
|
+
--workers N` is the same story. With only the in-process lock those two
|
|
353
|
+
exclude each other not at all, and the double-allocation window is the few
|
|
354
|
+
seconds between occupancy() and docker run -- the failure this module exists
|
|
355
|
+
to prevent.
|
|
356
|
+
Without Redis configured the cross-process lock degrades to a no-op (NullRedis
|
|
357
|
+
always returns True), so the combination "local + several processes + no Redis"
|
|
358
|
+
is refused outright at startup; see server/bootstrap.py:check_allocation_safety.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def __init__(self, runtime, settings, *, redis=None, series_resolver=None):
|
|
362
|
+
"""series_resolver: (configured profile, driver's card name) -> series id.
|
|
363
|
+
|
|
364
|
+
Injected because the hardware registry is the console's. Absent one --
|
|
365
|
+
which is every node -- the raw names are reported and the console maps
|
|
366
|
+
them at registration.
|
|
367
|
+
"""
|
|
368
|
+
self.runtime = runtime
|
|
369
|
+
self.settings = settings
|
|
370
|
+
self.redis = redis
|
|
371
|
+
self._series_resolver = series_resolver
|
|
372
|
+
self._lock = asyncio.Lock()
|
|
373
|
+
self._total: Optional[int] = None
|
|
374
|
+
self._series: Optional[str] = None
|
|
375
|
+
|
|
376
|
+
async def total_gpus(self) -> int:
|
|
377
|
+
"""Physical card count. An explicit setting wins: probing can fail when
|
|
378
|
+
the console itself runs inside a container.
|
|
379
|
+
|
|
380
|
+
A failed probe is not cached. The guard used to be `if self._total is
|
|
381
|
+
None`, but `configured or await detect_gpu_count()` stores whatever the
|
|
382
|
+
probe returned, and a failure returns 0 -- which is not None, so it was
|
|
383
|
+
cached for the lifetime of the process. One transient miss (nvidia-smi
|
|
384
|
+
briefly unavailable, or the 10s `_smi` timeout firing under load) then
|
|
385
|
+
pinned the node at zero cards forever: `occupancy().free` is empty so
|
|
386
|
+
every launch raises NoCapacity, and `cluster_gpus()` reports total=0 so
|
|
387
|
+
`series_total()` returns None and the capacity gate silently stops
|
|
388
|
+
checking anything. Only a restart recovered it.
|
|
389
|
+
"""
|
|
390
|
+
if self._total is None or self._total <= 0:
|
|
391
|
+
configured = int(getattr(self.settings, "local_gpu_count", 0) or 0)
|
|
392
|
+
self._total = configured or await detect_gpu_count()
|
|
393
|
+
return self._total
|
|
394
|
+
|
|
395
|
+
async def series_id(self) -> str:
|
|
396
|
+
"""Which series this machine's cards are (the hardware registry's series id);
|
|
397
|
+
empty when they cannot be identified.
|
|
398
|
+
|
|
399
|
+
The deployment's configured profile answers first -- an operator may have
|
|
400
|
+
classified them deliberately -- and with none configured the driver is asked for
|
|
401
|
+
the card name and the registry looked up. A series is a **hardware fact**, and
|
|
402
|
+
something that can be asked for directly should not depend on an operator
|
|
403
|
+
remembering a setting: a missing one does not cost "one group fewer". The
|
|
404
|
+
capacity view is organised by series, so cards that cannot be classified vanish
|
|
405
|
+
from the total as a batch and the page shows numbers that contradict themselves,
|
|
406
|
+
like "2 used / 0 total".
|
|
407
|
+
|
|
408
|
+
The result is cached: a series does not change over the life of the process,
|
|
409
|
+
while the capacity view is polled by the page.
|
|
410
|
+
An *unresolved* series is not cached, though. The guard used to be
|
|
411
|
+
`if self._series is None`, while the expression ends in `or ""` -- and
|
|
412
|
+
`""` is not None, so a single failed `detect_gpu_name()` pinned the node
|
|
413
|
+
as unclassifiable for the life of the process, producing exactly the
|
|
414
|
+
"used 2 / of 0" contradiction this docstring warns about.
|
|
415
|
+
"""
|
|
416
|
+
if not self._series:
|
|
417
|
+
# Resolved by whoever knows the hardware registry. On a node nobody
|
|
418
|
+
# does -- the registry is the console's, editable in its admin page
|
|
419
|
+
# -- so the node reports the raw names and the console maps them.
|
|
420
|
+
# A node that guessed would be a second answer to a question one
|
|
421
|
+
# place already owns, and the two would drift the first time an
|
|
422
|
+
# administrator added a card.
|
|
423
|
+
profile = getattr(self.settings, "cluster_profile", "") or ""
|
|
424
|
+
resolve = self._series_resolver or _raw_series
|
|
425
|
+
# The configured profile answers first, and answering means the
|
|
426
|
+
# driver is never asked. An operator who declared these cards as a
|
|
427
|
+
# series did so deliberately -- sometimes to put a batch under a
|
|
428
|
+
# different one than the hardware suggests -- and probing anyway
|
|
429
|
+
# would be both slower and, on the machine, a contradiction.
|
|
430
|
+
self._series = resolve(profile, "") or ""
|
|
431
|
+
if not self._series:
|
|
432
|
+
self._series = resolve("", await detect_gpu_name()) or ""
|
|
433
|
+
return self._series
|
|
434
|
+
|
|
435
|
+
async def occupancy(self) -> GpuOccupancy:
|
|
436
|
+
"""Occupancy now. Read afresh every time, never cached -- a stale cache means
|
|
437
|
+
over-subscription."""
|
|
438
|
+
total = await self.total_gpus()
|
|
439
|
+
states = await self.runtime.ps()
|
|
440
|
+
|
|
441
|
+
by_job: dict[int, str] = {}
|
|
442
|
+
for st in states:
|
|
443
|
+
# Only a live container holds cards. An exited one is still around waiting
|
|
444
|
+
# to be reclaimed, but it gave its GPU memory back long ago.
|
|
445
|
+
if st.status not in ("running", "created", "paused"):
|
|
446
|
+
continue
|
|
447
|
+
for idx in st.gpus:
|
|
448
|
+
if 0 <= idx < total:
|
|
449
|
+
by_job[idx] = st.run_id or st.name
|
|
450
|
+
|
|
451
|
+
external: set[int] = set()
|
|
452
|
+
probe_ok = True
|
|
453
|
+
if getattr(self.settings, "local_check_external_gpus", True):
|
|
454
|
+
# Union of the two probes: the process list attributes use to somebody, and
|
|
455
|
+
# memory used is readable from inside a container too. Without the latter
|
|
456
|
+
# the whole check is off whenever the console runs in a container (see
|
|
457
|
+
# _nvidia_smi_memory_busy).
|
|
458
|
+
by_proc, by_mem = await asyncio.gather(
|
|
459
|
+
_nvidia_smi_busy(), _nvidia_smi_memory_busy()
|
|
460
|
+
)
|
|
461
|
+
probe_ok = by_mem is not None
|
|
462
|
+
seen = by_proc | (by_mem or set())
|
|
463
|
+
external = {i for i in seen if i not in by_job and i < total}
|
|
464
|
+
|
|
465
|
+
unhealthy: dict[int, str] = {}
|
|
466
|
+
if getattr(self.settings, "local_check_gpu_health", True):
|
|
467
|
+
unhealthy = {
|
|
468
|
+
i: reason
|
|
469
|
+
for i, reason in (await _nvidia_smi_unhealthy()).items()
|
|
470
|
+
# A card a platform job already holds is not deducted twice (it was
|
|
471
|
+
# never free), but the health problem still shows up in what
|
|
472
|
+
# health() / cluster_gpus report.
|
|
473
|
+
if i not in by_job and i < total
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return GpuOccupancy(
|
|
477
|
+
total=total,
|
|
478
|
+
by_job=by_job,
|
|
479
|
+
external=external,
|
|
480
|
+
unhealthy=unhealthy,
|
|
481
|
+
external_probe_ok=probe_ok,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def _pick(self, run_id: str, count: int, occ: GpuOccupancy) -> list[int]:
|
|
485
|
+
"""Pick cards, with the lock held and occupancy already read. Idempotent: the
|
|
486
|
+
same run_id gets back the cards it already holds."""
|
|
487
|
+
if existing := sorted(i for i, rid in occ.by_job.items() if rid == run_id):
|
|
488
|
+
log.info("run %s already holds cards %s, treating as idempotent", run_id, existing)
|
|
489
|
+
return existing
|
|
490
|
+
|
|
491
|
+
if count > occ.total:
|
|
492
|
+
# This job can never be scheduled, so it must not sit waiting in the queue.
|
|
493
|
+
raise NoCapacity(
|
|
494
|
+
f"the job needs {count} cards and this machine has {occ.total} -- "
|
|
495
|
+
f"no amount of waiting will place it"
|
|
496
|
+
)
|
|
497
|
+
try:
|
|
498
|
+
return pick_gpus(occ.free, count)
|
|
499
|
+
except NoCapacity:
|
|
500
|
+
raise NoCapacity(f"need {count} cards, but {occ.explain()}") from None
|
|
501
|
+
|
|
502
|
+
@contextlib.asynccontextmanager
|
|
503
|
+
async def _exclusive(self):
|
|
504
|
+
"""The allocation critical section: the in-process lock, plus the cross-process
|
|
505
|
+
one when Redis is configured.
|
|
506
|
+
|
|
507
|
+
When Redis is unavailable `try_acquire` returns False and this retries forever
|
|
508
|
+
rather than letting the caller through -- allocation is an operation where
|
|
509
|
+
letting through means over-subscribing, so waiting for Redis to come back beats
|
|
510
|
+
handing out cards while unable to see the other processes. The caller (the
|
|
511
|
+
dequeue loop) already has the shape of retrying every 5s.
|
|
512
|
+
"""
|
|
513
|
+
async with self._lock:
|
|
514
|
+
redis = self.redis
|
|
515
|
+
if redis is None or not getattr(redis, "enabled", False):
|
|
516
|
+
yield
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
token = _lock_token()
|
|
520
|
+
while not await redis.try_acquire(_ALLOC_LOCK_KEY, token, _ALLOC_LEASE_MS):
|
|
521
|
+
await asyncio.sleep(_ALLOC_RETRY_S)
|
|
522
|
+
|
|
523
|
+
async def _renew() -> None:
|
|
524
|
+
while True:
|
|
525
|
+
await asyncio.sleep(_ALLOC_LEASE_MS / 3000.0)
|
|
526
|
+
if not await redis.renew(_ALLOC_LOCK_KEY, token, _ALLOC_LEASE_MS):
|
|
527
|
+
# The lease is no longer ours (Redis restarted, or somebody
|
|
528
|
+
# cleared it by hand). Let this allocation finish, but record
|
|
529
|
+
# it -- this is the one window where a double allocation is
|
|
530
|
+
# possible.
|
|
531
|
+
log.error(
|
|
532
|
+
"lost the GPU allocation lock lease (key=%s); "
|
|
533
|
+
"this allocation has no cross-process protection",
|
|
534
|
+
_ALLOC_LOCK_KEY,
|
|
535
|
+
)
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
renewer = asyncio.create_task(_renew())
|
|
539
|
+
try:
|
|
540
|
+
yield
|
|
541
|
+
finally:
|
|
542
|
+
renewer.cancel()
|
|
543
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
544
|
+
await renewer
|
|
545
|
+
await redis.release(_ALLOC_LOCK_KEY, token)
|
|
546
|
+
|
|
547
|
+
async def allocate(self, run_id: str, count: int) -> list[int]:
|
|
548
|
+
"""Pick cards without starting a container -- for dry runs and other paths that
|
|
549
|
+
take nothing.
|
|
550
|
+
|
|
551
|
+
⚠ A real launch must go through allocate_and_run: this method releases the lock
|
|
552
|
+
as it returns, and the truth about occupancy lives in container labels, so
|
|
553
|
+
between "cards picked" and "container running" another allocation still sees
|
|
554
|
+
them as free.
|
|
555
|
+
"""
|
|
556
|
+
if count <= 0:
|
|
557
|
+
return []
|
|
558
|
+
async with self._exclusive():
|
|
559
|
+
return self._pick(run_id, count, await self.occupancy())
|
|
560
|
+
|
|
561
|
+
async def allocate_and_run(self, run_id: str, count: int, run):
|
|
562
|
+
"""Pick cards and start the container **under the same lock**, atomically;
|
|
563
|
+
returns (gpus, whatever run returned).
|
|
564
|
+
|
|
565
|
+
The lock has to cover starting the container: the occupancy view is rebuilt from
|
|
566
|
+
container labels through `docker ps`, so until the container is actually running
|
|
567
|
+
those cards still look free to everyone else -- and the dequeue loop running
|
|
568
|
+
alongside a direct API submission then gives the same cards to two jobs, which
|
|
569
|
+
is the failure this module exists to prevent. The cost is that container
|
|
570
|
+
creation on this machine is serialised; the local backend is one machine with a
|
|
571
|
+
low submission rate, so that is acceptable.
|
|
572
|
+
|
|
573
|
+
run: async callable(gpus: list[int]) -> T, called exactly once, inside the lock.
|
|
574
|
+
"""
|
|
575
|
+
if count <= 0:
|
|
576
|
+
return [], await run([])
|
|
577
|
+
async with self._exclusive():
|
|
578
|
+
gpus = self._pick(run_id, count, await self.occupancy())
|
|
579
|
+
return gpus, await run(gpus)
|
|
580
|
+
|
|
581
|
+
async def release(self, run_id: str) -> None:
|
|
582
|
+
"""Release explicitly.
|
|
583
|
+
|
|
584
|
+
The normal path **does not need this** -- once a container stops, `ps` no longer
|
|
585
|
+
reports it holding cards and the occupancy view converges on its own. The method
|
|
586
|
+
is here as a fallback for process mode, where the state file has to be cleared
|
|
587
|
+
by hand, and for tests.
|
|
588
|
+
"""
|
|
589
|
+
for st in await self.runtime.ps():
|
|
590
|
+
if st.run_id != run_id:
|
|
591
|
+
continue
|
|
592
|
+
# Reclaim only what has exited. A running container must not be removed --
|
|
593
|
+
# that is killing somebody's training run without telling them.
|
|
594
|
+
if st.status not in ("running", "created", "paused"):
|
|
595
|
+
await self.runtime.remove(st.name)
|
|
596
|
+
return
|