Перейти к содержимому

Утилизация GPU

GPU — самая дорогая строчка в счёте у соло-разработчика с локальными моделями. Две беды выглядят одинаково молча:

  • простой при оплате — инстанс поднят, модель выгружена, но $X/час всё равно капает (забыли выключить после эксперимента);
  • устойчивая перегрузка — карта воткнута в 100% часами, очередь инференса растёт, а вы об этом узнаёте от пользователей.

Стандартные дашборды это показывают, только если на них смотреть. Push приходит сам.

nvidia-smi умеет отдавать метрики в CSV — парсим и решаем, что это: простой или перегрузка. State-файл предотвращает повторный алёрт.

import os, time, subprocess, requests
NOTIFLY_URL = os.environ["NOTIFLY_URL"]
NOTIFLY_TOKEN = os.environ["NOTIFLY_TOKEN"]
IDLE_UTIL = 5 # % загрузки, ниже которого считаем карту простаивающей
BUSY_UTIL = 95 # % загрузки, выше которого — перегрузка
WINDOW = 10 # сколько подряд замеров подтверждают состояние
COST_PER_HR = 1.20 # $/час за инстанс — чтобы посчитать «сожжённые» деньги
def notify(title, msg, prio):
requests.post(f"{NOTIFLY_URL}/message",
params={"token": NOTIFLY_TOKEN},
json={"title": title, "message": msg, "priority": prio},
timeout=5)
def sample():
# utilization.gpu (%), memory.used (MiB), power.draw (W) для каждой карты
out = subprocess.check_output([
"nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,power.draw",
"--format=csv,noheader,nounits",
], text=True)
rows = []
for line in out.strip().splitlines():
util, mem, power = (x.strip() for x in line.split(","))
rows.append((float(util), float(mem), float(power)))
return rows
STATE = "/tmp/gpu-util.txt"
def push_window(value):
arr = []
if os.path.exists(STATE):
arr = [float(x) for x in open(STATE).read().split() if x]
arr.append(value)
arr = arr[-WINDOW:]
open(STATE, "w").write(" ".join(map(str, arr)))
return arr
def flag(name):
return f"/tmp/gpu-{name}.flag"
def alert_once(name, title, msg, prio):
if not os.path.exists(flag(name)):
notify(title, msg, prio)
open(flag(name), "w").close()
def clear(name):
if os.path.exists(flag(name)):
os.remove(flag(name))
def check():
rows = sample()
util = max(r[0] for r in rows) # берём самую загруженную карту
arr = push_window(util)
if len(arr) < WINDOW:
return
if all(u <= IDLE_UTIL for u in arr):
waste = COST_PER_HR * (WINDOW / 60) # грубая оценка за окно
alert_once("idle",
"🟡 GPU простаивает, но оплачивается",
f"Загрузка ≤{IDLE_UTIL}% уже {WINDOW} замеров подряд. "
f"Сожжено ≈${waste:.2f}. Выключить инстанс?",
priority=6)
else:
clear("idle")
if all(u >= BUSY_UTIL for u in arr):
alert_once("busy",
"🔴 GPU перегружена",
f"Загрузка ≥{BUSY_UTIL}% уже {WINDOW} замеров. "
f"Очередь инференса растёт — добавьте карту или троттлите.",
priority=8)
else:
clear("busy")
check()

Простой (priority=6) — это про деньги, можно спокойно; перегрузка (priority=8) бьёт по пользователям, поэтому громче. power.draw полезен как второй сигнал: карта, где utilization дёргается, но power стабильно низкий, скорее всего просто держит модель в памяти, ничего не считая.

Если GPU — на арендованном сервере, положите скрипт в cron прямо там:

*/1 * * * * /usr/bin/python3 /opt/notifly/gpu_util.py

State-файлы в /tmp живут между запусками, так что окно и flag-и работают без внешней БД.

Вариант 3: heartbeat «инстанс жив и занят делом»

Заголовок раздела «Вариант 3: heartbeat «инстанс жив и занят делом»»

Инвертируйте логику: пусть скрипт шлёт heartbeat только пока карта реально что-то считает. Пропущенный heartbeat = «инференс встал» (процесс упал, OOM в CUDA), и Notifly сам пришлёт алёрт по таймауту — без единой строки про «проверить, жив ли воркер».

  • индекс карты (GPU 0/1/...) — если их несколько;
  • memory.used и power.draw — чтобы отличить «держит модель» от «реально считает»;
  • оценку сожжённых денег за окно простоя;
  • команду выключения инстанса (yc compute instance stop ...).