spark-memory.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. #!/usr/bin/env python3
  2. """Account for every byte of host memory on a GB10 node.
  3. The GPU shares the host's memory, and its allocations do not appear in process
  4. RSS: on one node the engine holds about 100 GiB that `ps` reports as 6 GiB
  5. across every process on the box. `nvidia-smi --query-gpu=memory.used` answers
  6. N/A here, but `--query-compute-apps` still gives a per-pid figure, which is
  7. what makes the total reconcile.
  8. Prints one table that sums to MemTotal, with the residual named rather than
  9. hidden, so a number that moves after a config change has somewhere to land.
  10. """
  11. import os
  12. import re
  13. import subprocess
  14. import sys
  15. from collections import defaultdict
  16. GIB = 1024 ** 3
  17. def meminfo() -> dict[str, int]:
  18. out = {}
  19. with open("/proc/meminfo") as f:
  20. for line in f:
  21. k, _, v = line.partition(":")
  22. out[k] = int(v.split()[0]) * 1024
  23. return out
  24. def gpu_by_pid() -> dict[int, int]:
  25. """Bytes of GPU memory per pid, empty when the driver declines to say."""
  26. try:
  27. r = subprocess.run(
  28. ["nvidia-smi", "--query-compute-apps=pid,used_memory",
  29. "--format=csv,noheader,nounits"],
  30. capture_output=True, text=True, timeout=15)
  31. except (OSError, subprocess.SubprocessError):
  32. return {}
  33. out = {}
  34. for line in r.stdout.splitlines():
  35. parts = [p.strip() for p in line.split(",")]
  36. if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
  37. out[int(parts[0])] = int(parts[1]) * 1024 * 1024
  38. return out
  39. def classify(pid: int, cmd: str) -> str:
  40. try:
  41. with open(f"/proc/{pid}/cgroup") as f:
  42. cg = f.read()
  43. except OSError:
  44. cg = ""
  45. in_docker = "docker" in cg or "containerd" in cg
  46. if "EngineCore" in cmd or re.search(r"\bvllm\b", cmd):
  47. return "vllm engine"
  48. if "RayWorkerProc" in cmd or "ray::" in cmd:
  49. return "ray workers"
  50. if "raylet" in cmd or "gcs_server" in cmd or "/ray/" in cmd:
  51. return "ray system"
  52. if "status-server.py" in cmd:
  53. return "status server"
  54. if in_docker:
  55. return "other container processes"
  56. if "dockerd" in cmd or "containerd" in cmd:
  57. return "docker daemons"
  58. if pid == 1 or "/systemd" in cmd or "systemd-" in cmd:
  59. return "systemd and units"
  60. if "sshd" in cmd:
  61. return "ssh sessions"
  62. return "other host processes"
  63. def processes() -> tuple[dict[str, int], dict[str, int], dict[str, list]]:
  64. """(rss by role, gpu by role, notable pids by role)."""
  65. gpu = gpu_by_pid()
  66. rss_by, gpu_by = defaultdict(int), defaultdict(int)
  67. top = defaultdict(list)
  68. for entry in os.listdir("/proc"):
  69. if not entry.isdigit():
  70. continue
  71. pid = int(entry)
  72. try:
  73. with open(f"/proc/{pid}/cmdline", "rb") as f:
  74. cmd = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
  75. with open(f"/proc/{pid}/statm") as f:
  76. rss = int(f.read().split()[1]) * os.sysconf("SC_PAGE_SIZE")
  77. except (OSError, ValueError, IndexError):
  78. continue
  79. role = classify(pid, cmd)
  80. rss_by[role] += rss
  81. g = gpu.get(pid, 0)
  82. gpu_by[role] += g
  83. if g or rss > 256 * 1024 * 1024:
  84. top[role].append((g or rss, pid, cmd.strip()[:60], bool(g)))
  85. return rss_by, gpu_by, top
  86. def main() -> int:
  87. m = meminfo()
  88. total = m["MemTotal"]
  89. rss_by, gpu_by, top = processes()
  90. gpu_total = sum(gpu_by.values())
  91. # Process RSS on this box excludes the GPU allocation, so the two add
  92. # rather than overlap. Shmem is already inside Cached.
  93. rss_total = sum(rss_by.values())
  94. cache = m["Cached"] + m["Buffers"] - m["Shmem"]
  95. kernel = (m["SUnreclaim"] + m["KernelStack"] + m["PageTables"]
  96. + m.get("VmallocUsed", 0) + m.get("Percpu", 0))
  97. reclaimable = m["SReclaimable"]
  98. free = m["MemFree"]
  99. rows = [("GPU allocations", gpu_total), ("process memory", rss_total),
  100. ("page cache", cache), ("tmpfs and shared", m["Shmem"]),
  101. ("kernel", kernel), ("kernel, reclaimable", reclaimable),
  102. ("free", free)]
  103. named = sum(v for _, v in rows)
  104. rows.append(("unaccounted", total - named))
  105. print(f"{'MemTotal':<28}{total / GIB:>9.2f} GiB\n")
  106. for label, v in rows:
  107. print(f" {label:<26}{v / GIB:>9.2f} GiB{v / total:>8.1%}")
  108. print(f"\n{'GPU allocations by role':<28}")
  109. for role, v in sorted(gpu_by.items(), key=lambda kv: -kv[1]):
  110. if v:
  111. print(f" {role:<26}{v / GIB:>9.2f} GiB")
  112. print(f"\n{'process memory by role':<28}")
  113. for role, v in sorted(rss_by.items(), key=lambda kv: -kv[1]):
  114. if v > 32 * 1024 * 1024:
  115. print(f" {role:<26}{v / GIB:>9.2f} GiB")
  116. if "-v" in sys.argv:
  117. print("\nlargest single processes")
  118. flat = [x for lst in top.values() for x in lst]
  119. for size, pid, cmd, is_gpu in sorted(flat, reverse=True)[:12]:
  120. kind = "GPU" if is_gpu else "RSS"
  121. print(f" {size / GIB:>7.2f} GiB {kind} pid {pid:<8} {cmd}")
  122. if m.get("SwapTotal"):
  123. used = m["SwapTotal"] - m["SwapFree"]
  124. print(f"\nswap {used / GIB:.2f} of {m['SwapTotal'] / GIB:.2f} GiB used, "
  125. f"{m.get('SwapCached', 0) / GIB:.2f} GiB cached")
  126. return 0
  127. if __name__ == "__main__":
  128. raise SystemExit(main())