spark_mem_trace.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. """Bound a vLLM worker's device memory, and name whatever crosses the bound.
  2. vLLM never calls set_per_process_memory_fraction, so nothing bounds a worker.
  3. gpu_memory_utilization only sizes the KV pool; every allocation outside it --
  4. the sparse indexer scores chunk x context/kpool, and grows with the session --
  5. comes from host memory, because on GB10 there is no separate VRAM to exhaust
  6. first. The node stops forking and the watchdog resets it, and nothing in any
  7. log says which allocation did it.
  8. A fraction turns that into torch.OutOfMemoryError, so one request fails and the
  9. node keeps serving. Arming the trace as well turns the same moment into a stack
  10. naming the call site, since the observer runs in the allocating thread at the
  11. point of failure.
  12. To find a culprit rather than survive it, set the fraction BELOW the level that
  13. hurts: the first allocation past it is reported and the node stays up.
  14. Env, all empty by default so an unset deployment behaves like upstream:
  15. TORCH_MEM_FRACTION ceiling as a fraction of device memory, e.g. 0.90
  16. TORCH_MEM_TRACE_DIR directory for the snapshot; also arms the recorder
  17. TORCH_MEM_TRACE_ENTRIES allocation records kept (default 100000)
  18. TORCH_MEM_STATS_DIR directory for the per-worker counter file
  19. TORCH_MEM_STATS_INTERVAL seconds between samples (default 15)
  20. """
  21. from __future__ import annotations
  22. import json
  23. import os
  24. import threading
  25. import time
  26. import traceback
  27. import torch
  28. from vllm.logger import init_logger
  29. logger = init_logger(f"vllm.{__name__}")
  30. _GiB = 1 << 30
  31. _reported = threading.Event()
  32. def _observer(trace_dir: str):
  33. def on_oom(*args) -> None:
  34. # (device, alloc, device_allocated, device_free) upstream, taken
  35. # positionally so a signature change degrades to a bare report.
  36. size = args[1] if len(args) > 1 else -1
  37. free = args[3] if len(args) > 3 else -1
  38. logger.error(
  39. "spark: allocation of %.2f GiB crossed the cap (%.2f GiB free)",
  40. size / _GiB,
  41. free / _GiB,
  42. )
  43. logger.error(
  44. "spark: allocating stack:\n%s",
  45. "".join(traceback.format_stack()[:-1]),
  46. )
  47. if _reported.is_set():
  48. return
  49. _reported.set()
  50. path = os.path.join(trace_dir, f"mem-snapshot-pid{os.getpid()}.pickle")
  51. try:
  52. torch.cuda.memory._dump_snapshot(path)
  53. logger.error("spark: wrote %s (open at pytorch.org/memory_viz)", path)
  54. except Exception as exc:
  55. logger.error("spark: snapshot failed: %s", exc)
  56. return on_oom
  57. def arm(device) -> None:
  58. """Apply the cap and trace this worker asks for. Safe to call once per worker."""
  59. frac = os.environ.get("TORCH_MEM_FRACTION", "").strip()
  60. if frac:
  61. torch.cuda.set_per_process_memory_fraction(float(frac), device)
  62. logger.info("spark: worker capped at %s of device memory", frac)
  63. _start_reporter()
  64. trace_dir = os.environ.get("TORCH_MEM_TRACE_DIR", "").strip()
  65. if not trace_dir:
  66. return
  67. os.makedirs(trace_dir, exist_ok=True)
  68. entries = int(os.environ.get("TORCH_MEM_TRACE_ENTRIES", "").strip() or "100000")
  69. # Recording keeps a stack per allocation, so it costs both time and memory;
  70. # entries bounds the second.
  71. torch.cuda.memory._record_memory_history(max_entries=entries)
  72. torch._C._cuda_attach_out_of_memory_observer(_observer(trace_dir))
  73. logger.info("spark: memory trace armed, %d entries, snapshots to %s", entries, trace_dir)
  74. def _report(path: str, interval: float) -> None:
  75. while True:
  76. try:
  77. free, total = torch.cuda.mem_get_info()
  78. tmp = f"{path}.tmp"
  79. with open(tmp, "w") as fh:
  80. json.dump(
  81. {
  82. "pid": os.getpid(),
  83. "ts": time.time(),
  84. "allocated": torch.cuda.memory_allocated(),
  85. "reserved": torch.cuda.memory_reserved(),
  86. "max_reserved": torch.cuda.max_memory_reserved(),
  87. "device_free": free,
  88. "device_total": total,
  89. },
  90. fh,
  91. )
  92. os.replace(tmp, path)
  93. except Exception:
  94. pass
  95. time.sleep(interval)
  96. def _start_reporter() -> None:
  97. """Publish this worker's allocator counters for another process to read.
  98. The counters are per process and every reader is another one, so a worker
  99. that does not write them cannot be asked. What the pair of numbers answers
  100. is which side is growing: reserved is what torch holds, and the host's
  101. MemAvailable is what is left, so the gap between them is everything else --
  102. NCCL, cuBLAS, the JIT caches. Guessing at that split cost most of a day on
  103. 2026-08-29.
  104. """
  105. stats_dir = os.environ.get("TORCH_MEM_STATS_DIR", "").strip()
  106. if not stats_dir:
  107. return
  108. os.makedirs(stats_dir, exist_ok=True)
  109. # Compose passes an unset variable through as empty, so a default that
  110. # only covers a missing key still reaches float() with "".
  111. interval = float(os.environ.get("TORCH_MEM_STATS_INTERVAL", "").strip() or "15")
  112. path = os.path.join(stats_dir, f"mem-stats-pid{os.getpid()}.json")
  113. threading.Thread(target=_report, args=(path, interval), daemon=True).start()
  114. logger.info("spark: publishing allocator counters to %s every %.0fs", path, interval)