Retire the GPU workers when a run that held a card finishes
A worker that has run a jax node keeps holding the GPU after the run: XLA preallocates most of the VRAM at import and never releases it, so the next process OOMs on preallocation while a warm idle worker sits on the card. Pools are kept warm on purpose — a library reads its environment at import, so a warm worker cannot be re-told — but the end of a run is a point where the memory should go back, and the environments carrying a GPU assignment are exactly the pools that ran on one. Idle ones go now, busy ones when they return. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
This commit is contained in:
@@ -1190,6 +1190,7 @@ class RunService:
|
|||||||
self._finish(run_id, status, reason, result, duration)
|
self._finish(run_id, status, reason, result, duration)
|
||||||
run.status = status
|
run.status = status
|
||||||
self._publish(run, "run_finished")
|
self._publish(run, "run_finished")
|
||||||
|
self._release_cards(run)
|
||||||
# Its values were only ever this run's; nothing reads them once it
|
# Its values were only ever this run's; nothing reads them once it
|
||||||
# has a result. On Redis the namespace would expire anyway.
|
# has a result. On Redis the namespace would expire anyway.
|
||||||
if state is not None and status != "error":
|
if state is not None and status != "error":
|
||||||
@@ -1198,6 +1199,29 @@ class RunService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not clear state of run %s", run_id)
|
logger.warning("Could not clear state of run %s", run_id)
|
||||||
|
|
||||||
|
def _release_cards(self, run: Run) -> None:
|
||||||
|
"""Hand a GPU run's device memory back when the run is over.
|
||||||
|
|
||||||
|
The accountant frees the card the moment the node returns, but the
|
||||||
|
worker that ran on it is kept warm and a library that preallocated
|
||||||
|
most of the VRAM never gives it up — so the next process to want the
|
||||||
|
card found it taken by one sitting idle.
|
||||||
|
"""
|
||||||
|
# ponytail: retires every CUDA pool rather than the ones this run used,
|
||||||
|
# which needs no bookkeeping — a concurrent GPU run's busy worker only
|
||||||
|
# dies when it returns, which is when its own memory should go back
|
||||||
|
# anyway. What it costs is the warm worker of a *live* flow's GPU node.
|
||||||
|
# Track the pools per run if that ever matters.
|
||||||
|
if not (run.needs or {}).get("gpus"):
|
||||||
|
return
|
||||||
|
pool = getattr(self.controller, "workers", None)
|
||||||
|
if pool is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pool.retire_gpu_children()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not retire the GPU workers of run %s", run.id)
|
||||||
|
|
||||||
def _record_node(self, run_id: str, outcome: NodeOutcome) -> None:
|
def _record_node(self, run_id: str, outcome: NodeOutcome) -> None:
|
||||||
outputs = _cacheable(outcome)
|
outputs = _cacheable(outcome)
|
||||||
row = RunNode(
|
row = RunNode(
|
||||||
|
|||||||
@@ -321,6 +321,24 @@ class PythonWorkerPool:
|
|||||||
for child in children:
|
for child in children:
|
||||||
child.respawn_all()
|
child.respawn_all()
|
||||||
|
|
||||||
|
def retire_gpu_children(self) -> None:
|
||||||
|
"""Retire the pools holding a card, so the VRAM goes back.
|
||||||
|
|
||||||
|
A library like JAX takes most of the device when it imports and never
|
||||||
|
releases it, so a warm worker that has run one such node is a held
|
||||||
|
card — and warm is the point of a pool, so nothing retires it. At the
|
||||||
|
end of a run there is something to key on: the environments carrying a
|
||||||
|
GPU assignment are exactly the pools that ran on one.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
children = [
|
||||||
|
child
|
||||||
|
for key, child in self._children.items()
|
||||||
|
if any(name == "CUDA_VISIBLE_DEVICES" for name, _ in key)
|
||||||
|
]
|
||||||
|
for child in children:
|
||||||
|
child.respawn_all()
|
||||||
|
|
||||||
def _drain(self) -> list[_Worker | None]:
|
def _drain(self) -> list[_Worker | None]:
|
||||||
slots = []
|
slots = []
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -685,6 +685,19 @@ def test_retiring_workers_reaches_the_children(pool):
|
|||||||
assert child._generation > before
|
assert child._generation > before
|
||||||
|
|
||||||
|
|
||||||
|
def test_retiring_the_cards_leaves_the_other_pools_warm(pool):
|
||||||
|
"""A library that preallocated the card only gives it back by dying."""
|
||||||
|
card = pool.for_env({"CUDA_VISIBLE_DEVICES": "0"})
|
||||||
|
threads = pool.for_env({"OMP_NUM_THREADS": "2"})
|
||||||
|
before = (card._generation, threads._generation)
|
||||||
|
|
||||||
|
pool.retire_gpu_children()
|
||||||
|
|
||||||
|
assert card._generation > before[0]
|
||||||
|
# Nothing to hand back, so nothing pays a cold start for it.
|
||||||
|
assert threads._generation == before[1]
|
||||||
|
|
||||||
|
|
||||||
def test_cancelling_reaches_a_node_running_in_a_child(pool):
|
def test_cancelling_reaches_a_node_running_in_a_child(pool):
|
||||||
child = pool.for_env({"OMP_NUM_THREADS": "2"})
|
child = pool.for_env({"OMP_NUM_THREADS": "2"})
|
||||||
started = threading.Event()
|
started = threading.Event()
|
||||||
|
|||||||
Reference in New Issue
Block a user