DGX Spark: мониторинг unified memory, когда NVML и dcgm‑exporter молчат

от автора

Свежепоставленный мониторинг на DGX Spark. Открываю NVIDIA‑дашборд в Grafana — половина memory‑панелей пустые, прямые линии по нулю. Сначала кажется, что что‑то не настроил. Через полчаса доходит: это не у меня сломалось, это NVML на GB10 так работает.

Это та область, где на GB10 половина стандартного observability‑стека просто не работает: NVML отдаёт [N/A] на memory.used и memory.total, dcgm‑exporter не ставится, nvtop в memory‑колонке показывает пустоту. В Grafana NVIDIA‑дашборды по умолчанию выглядят так, будто GPU вообще нет — и это не очевидно, потому что Grafana при отсутствии данных не кричит, а молча рисует ровную линию по нулю.

Эта статья — про то, как я это место обошёл и что в итоге увидел в Grafana. Трёхуровневая схема: textfile collector для базовых метрик, per‑container attribution через docker top + nvidia-smi, и CLI‑фоллбэк на /proc/meminfo, который оказался полезен не только на Spark, но и на других Linux‑системах с единой памятью (unified memory) — AMD Strix Halo и подобные.

В статье разберу:

  • почему стандартные инструменты ломаются на GB10;

  • как устроен collector на 65 строк bash и какие три не очевидных решения в нём сидят;

  • как делать per‑container attribution GPU‑памяти, когда NVML слепой;

  • как читать /proc/meminfo вместо memory.used и почему это применимо не только на Spark;

  • почему gpu_memory_utilization=0.60, а не 0.70 — и при чём тут CUBLAS.

Что nvidia‑smi отдаёт на GB10

Запускаю на проде стандартный запрос, привычный для discrete GPU:

$ nvidia-smi --query-gpu=memory.used,memory.total --format=csvmemory.used [MiB], memory.total [MiB][N/A], [N/A]

Сначала кажется, что что‑то не так с драйвером — nvidia-smi без аргументов работает, показывает GPU, температуру, питание, утилизацию. Но как только спрашиваешь про память — пустота. И не «0», а буквально строка [N/A].

Это не баг — это by design, на форумах NVIDIA подтверждается. На GB10 нет отдельного VRAM‑региона: CPU и GPU делят 128 GiB LPDDR5x как один когерентный пул. NVML, который раньше для каждого discrete GPU честно репортил «занято/всего», на единой памяти не знает, что отвечать. С точки зрения системы все 128 GiB и так доступны GPU всегда — концепции «VRAM использовано» больше нет.

Дальше идёт цепочка следствий, и каждое надо проговорить отдельно.

dcgm‑exporter на Spark не ставится. Точнее, контейнер поднимется, но метрики по памяти будут пустые — он построен поверх NVML/DCGM, и на GB10 наследует ту же слепоту. Стандартные NVIDIA Grafana‑дашборды через него превращаются в пустые панели.

nvtop показывает то же [N/A] в memory‑колонке. Жалко, потому что nvtop удобный. Но он тоже сидит на NVML.

Стандартный Grafana NVIDIA dashboard в memory‑панелях — пустота. И самое противное: Grafana не кричит «нет данных», она просто рисует прямую линию по нулю. Без collector’а ты бы это не заметил неделями. Дашборд вроде есть, на нём что‑то нарисовано — кажется, всё работает. На самом деле половина панелей слепые.

Моя задача после этого открытия превратилась в простую: понять, что на GB10 работает, и собрать из этого мониторинг.

Что работает: ‑query‑compute‑apps

Если копать дальше в опции nvidia‑smi, находится одна ветка, которая на Spark отдаёт осмысленные данные:

$ nvidia-smi --query-compute-apps=gpu_uuid,pid,process_name,used_memory --format=csvGPU-40770741-..., 2716588, /opt/app-root/bin/python3, 1254 MiBGPU-40770741-..., 2725545, VLLM::EngineCore, 1531 MiBGPU-40770741-..., 2725763, VLLM::EngineCore, 1949 MiB

