A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 6, 13:15 UTC
[CI] Fix GPU memory leak when RemoteOpenAIServer fails to start in __init__ (#37230)
Signed-off-by: Andreas Karatzas <akaratza@amd.com>
Andreas Karatzas committed
Mar 17, 2026 at 11:08 UTC
4ed51308c8826619459be858a6dc4333206f41c1
1 file changed
+123
-52
tests/utils.py
+123
-52
@@ -225,13 +225,31 @@ class RemoteVLLMServer:
225
)
226
227
self._start_server(model, vllm_serve_args, env_dict)
228
- max_wait_seconds = max_wait_seconds or 360
229
- self._wait_for_server(url=self.url_for("health"), timeout=max_wait_seconds)
228
+ max_wait_seconds = max_wait_seconds or 480
229
+ try:
230
+ self._wait_for_server(url=self.url_for("health"), timeout=max_wait_seconds)
231
+ except Exception:
232
+ # If the server never became healthy, we must still clean up
233
+ # the subprocess tree. Without this, a timeout in __init__
234
+ # leaks the server + EngineCore processes (and their GPU
235
+ # memory), because __exit__ is never called when __init__
236
+ # raises inside a ``with`` statement.
237
+ self._shutdown()
238
+ raise
239
240
def __enter__(self):
241
return self
242
243
def __exit__(self, exc_type, exc_value, traceback):
244
+ self._shutdown()
245
+
246
+ def _shutdown(self) -> None:
247
+ """Kill the server process tree and wait for GPU memory release.
248
+
249
+ Called from both ``__exit__`` (normal path) and ``__init__``
250
+ (when the server fails to start). Must be safe to call even if
251
+ the process is already dead.
252
+ """
253
pid = self.proc.pid
254
255
# Get the process group ID. Because we used
@@ -265,33 +283,92 @@ class RemoteVLLMServer:
283
self.proc.wait(timeout=10)
284
print(f"[RemoteOpenAIServer] Server {pid} killed")
285
except subprocess.TimeoutExpired:
268
- # Phase 3: last resort - find and kill any orphaned children
269
- self._kill_orphaned_children(pid)
286
+ pass
287
271
- # Wait for GPU memory to actually be *freed*, not just
288
+ # After killing the root process, ensure all children in the
289
+ # process group (e.g. EngineCore workers) are also dead.
290
+ # On ROCm especially, surviving children hold GPU contexts and
291
+ # prevent VRAM from being reclaimed by the driver.
292
+ self._kill_process_group_survivors(pgid)
293
+
294
+ # Wait for GPU memory to actually be freed, not just
295
# "stabilized at whatever level it's at".
296
self._wait_for_gpu_memory_release()
297
275
- def _kill_orphaned_children(self, parent_pid: int) -> None:
276
- """Best-effort cleanup of any lingering child processes."""
277
- try:
278
- import psutil
298
+ def _kill_process_group_survivors(
299
+ self, pgid: int | None, timeout: float = 15.0
300
+ ) -> None:
301
+ """SIGKILL any processes still in the server's process group
302
+ and wait for them to exit.
303
280
- parent = psutil.Process(parent_pid)
281
- children = parent.children(recursive=True)
282
- for child in children:
283
- print(
284
- f"[RemoteOpenAIServer] Killing orphaned child "
285
- f"pid={child.pid} name={child.name()}"
286
- )
287
- child.kill()
288
- psutil.wait_procs(children, timeout=5)
289
- except Exception as e:
290
- # psutil may not be installed, or processes already gone
291
- print(f"[RemoteOpenAIServer] Orphan cleanup failed: {e}")
292
- # Fallback: try to kill by pgid one more time
293
- with contextlib.suppress(ProcessLookupError, OSError):
294
- os.killpg(parent_pid, signal.SIGKILL)
304
+ Because the server is launched with ``start_new_session=True``,
305
+ all its children (EngineCore, workers, etc.) share the same
306
+ pgid. After the root process is killed, stragglers -- especially
307
+ on ROCm where GPU contexts linger until the *process* exits --
308
+ must be reaped explicitly.
309
+
310
+ Uses ``/proc`` to scan for pgid members so this works even after
311
+ the parent has been reaped (unlike ``psutil.Process.children``).
312
+ """
313
+ if pgid is None:
314
+ return
315
+
316
+ # Send SIGKILL to the entire process group one more time.
317
+ # This is cheap and harmless if everyone is already dead.
318
+ with contextlib.suppress(ProcessLookupError, OSError):
319
+ os.killpg(pgid, signal.SIGKILL)
320
+
321
+ # Collect surviving PIDs by scanning /proc for matching pgid.
322
+ # This works on Linux even after the parent has been waited on
323
+ # and is more reliable than psutil.Process(parent).children().
324
+ survivor_pids = self._find_pgid_members(pgid)
325
+
326
+ if not survivor_pids:
327
+ return
328
+
329
+ print(
330
+ f"[RemoteOpenAIServer] {len(survivor_pids)} process(es) still "
331
+ f"in pgid {pgid} after SIGKILL: {survivor_pids}"
332
+ )
333
+
334
+ # Wait for each survivor to actually exit so the GPU driver
335
+ # releases its VRAM.
336
+ deadline = time.time() + timeout
337
+ while survivor_pids and time.time() < deadline:
338
+ still_alive = []
339
+ for spid in survivor_pids:
340
+ try:
341
+ os.kill(spid, 0) # Check if still alive
342
+ still_alive.append(spid)
343
+ except (ProcessLookupError, OSError):
344
+ pass
345
+ survivor_pids = still_alive
346
+ if survivor_pids:
347
+ time.sleep(0.5)
348
+
349
+ if survivor_pids:
350
+ print(
351
+ f"[RemoteOpenAIServer] WARNING: processes {survivor_pids} "
352
+ f"in pgid {pgid} could not be killed within {timeout}s"
353
+ )
354
+
355
+ @staticmethod
356
+ def _find_pgid_members(pgid: int) -> list[int]:
357
+ """Return PIDs of all living processes whose pgid matches."""
358
+ members: list[int] = []
359
+ proc_path = Path("/proc")
360
+ if not proc_path.is_dir():
361
+ return members
362
+ for entry in proc_path.iterdir():
363
+ if not entry.name.isdigit():
364
+ continue
365
+ pid = int(entry.name)
366
+ try:
367
+ if os.getpgid(pid) == pgid:
368
+ members.append(pid)
369
+ except OSError:
370
+ continue
371
+ return members
372
373
def _get_gpu_memory_used(self) -> float | None:
374
"""Get total GPU memory used across all visible devices in bytes."""
@@ -318,13 +395,16 @@ class RemoteVLLMServer:
395
return None
396
return None
397
321
- def _wait_for_gpu_memory_release(self, timeout: float = 60.0):
398
+ def _wait_for_gpu_memory_release(
399
+ self, timeout: float = 120.0, log_interval: float = 10.0
400
+ ):
401
"""Wait for GPU memory to drop back toward pre-server levels.
402
324
- Two-phase strategy:
325
- 1. Try to wait for memory to return close to pre-server baseline.
326
- 2. If that doesn't happen, fall back to waiting for stabilization
327
- and log a warning (the next server might still OOM).
403
+ Waits the full timeout for memory to return close to the
404
+ pre-server baseline. Does NOT fall back to a "stabilization"
405
+ heuristic -- if memory is still held when the timeout expires,
406
+ the test fails so the problem is surfaced immediately rather
407
+ than causing cascading OOM failures in every subsequent test.
408
"""
409
baseline = self._pre_server_gpu_memory
410
if baseline is None:
@@ -337,8 +417,7 @@ class RemoteVLLMServer:
417
target = baseline + headroom_bytes
418
419
start = time.time()
340
- last_used: float | None = None
341
- stable_count = 0
420
+ next_log_time = start + log_interval
421
422
while time.time() - start < timeout:
423
used = self._get_gpu_memory_used()
@@ -350,7 +429,6 @@ class RemoteVLLMServer:
429
target_gb = target / 1e9
430
elapsed = time.time() - start
431
353
- # Phase 1: memory dropped to near baseline - we're done.
432
if used <= target:
433
print(
434
f"[RemoteOpenAIServer] GPU memory released to "
@@ -359,28 +437,19 @@ class RemoteVLLMServer:
437
)
438
return
439
362
- # Phase 2 (after 40s): fall back to stabilization check.
363
- # This handles cases where another process is using GPU memory
364
- # and we'll never reach baseline.
365
- if elapsed > 40.0 and last_used is not None:
366
- delta = abs(used - last_used)
367
- if delta < 200 * 1024 * 1024: # 200 MB
368
- stable_count += 1
369
- if stable_count >= 3:
370
- print(
371
- f"[RemoteOpenAIServer] WARNING: GPU memory "
372
- f"stabilized at {used_gb:.2f} GB "
373
- f"(target was {target_gb:.2f} GB). "
374
- f"Proceeding - next server may OOM."
375
- )
376
- return
377
- else:
378
- stable_count = 0
440
+ now = time.time()
441
+ if now >= next_log_time:
442
+ print(
443
+ f"[RemoteOpenAIServer] Waiting for GPU memory release: "
444
+ f"{used_gb:.2f} GB (target: {target_gb:.2f} GB) "
445
+ f"[{elapsed:.0f}s/{timeout:.0f}s]"
446
+ )
447
+ next_log_time = now + log_interval
448
380
- last_used = used
449
time.sleep(1.0)
450
383
- # Timeout - log clearly so CI failures are diagnosable
451
+ # Timeout -- raise so the current test fails with a clear
452
+ # message instead of silently poisoning subsequent tests.
453
final_used = self._get_gpu_memory_used()
454
final_gb = final_used / 1e9 if final_used else 0.0
455
raise RuntimeError(
@@ -534,7 +603,9 @@ class RemoteLaunchRenderServer(RemoteVLLMServer):
603
revision=model_config.tokenizer_revision,
604
)
605
537
- def _wait_for_gpu_memory_release(self, timeout: float = 30.0):
606
+ def _wait_for_gpu_memory_release(
607
+ self, timeout: float = 30.0, log_interval: float = 10.0
608
+ ):
609
pass # No GPU used
610
611