Утилизация GPU
GPU — самая дорогая строчка в счёте у соло-разработчика с локальными моделями. Две беды выглядят одинаково молча:
- простой при оплате — инстанс поднят, модель выгружена, но
$X/часвсё равно капает (забыли выключить после эксперимента); - устойчивая перегрузка — карта воткнута в 100% часами, очередь инференса растёт, а вы об этом узнаёте от пользователей.
Стандартные дашборды это показывают, только если на них смотреть. Push приходит сам.
Вариант 1: опрос nvidia-smi по таймеру
Заголовок раздела «Вариант 1: опрос nvidia-smi по таймеру»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 стабильно
низкий, скорее всего просто держит модель в памяти, ничего не считая.
Вариант 2: cron на самом инстансе
Заголовок раздела «Вариант 2: cron на самом инстансе»Если GPU — на арендованном сервере, положите скрипт в cron прямо там:
*/1 * * * * /usr/bin/python3 /opt/notifly/gpu_util.pyState-файлы в /tmp живут между запусками, так что окно и flag-и работают
без внешней БД.
Вариант 3: heartbeat «инстанс жив и занят делом»
Заголовок раздела «Вариант 3: heartbeat «инстанс жив и занят делом»»Инвертируйте логику: пусть скрипт шлёт heartbeat только пока карта реально что-то считает. Пропущенный heartbeat = «инференс встал» (процесс упал, OOM в CUDA), и Notifly сам пришлёт алёрт по таймауту — без единой строки про «проверить, жив ли воркер».
Что положить в текст алёрта
Заголовок раздела «Что положить в текст алёрта»- индекс карты (
GPU 0/1/...) — если их несколько; memory.usedиpower.draw— чтобы отличить «держит модель» от «реально считает»;- оценку сожжённых денег за окно простоя;
- команду выключения инстанса (
yc compute instance stop ...).