NVML, как выяснилось, прекрасно знает, сколько GPU‑памяти аллоцировал каждый отдельный процесс — он только не умеет суммировать в общую цифру для единого пула. Это и есть мост к решению: суммарная память берётся из /proc/meminfo (на системе с единой памятью она и так корректная), а атрибуция «кто сколько съел» — из --query-compute-apps. Сложить их в один дашборд — задача не научная, надо просто сделать.

Архитектура: три независимых слоя

Я разбил мониторинг на три слоя, каждый со своей задачей:

  1. Textfile collector — забирает базовые GPU‑метрики (температура, утилизация, мощность, частота) через nvidia‑smi и пишет их в Prometheus‑формат, который читает node‑exporter. Туда же подключаются node_memory_* метрики самого node‑exporter — через них видна общая память коробки;

  2. Per‑container attribution — отдельная команда agmind gpu status, которая показывает, кто из контейнеров сколько GPU‑памяти занял прямо сейчас. Работает через docker top + nvidia-smi --query-compute-apps. Суммарной картинки в Grafana не даёт, зато даёт мгновенный ответ на вопрос «почему GPU кончился»;

  3. CLI‑фоллбэк на /proc/meminfo — если основной запрос nvidia‑smi отдаёт N/A в memory полях, скрипт читает /proc/meminfo и показывает память с пометкой (unified). Полезно не только на Spark.

Дальше по слоям.

Collector: 65 строк bash и три не очевидных решения

Полный код — scripts/gpu-metrics.sh в репе, всего 65 строк. Покажу четыре места: один базовый запрос для контекста и три не очевидных решения, которые пришлось переписывать.

Базовый запрос:

bash

data=$(nvidia-smi --query-gpu=index,temperature.gpu,utilization.gpu,power.draw,clocks.current.graphics,memory.used,memory.total,name \    --format=csv,noheader,nounits)

Ничего экзотического — обычный CSV‑вывод. Дальше начинается специфика.

Чистка [N/A] перед экспортом:

bash

mem_used="${mem_used//\[N\/A\]/0}"mem_total="${mem_total//\[N\/A\]/0}"

Если этого не сделать, в .prom файле окажутся строки вида agmind_gpu_memory_used_bytes{...} [N/A], и node‑exporter ругнётся ошибкой парсинга — конкретно эта строка в Prometheus не попадёт.

Skip‑логика для метрик, которые на Spark всегда нулевые:

bash

if [[ "$mem_used" != "0" ]]; then    echo "agmind_gpu_memory_used_bytes{${labels}} $((mem_used * 1048576))"fi

Это уже не из документации, а из практики. Если просто экспортировать «0», Grafana покажет ровную линию на дне — выглядит так, будто метрика работает, но всегда нулевая. Это хуже, чем явное отсутствие метрики: смотришь и думаешь «а GPU точно работает?». Лучше вообще не выводить строку — тогда в Grafana панель явно скажет «No data», и сразу понятно, что эту панель надо переделать на что‑то другое (см. ниже про System Memory).

Atomic rename в конце:

bash

mv "$TMP_FILE" "$PROM_FILE"

Маленькая, но обязательная деталь. Node‑exporter сканирует textfile‑директорию каждые несколько секунд. Если писать прямо в .prom файл — будет ситуация, когда node‑exporter откроет его в момент, пока collector ещё дописывает, и попытается распарсить половину. На быстрых машинах это случается редко, на нагруженных — постоянно. Пишем в tmp, потом атомарно переименовываем — теперь node‑exporter всегда видит либо старую полную версию, либо новую полную версию, без половинок.

Запускается это раз в 15 секунд — через cron или systemd timer, разницы по сути никакой. Для GPU‑метрик такой частоты достаточно.

Что в итоге в Prometheus

Вот живой вывод из gpu_metrics.prom на моём проде, прямо сейчас:

