nvidia-smiで「回収候補GPU」をPython検出する
GPU Utilization 0% のGPUを、そのまま空きと扱う監視は危ない。推論プロセスが待機しているだけなら利用率は0%になるし、VRAMが空でもPIDが残ることがある。回収してよい候補は、利用率、VRAM使用量、compute process の三つを同時に見るのが安全だ。
7月30日にHugging Faceが公開した GPU Management: Why Idle GPUs Are the New Grounded Aircraft を読んで、手元の確認手順を小さなスクリプトにした。GPUを自動でリセットするものではない。運用担当が「誰にも使われていないので、ジョブを載せられそう」と確認するための候補一覧である。
今朝、模擬した3枚のGPUで動かすと、利用率もVRAMも0なのにPIDが残るGPUが一枚あった。利用率だけを見ていたら、そのGPUも空きとして通知していたはず。こういう地味な誤検知が一番困る。
Q. なぜ nvidia-smi の利用率だけでは足りない?
利用率は瞬間値だ。バッチの合間、モデルをロードしたまま入力を待つAPI、CUDAコンテキストだけを残したプロセスは0%を返しうる。一方でVRAMだけを見る方法も不十分で、CPU側の待機や小さなVRAM予約を見落とす。
そこで、次の条件をすべて満たすGPUだけを回収候補にする。
| 観測値 | 条件 | 意図 |
|---|---|---|
utilization.gpu |
0 | 実行中の計算が見えない |
memory.used |
0 MiB | VRAMに残ったモデルやキャッシュがない |
--query-compute-apps |
該当UUIDがない | compute processが掴んでいない |
判定の入口は、GPU単位のCSVとプロセス単位のCSVをUUIDで結合するところにある。
Q. 実装はどうする?
標準ライブラリだけで書いた。--fixture は記事中の検証用で、本番では外して実行する。GPUがないMacやCIでも判定ロジックを確認できるようにしてある。
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import csv
import subprocess
from dataclasses import dataclass
GPU_QUERY = [
"nvidia-smi",
"--query-gpu=index,uuid,utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits",
]
APP_QUERY = [
"nvidia-smi",
"--query-compute-apps=gpu_uuid,pid,process_name,used_memory",
"--format=csv,noheader,nounits",
]
FIXTURE_GPUS = """0, GPU-aaaa, 0, 0, 81920
1, GPU-bbbb, 18, 4096, 81920
2, GPU-cccc, 0, 0, 81920
"""
FIXTURE_APPS = """GPU-bbbb, 4321, python, 4096
GPU-cccc, 9876, python, 0
"""
@dataclass(frozen=True)
class Gpu:
index: str
uuid: str
utilization: int
memory_used: int
memory_total: int
def run(command: list[str]) -> str:
result = subprocess.run(command, text=True, capture_output=True, check=True)
return result.stdout
def rows(text: str) -> list[list[str]]:
return [[cell.strip() for cell in row] for row in csv.reader(text.splitlines()) if row]
def parse_gpus(text: str) -> list[Gpu]:
return [
Gpu(index, uuid, int(utilization), int(memory_used), int(memory_total))
for index, uuid, utilization, memory_used, memory_total in rows(text)
]
def active_gpu_uuids(text: str) -> set[str]:
return {row[0] for row in rows(text)}
def reclaim_candidates(gpus: list[Gpu], active_uuids: set[str]) -> list[Gpu]:
return [
gpu
for gpu in gpus
if gpu.utilization == 0
and gpu.memory_used == 0
and gpu.uuid not in active_uuids
]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--fixture", action="store_true")
args = parser.parse_args()
gpu_text = FIXTURE_GPUS if args.fixture else run(GPU_QUERY)
app_text = FIXTURE_APPS if args.fixture else run(APP_QUERY)
candidates = reclaim_candidates(parse_gpus(gpu_text), active_gpu_uuids(app_text))
if not candidates:
print("回収候補はありません")
return
for gpu in candidates:
print(f"GPU {gpu.index}: {gpu.uuid} (利用率 0%, VRAM 0/{gpu.memory_total} MiB)")
if __name__ == "__main__":
main()
GPU-cccc は利用率0%、VRAM 0 MiBでも、FIXTURE_APPS にPID 9876がある。候補から外れるのはこのUUID照合のためだ。プロセスが何もない状態では nvidia-smi --query-compute-apps が No running compute processes found と出る環境もある。この文字列はUUIDにならないので、全GPUがプロセスなしとして扱われる。
Q. 検証結果は?
記事のコードを gpu_idle_check.py として保存し、Python 3.9.6で次を実行した。
python3 gpu_idle_check.py --fixture
出力はこれだけだった。
GPU 0: GPU-aaaa (利用率 0%, VRAM 0/81920 MiB)
GPU 1 は18%・4096 MiBなので当然残る。GPU 2 は前述のPIDがあるため残る。3枚中1枚だけを候補にできた。nvidia-smi のCSVは空白を含むので、csv.reader の後に strip() している。ここを省くとUUIDの比較が静かに外れて、実行中GPUまで候補に混ざる。
--fixture で確かめたのは、CSVの分解、UUID照合、三条件のAND判定だ。ドライバやコンテナから見えるPIDの範囲までは検証していない。実機へ入れる前に、そのノードで nvidia-smi を一度単独で叩き、CSVが5列と4列で出ることを見ておく。ここは環境差をコードで吸収したつもりにならないほうがいい。
Q. PIDが見えれば、コンテナ内のジョブまで追える?
必ずしも追えない。--query-compute-apps に出るPIDはホスト側のPIDで、KubernetesのPod名やDockerのコンテナIDではない。通知にPIDだけを載せると、調査する人は結局 ps やランタイムの情報を横断することになる。
最初はそこまで自動化しなくてよい。候補から除外されたGPUが多いなら、UUIDとPIDを一緒にログへ残して、あとから crictl ps やジョブスケジューラの割り当てと突き合わせる。逆に、候補に出たGPUだけを対象にするなら、このスクリプトの出力はGPU indexとUUIDで足りる。調査のための情報と、回収判断のための情報を混ぜないほうが運用が落ち着く。
同じ理由で、プロセス名を条件に入れていない。python、python3、推論サーバの実行ファイル名を列挙し始めると、名前を変えたジョブだけを見逃す。GPU UUIDがprocess一覧にあるかだけを判定に使えば、実装言語やフレームワークに依存しない。
実機では次のように使う。
python3 gpu_idle_check.py
出力をSlackへ送る、Kubernetesのノードにラベルを付ける、といった次の処理は別にしておくのがいい。回収候補と即時回収を一つのスクリプトでつなぐと、短いアイドル時間でジョブを落としかねない。自分は5分おきに記録し、連続3回候補になったUUIDだけを人が確認する運用にしている。
Q. 5分を3回見る理由は?
1回だけの判定なら、APIサーバのリクエスト間隔やデータローダーの待ち時間まで空きに見える。5分間隔で3回なら、少なくとも10分をまたいで同じ状態だったGPUだけを拾える。ここで必要なのは厳密な「アイドル時間」ではなく、次のジョブを載せる前に確認する一覧だ。
監視基盤に時系列DBがあるなら、スクリプトを常駐させず、DCGM_FI_DEV_GPU_UTIL とVRAMのメトリクスで同じ窓を作ってもいい。その場合もprocess一覧との照合は別途必要になる。メトリクスはGPUが静かなことを示し、process一覧は誰かがGPUを確保していることを示す。役割が違う。
回収候補が0枚の日が続いても、閾値をすぐ緩めない。0%のGPUを増やすために「VRAM 1 GiB未満」へ変えると、モデルをロード済みで一時停止しているサーバまで候補に寄る。空き容量を増やす施策は、利用者の明示的なリリースやスケジューラの予約期限と組み合わせるのが本筋だと思っている。
Q. あとで誤通知を調べるには何を残す?
通知本文だけを残すと、なぜ候補になったかが消える。少なくとも観測時刻、GPU UUID、利用率、使用VRAM、process一覧に同じUUIDがあったかを一行で残す。候補でなかったGPUも、集計値だけは残しておくとよい。翌日に「GPU 2が候補にならなかったのはバグか、それともPIDがあったのか」を判別できる。
このスクリプトはCSVの列数が想定と違うと例外で止まる。無理に0として扱わないためだ。ドライバ更新やラッパーの変更で出力形式が変わったとき、空きGPUを大量に通知するより、監視が壊れたと分かったほうがましだった。通知の監視では、この終了失敗自体も拾うようにしている。
運用ログを見始めると、候補判定とは別の偏りも見える。特定のGPUだけが毎回空くなら、GPUの故障を疑う前に、スケジューラのresource requestやAffinityを確認する。逆に全GPUが同時に候補になる時間があるなら、夜間バッチの終了時刻に合わせて次のジョブを詰められる。回収候補の一覧は、空きの検出だけでなく配置の癖を読む材料になる。
Q. このまま自動回収してよい?
よくない。これは「GPUに何も載っていない」ことを、通常のcompute processに限って見る検査だ。MIG構成、コンテナランタイムの見え方、graphics process、GPU外に状態を持つ分散ジョブは別に確認がいる。とくに共有ノードでの nvidia-smi --gpu-reset を、この結果だけで実行してはいけない。
判定を厳しくすると、回収できるGPUは少なく見える。それでよい。空きGPUの通知は速さより誤通知の少なさが大事で、1枚を取り逃すより、動いている推論を止めるほうが高くつく。
おわりに
GPU運用で見るべきなのは利用率の低さではなく、実際に誰が掴んでいるかだった。nvidia-smi のGPU一覧とprocess一覧をUUIDで突き合わせれば、0%という一瞬の値を通知根拠にしなくて済む。
まずは読み取り専用の候補出しとして入れ、実機の出力を数日残す。候補が本当に使われていなかったかを確かめてから、通知先や自動化を足す。この順番なら、GPUを寝かせたままにする損と、回収を急ぎすぎる事故の両方を小さくできる。