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.
@@ -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