agmind_gpu_temperature_celsius{gpu="0",name="NVIDIA_GB10"} 47agmind_gpu_utilization_percent{gpu="0",name="NVIDIA_GB10"} 0agmind_gpu_power_watts{gpu="0",name="NVIDIA_GB10"} 10.66agmind_gpu_clock_mhz{gpu="0",name="NVIDIA_GB10"} 2405

Memory‑метрик в файле нет — отработала skip‑логика. Это правильно: на их месте в Grafana работает System Memory панель, заполняемая через node_memory_MemTotal_bytes и node_memory_MemAvailable_bytes от node‑exporter. На системе с единой памятью это и есть «настоящая» память — деления на CPU и GPU всё равно нет физически.

Конфиг node‑exporter без сюрпризов:

yaml

node-exporter:  image: prom/node-exporter:v1.11.1  pid: host  volumes:    - /proc:/host/proc:ro    - /:/rootfs:ro    - ./monitoring/textfile:/textfile:ro  command:    - '--path.procfs=/host/proc'    - '--collector.textfile.directory=/textfile'

Ключевое — --collector.textfile.directory. Всё, что лежит в этой директории как *.prom, автоматически попадает в Prometheus.

Per‑container attribution: главная фишка

Базовые метрики из collector’а отвечают на вопрос «как себя чувствует GPU». Они не отвечают на «кто из контейнеров его занял». Для этого у меня есть отдельная команда agmind gpu status. Вот её живой вывод на проде:

GPUs:  GPU 0: NVIDIA GB10  | VRAM: 31382 / 124610 MiB (unified) (free: 93228 MiB) | Util: 0Container Assignments:  vLLM   -> GPU 0  (VLLM_CUDA_DEVICE=0)  TEI    -> not active (EMBED_PROVIDER=vllm-embed)GPU Processes:  agmind-docling                          | 1254 MiB  agmind-vllm-embed                       | 1531 MiB  agmind-vllm-rerank                      | 1949 MiB  agmind-ragflow                          |  292 MiB  agmind-ragflow                          |  292 MiB

Это и есть то, ради чего стоило городить весь этот огород. Я в одну строку вижу:

— общая занятость GPU‑памяти (31 GiB из 122); — пометка (unified) — чтобы я не путался с реальной discrete‑картой; — список контейнеров с конкретной аллокацией каждого.

Реализовано через четырёхшаговый пайплайн (scripts/agmind.sh:661-704):

1. docker compose ps -q                  → список контейнеров стека2. docker top <container> -o pid         → мапа PID → имя контейнера3. nvidia-smi --query-compute-apps       → список процессов с GPU-аллокацией4. JOIN по PID                           → строка «контейнер | сколько MiB»

Полезный side‑effect: если на коробке вдруг появится посторонний процесс с GPU‑аллокацией не из docker compose, он покажется отдельной строкой PID xxx | <process_name> | <MiB> (non-agmind). Это сразу видно — например, если кто‑то залогинился по SSH и запустил python с CUDA‑вызовом.

Симметричный peer‑worker дашборд в кластере выглядит зеркально, плюс там добавляются vLLM‑специфичные метрики (requests running/waiting, GPU KV cache hit rate, p50/p95 latency). Метрики передаются по QSFP 200G линку master ← peer.

peer-worker dashboard

peer‑worker dashboard

CLI fallback на /proc/meminfo

Возвращаемся к строке VRAM: 31382 / 124610 MiB (unified) из вывода выше. Откуда там цифры, если nvidia‑smi отдаёт [N/A]?

Кусок из scripts/agmind.sh:617-626:

bash

if [[ "$mem_total" == *"N/A"* || -z "$mem_total" ]]; then    meminfo_total=$(awk '/^MemTotal:/{print int($2/1024)}' /proc/meminfo)    meminfo_avail=$(awk '/^MemAvailable:/{print int($2/1024)}' /proc/meminfo)    mem_total="$meminfo_total"    mem_used="$((meminfo_total - meminfo_avail))"    mem_free="$meminfo_avail"    unified_label=" (unified)"fi

Логика прямолинейная: если nvidia‑smi молчит, у нас единая память; а в едином пуле «GPU память» — это просто общая память коробки. Читаем MemTotal и MemAvailable из /proc/meminfo, считаем used = total − available, ставим пометку (unified), чтобы в выводе было понятно, что это не настоящая VRAM, а общий пул.

Реальный снапшот /proc/meminfo с моей машины:

MemTotal:       127601388 kB     # ~121 GiBMemAvailable:    95409316 kB     # ~91 GiB свободноSwapTotal:       33554428 kBSwapFree:        33319272 kB

121 GiB видимых в OS — это, кстати, уже не идеально: NVIDIA продаёт коробку как «128 GB», но после резерва под прошивку и kernel reserved memory остаётся около 121. Ничего страшного, но при планировании бюджета оперируйте 121, а не 128.

Сам приём — /proc/meminfo как fallback, когда GPU API не отдаёт memory — применим за пределами Spark. У каждой системы с единой памятью свои нюансы: на Apple Silicon Metal Performance Shaders отдаёт currentAllocatedSize, есть IOReport и powermetrics; на AMD Strix Halo rocm-smi репортит свои цифры; на NVML/GB10 — то самое [N/A]. Где‑то API молчит совсем, где‑то даёт частичную картину. /proc/meminfo — универсальный last resort, который покажет хотя бы общее состояние коробки и ляжет в любой Linux‑стек без специфичных зависимостей.

Memory budget на 121 GiB: без него мониторинг бесполезен

Теперь у меня есть числа. Что с ними делать? Чтобы мониторинг превратился из «какой‑то график» в инструмент принятия решений, нужен бюджет — заранее понятные пороги «это нормально», «это тревожно», «это всё, упали».

На 121 GiB единого пула я считаю так. Сначала резерв под систему:

Что резервируется

GiB

Kernel + system

10

Buffer / page cache

15

Swap headroom

10

Итого вычесть

35

Доступно контейнерам

86

Page cache важно явно зарезервировать — на read‑heavy воркладе RAGFlow с ES‑индексами кэш страниц легко съедает 15–20 GiB. Если запас не оставить, Linux начнёт активно эвиктить страницы, latency индексации поедет вверх, и в логах вы это не увидите, потому что технически ничего не сломалось.

Дальше типичный split на 86 GiB контейнерного бюджета. Сразу оговорка: цифры ниже — это пиковые потребности, на проде они не складываются один к одному. vLLM держит свои ~83 GiB постоянно, остальные выходят на пики редко и не одновременно. Именно поэтому gpu_memory_utilization=0.60 оказывается рабочим компромиссом, а не математически очевидным числом — суммарно по таблице получается больше, чем 86 GiB, и это нормально.

Контейнер

GiB

vLLM (gpu_memory_utilization=0.60)

~83

docling peak (batch 64)

8–16

Postgres shared_buffers=8G

8

Weaviate JVM heap

15

Redis + Celery + workers

~5

Именно при активном docling-парсинге и проявляется почему 0.60, а не 0.70. На 0.70 у меня воспроизводимо валится CUBLAS с illegal memory access внутри vLLM, причём не сразу, а через какое‑то время после параллельного запуска docling‑задачи на батч из десятка PDF. Точную причину я не докопался, но рабочая гипотеза — фрагментация единого пула в момент, когда vLLM‑аллокации и docling‑аллокации одновременно растут. На 0.60 проблема не воспроизводится.

Если у вас второй Spark выделен под vLLM (dual‑Spark кластер из первой части), там этот предел можно поднимать выше — параллельной нагрузки от docling на той же коробке нет.

Что в итоге в Grafana

Дашборд gpu.json для master Spark состоит из нескольких секций.

gpu.json master dashboard

gpu.json master dashboard

Top row — мгновенные значения через gauges: температура (47–48 °C в idle), утилизация (0% когда нагрузки нет), мощность (10.66 W idle baseline для GB10 — под нагрузкой она ожидаемо растёт), частота 2.4 GHz.

Middle row — история за час: температура, мощность и утилизация. Видны короткие burst’ы GPU‑работы между периодами idle. Под реальной нагрузкой (chat‑сессии, RAG‑запросы) тут видны устойчивые плато на 80–100% утилизации.

Bottom row — это и есть та самая «System Memory вместо VRAM» панель. Used / Available / Swap Used строятся через node_memory_* и работают честно. На моём текущем idle‑снапшоте: ~32 GiB used, ~96 GiB available, swap 0 — характерно для коробки в покое, активной нагрузки нет, бóльшая часть памяти доступна.

Весь дашборд построен на двух источниках: agmind_gpu_* (textfile collector) + node_memory_* (node‑exporter поверх /proc/meminfo). Никакого NVML.

Что осталось за бортом

Честно проговорю ограничения, потому что без них статья превратится в маркетинг.

— На unified memory нет нативного способа отличить «вот эти X GiB занял GPU, а эти Y — CPU». Физически это один пул, и весь стек инструментов под этим предположением и построен. Это не моя слабость, это объективное состояние мониторинга unified‑систем в 2026 году. Если у кого‑то получилось такой split сделать, буду благодарен за наводку в комментах.

dcgm-exporter я не смог завести на Spark. Он построен поверх DCGM API, который зависит от NVML, который на GB10 в memory‑метриках слеп. Если кто‑то заставил его работать конкретно на Spark — пишите тоже.

nvtop показывает [N/A] в memory колонке. Лечится только патчем самого nvtop, чтобы он на unified‑системах ходил в /proc/meminfo. PR в апстрим я не отправлял — на момент статьи руки не дошли.

Что я из этого вынес

На стандартном NVIDIA Grafana‑дашборде memory‑панели на Spark рисуют прямую линию по нулю — и это выглядит как «всё нормально, GPU не занят». Без collector’а вы бы продолжали смотреть на эту прямую неделями и быть уверенным, что у вас всё под контролем.

Это применимо не только к GB10 и не только к unified memory. Любой раз, когда вы запускаете observability‑стек на нестандартном железе, новой архитектуре или свежем рантайме, половина метрик может врать. Не возвращать ошибку, а молча врать: показывать ноль вместо отсутствующих данных, рисовать прямые там, где должны быть пики, не алёртить на условия, которых их API ещё не знает. Инструменты отстают от железа, и это нормально — они пишутся людьми, у которых это железо появляется на полгода‑год позже.

Единственный способ это поймать — сесть на час‑два и руками сверить, что метрики из дашборда соответствуют тому, что вы видите в реальной жизни. Открыть htop, посмотреть, что Postgres ест 8 GiB; открыть Grafana, убедиться, что соответствующая панель показывает 8 GiB. Если расхождение — копать. Это не паранойя, это базовая гигиена observability на нестандартном железе.

И второй вывод, поменьше. Когда я делал этот мониторинг, мне очень хотелось плюнуть и оставить в дашборде «общая память сервера», без атрибуции по контейнерам. Хватило бы и этого. Но в момент, когда RAG‑индексация однажды съест всю память и упадёт OOM‑килл, разница между «у меня кончилась память» и «у меня кончилась память, потому что docling в очередной раз решил батчем обработать весь документооборот компании» — это разница между «искать пять часов» и «починить за пять минут». Per‑container attribution стоит вечера работы и окупается с первого инцидента.

Первая часть про сборку приватного AI‑стека на DGX Spark неожиданно для меня хорошо зашла читателям — спасибо всем, кто прочитал и оставил комментарии.

В следующей части  — три кейса с Dify: безопасность plugin_daemon, RAG‑ассистент для сисадмина (логи, конфиги, runbook’и), связка Dify + RAGFlow для индексации тяжёлых документов. Увидимся.

AGmind


ссылка на оригинал статьи https://habr.com/ru/articles/1031904/