golang

Настроить GPU-экспортеры? Легче создать свой или «как подружить экспортер Nvidia-smi-exporter с Pod…

  • суббота, 8 марта 2025 г. в 00:00:11
https://habr.com/ru/companies/t2/articles/888852/

Привет! Меня зовут Настя Бережная, я – DevOps-инженер, и в этой статье я расскажу о том, как мы пробовали использовать для решения своей задачи экспортеры Nvidia DCGM-Exporter и nvidia-gpu-exporter. Но после некоторых скитаний по документациям, форумам и попытками настроить экспортер малой кровью, было решено создать свой.

Начнем с самого начала. К нашей команде пришел бизнес на первый взгляд с достаточно тривиальной задачей – реализовать отслеживание ресурсов видеопамяти в разрезе приложения. Важно было увидеть, сколько GPU-памяти потребляет каждый из Pod'ов Jupyter, которые, в свою очередь, развернуты у нас в Kubernetes.

Первым делом было решено искать уже готовые решения, потому что задача звучит достаточно ходовой – отслеживать ресурсы в рамках Pod'ов умеют многие экспортеры. У Nvidia есть официальный экспортер DCGM-Exporter (документация которого даже упоминает эту возможность) и неофициальный, но достаточно популярный – nvidia-gpu-exporter. В нем на первый взгляд нет упоминания того, что нам нужно, но мы решили попробовать копнуть немного глубже, если не получится с DCGM-Exporter, ну а вдруг?

Почему нам не подошел готовый официальный экспортер Nvidia

Как я упомянула ранее, первым делом взор упал на DCGM-Exporter. Все-таки официальный софт от Nvidia с какой-никакой документацией и, судя по описанию, целым букетом возможностей. Но нас интересуют метрики потребления GPU-memory by Pod.
Что написано в документации по этой теме: в блоке DCGM-Exporter Customization есть табличка с переменными среды для настройки поведения DCGM-Exporter по умолчанию, в табличке можно увидеть переменную $DCGM_EXPORTER_KUBERNETES, которая включает механизм сопоставления метрик k8s с Pod'ми. По умолчанию он выключен: Enable kubernetes mapping metrics to kubernetes pods. Default: false. Отмечу, хотя явно в документации этого не сказано, есть намек на то, что должен быть установлен nvidia-device-plugin, который позволяет взаимодействовать Pod'ам с GPU ресурсами. Приведу пример метрики с существующими label:

DCGM_FI_DEV_GPU_UTIL{gpu="",UUID="",device="",modelName="",Hostname="",DCGM_FI_DRIVER_VERSION="",container="",namespace="",pod=""}

Из примера видно, что есть label pod, но по документации нет однозначности, что должен содержать этот label: наименование Pod'а, генерирующего рабочую нагрузку или наименование Pod'а экспортера. Спойлер: по умолчанию – Pod'а экспортера. На момент написания статьи, информации о настройке вывода наименования Pod'а, генерирующего рабочую нагрузку, в документации не было. Оставалось обращаться к многочисленным форумам. Там действительно можно обнаружить эту проблему, но решения у нее достаточно разные и работают не у всех. Можно отметить самые популярные такие:

  • Добавить переменные среды в экспортер

extraEnv:
- name: "DCGM_EXPORTER_KUBERNETES"
value: "true"
- name: "DCGM_EXPORTER_KUBERNETES_GPU_ID_TYPE"
value: "device-name"

или

extraEnv:
- name: "DCGM_EXPORTER_KUBERNETES"
value: "true"
- name: "DCGM_EXPORTER_KUBERNETES_GPU_ID_TYPE"
value: "uid"
  • Не забыть про установку nvidia-device-plugin в k8s кластер

  • Не забыть про установку nvidia-device-plugin в k8s кластер

  • Убедиться, что kubelet, device-plugin, dcgm-exporter смотрят в одну директорию

  • Добавить в ServiceMonitor флаг honorLabels: true, дабы разрешить возможные конфликты (HonorLabels chooses the metric’s labels on collisions with target labels)

К сожалению, нам эти советы не помогли. Допускаю, что есть еще сценарии, подобные нашему, при которых возникают похожие проблемы. После того как мы поняли, что тут можно закопаться надолго и с большой вероятностью не найти решение проблемы, решили посмотреть в сторону собственного экспортера, предварительно заглянув в git к другим. Чаще всего быстрее и проще взять за основу уже что-то готовое и доработать под собственные нужды. К счастью, был найден проект Nvidia smi exporter, который уже выполняет часть задачи, а именно формирует метрики видеокарты, основываясь на данных инструмента nvidia-smi.

Общие сведения о Nvidia smi exporter (Docker Prometheus Nvidia SMI Exporter)

Экспортер Docker Prometheus Nvidia SMI Exporter (https://github.com/e7d/docker-prometheus-nvidiasmi/tree/master) формирует метрики, полученные инструментом nvidia-smi, а именно статистику по видеокарте и потребляемое процессами GPU. Простыми словами, экспортер выводит данные, формируемые инструментом nvidia-smi.

Инструмент nvidia-smi (The NVIDIA System Management Interface) – это утилита командной строки, основанная на библиотеке управления NVIDIA (NVML), предназначенная для управления и мониторинга устройств на базе графических процессоров NVIDIA.

Вывод команды nvidia-smi без аргументов, как быстрое знакомство:

[xxxxxxx@xxxxxxxx ~]$ nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 550.xx.xx              Driver Version: 550.xx.xx      CUDA Version: 12.x     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  Tesla xxxxxxxxxxxxxx           Off |   00000000:01:00.0 Off |                    0 |
| N/A   29C    P0             36W /  250W |     499MiB /  xxxxxxxx |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI        PID   Type   Process name                              GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|    0   N/A  N/A     87068      C   /opt/java/openjdk/bin/java                    496MiB |
+-----------------------------------------------------------------------------------------+

Метрики выводятся в prometheus-формате; пример вывода возвращаемых показателей:

nvidiasmi_clock_policy_auto_boost{id="00000000:01:00.0",uuid="GPU-xxxxxxxxxxx",name="Tesla xxxxxxxxxxx"} 0
nvidiasmi_clock_policy_auto_boost_default{id="00000000:01:00.0",uuid="GPU-xxxxxxxxxxx",name="Tesla xxxxxxxxxxx"} 0
nvidiasmi_process_used_memory_bytes{id="00000000:01:00.0",uuid="GPU-xxxxxxxxxxx",name="Tesla xxxxxxxxxxx",process_pid="87068", process_name="/opt/java/openjdk/bin/java",process_type="C"} 5.20093696e+08

Можно заметить, что здесь нет label’a, который нам нужен – имя Pod'а, но есть process_pid, который использует ресурсы видеокарты и через который в дальнейшем можно будет «вытащить» имя Pod'а. Это и нужно доработать.

Общие сведения о доработанном Nvidia-smi-exporter, с какой задачей он справляется

Экспортер Nvidia-smi-exporter создан для просмотра метрик c помощью инструмента nvidia-smi. Экспортер выводит основные общие метрики по видеокарте + GPU memory в разрезе Pod'ов, создающих рабочую нагрузку.

Общий принцип работы доработанного Nvidia-smi-exporter

  1. Экспортер работает как сервис на каждом worker-узле кластера, который содержит видеокарту. Он формирует xml-файл через утилиту nvidia-smi с основными характеристиками карты и выполняющимися на ней процессами на момент вызова.
    nvidia-smi – полезный инструмент, он выводит нагрузку GPU-memory для каждого процесса, использующего видеокарту. Так как Pod тоже порождает процессы, мы можем «вытащить» его данные через cgroup.

  2. Экспортер парсит полученный ранее от nvidia-smi XML-файл и формирует метрики в формате Prometheus.

  3. При формировании метрик для каждого процесса из XML-файла вызывается новая функция LookupPod, о которой далее будет сказано более подробно. Функция на вход получает ProcessId, просматривает cgroup процесса и вытаскивает из него containerID по заранее созданному паттерну. После этого функция ищет имя контейнера.
    Поиск наименования контейнера зависит от системы контейнеризации в кластере. В нашем случае актуально было настроить поиск для docker и containerd, так как у нас имеются кластеры, работающие на этих двух системах. Соответственно, в первом случае поиск происходит через docker inspect, а во втором – через runc.

Что под капотом?

Настало время кода. Было решено писать на Go, так как исходный код тоже написан на Go :) Некоторые вещи тут можно оптимизировать, но, мне кажется, для лучшего восприятия и прослеживания логики действий стоит оставить более подробный код. А теперь по порядку:

Как я уже упоминала, первый шаг – это получить данные от nvidia-smi в формате xml. Для этого создана функция metrics. Рассмотрим ее по частям, так как эта функция также парсит и формирует метрики в Prometheus-формате.

Формирование XML-файла происходит с помощью команды nvidia-smi -q -x, в коде за это отвечает:

cmd = exec.Command(NVIDIA_SMI_PATH, "-q", "-x")

В исходном коде NVIDIA_SMI_PATH задана как константа, что не выглядит правильным решением для динамического подхода. При изменении пути не хочется пересобирать код, поэтому вынесем путь в переменную среды NVIDIA_SMI_PATH и будем брать оттуда

NVIDIA_SMI_PATH, exists_path := os.LookupEnv("NVIDIA_SMI_PATH")
    if exists_path == false {
                 NVIDIA_SMI_PATH = "/usr/bin/nvidia-smi"
                 }

В случае если переменная среды не задана, остается путь по умолчанию. Соберем эту часть:

func metrics(w http.ResponseWriter, r *http.Request) {
	log.Print("Serving /metrics")
	var cmd *exec.Cmd
	NVIDIA_SMI_PATH, exists_path := os.LookupEnv("NVIDIA_SMI_PATH")
        if exists_path == false {
                 NVIDIA_SMI_PATH = "/usr/bin/nvidia-smi"
                 }
       log.Print("Path to nvidia-smi: ", NVIDIA_SMI_PATH)
	
	cmd = exec.Command(NVIDIA_SMI_PATH, "-q", "-x")

	// Execute system command
	stdout, err := cmd.Output()
	if err != nil {
		println(err.Error())
		if testMode != "1" {
			println("Something went wrong with the execution of nvidia-smi")
		}
		return
	}

Парсинг. Структурируем полученную информацию. Код для Unmarshal выглядит следующим образом:

var xmlData NvidiaSmiLog
xml.Unmarshal(stdout, &xmlData)

Для парсинга используется пакет encoding/xml.

Go-структура, которая описывает полученный ранее XML-файл:

type NvidiaSmiLog struct {
	DriverVersion string `xml:"driver_version"`
	CudaVersion   string `xml:"cuda_version"`
	AttachedGPUs  string `xml:"attached_gpus"`
	GPU           []struct {
		Id                       string `xml:"id,attr"`
		ProductName              string `xml:"product_name"`
		ProductBrand             string `xml:"product_brand"`
		DisplayMode              string `xml:"display_mode"`
		DisplayActive            string `xml:"display_active"`
		PersistenceMode          string `xml:"persistence_mode"`
		AccountingMode           string `xml:"accounting_mode"`
		AccountingModeBufferSize string `xml:"accounting_mode_buffer_size"`
		DriverModel              struct {
			CurrentDM string `xml:"current_dm"`
			PendingDM string `xml:"pending_dm"`
		} `xml:"driver_model"`
		Serial         string `xml:"serial"`
		UUID           string `xml:"uuid"`
//more code…
		PCI struct {
			Bus         string `xml:"pci_bus"`
			Device      string `xml:"pci_device"`
//more code…
		} `xml:"pci"`
		FanSpeed         string `xml:"fan_speed"`
		PerformanceState string `xml:"performance_state"`
		FbMemoryUsage struct {
			Total string `xml:"total"`
			Used  string `xml:"used"`
			Free  string `xml:"free"`
		} `xml:"fb_memory_usage"`
//more code…
		Processes struct {
			ProcessInfo []struct {
				Pid         string `xml:"pid"`
				Type        string `xml:"type"`
				ProcessName string `xml:"process_name"`
				UsedMemory  string `xml:"used_memory"`
			} `xml:"process_info"`
		} `xml:"processes"`
	} `xml:"gpu"`
}

Далее формируем метрики на основе полученных ранее данных:

for _, GPU := range xmlData.GPU {
		io.WriteString(w, formatVersion("nvidiasmi_driver_version", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", xmlData.DriverVersion))
		io.WriteString(w, formatVersion("nvidiasmi_cuda_version", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", xmlData.CudaVersion))
//more code...
if GPU.PowerReadings.PowerState != "" { // older nvidia-smi versions
			io.WriteString(w, formatValue("nvidiasmi_power_state_int", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterNumber(GPU.PowerReadings.PowerState)))
//more code...
if GPU.GpuPowerReadings.PowerState != "" { // newer nvidia-smi versions
			// retrocompatible format
			io.WriteString(w, formatValue("nvidiasmi_power_state_int", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterNumber(GPU.GpuPowerReadings.PowerState)))
//more code...
		io.WriteString(w, formatValue("nvidiasmi_clock_policy_auto_boost", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterUnit(GPU.ClockPolicy.AutoBoost)))
		io.WriteString(w, formatValue("nvidiasmi_clock_policy_auto_boost_default", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\"", filterUnit(GPU.ClockPolicy.AutoBoostDefault)))
		for _, Process := range GPU.Processes.ProcessInfo {
		    podName := LookupPod(Process.Pid)
			io.WriteString(w, formatValue("nvidiasmi_process_used_memory_bytes", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\",process_pid=\""+Process.Pid+"\", pod_name=\""+podName+"\",process_name=\""+Process.ProcessName+"\",process_type=\""+Process.Type+"\"", filterUnit(Process.UsedMemory)))
		}
	}
}

На этом завершается функция metrics. Однако в этой функции нас еще интересуют последние строчки.  Остановимся на них подробнее. Тут у нас запущен цикл, который формирует метрику nvidiasmi_process_used_memory_bytes по каждому процессу.

for _, Process := range GPU.Processes.ProcessInfo {
		    podName := LookupPod(Process.Pid)
			io.WriteString(w, formatValue("nvidiasmi_process_used_memory_bytes", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\",process_pid=\""+Process.Pid+"\", pod_name=\""+podName+"\",process_name=\""+Process.ProcessName+"\",process_type=\""+Process.Type+"\"",filterUnit(Process.UsedMemory)))
		}

Вспоминаем нашу первоначальную цель: нужно вытащить имя Pod'а и получать данные GPU-memory относительно него. Внутри Pod'а крутится контейнер, которому соответствует ProcessId (PId). Для сопоставления PId и названия Pod'а в этом фрагменте для каждого процесса вызывается новая функция LookupPod podName := LookupPod(Process.Pid), которой на вход подается ProcessId, а на выходе мы получаем от нее имя Pod'а.

func LookupPod(pid string) (string) {
    log.Print("Pid:", pid)
    f, err := os.Open(fmt.Sprintf("/proc/%s/cgroup", pid))
    if err != nil {
            log.Print(err)
    		return ""
    	}
    	defer f.Close()
        scanner := bufio.NewScanner(f)
           for scanner.Scan() {
                		line := scanner.Text()
                		log.Print("line:" , line)
                		cId := kubePattern.FindStringSubmatch(line)
                		log.Print("cId-test ", cId)
                		if cId == nil {
                		    cId := kubePatternConD.FindStringSubmatch(line)
                            if cId == nil { fmt.Fprintln(os.Stderr, "Something wrong with pattern")
                                             return "" }
                            log.Print("cId[2] ", cId[2])
                            fmt.Fprintln(os.Stderr, "cgroup by kuber (containerd)")
                            argCmd = fmt.Sprintf(`runc --root /run/containerd/runc/k8s.io/ state %s | grep '"io.kubernetes.cri.sandbox-name":' | sed 's/.*"io.kubernetes.cri.sandbox-name": "\|".*//g' `, cId[2])
                		} else {
                            log.Print("cId[2] ", cId[2])
                            fmt.Fprintln(os.Stderr, "cgroup by kuber (docker)")
                            argCmd = fmt.Sprintf(`docker inspect --format '{{index .Config.Labels "io.kubernetes.pod.name"}}' %s |  tr -d '\n' `, cId[2])
                		}
                        log.Print("argCmd: ", argCmd)
                        out, err := exec.Command("bash","-c", argCmd ).Output()
                        if err != nil {
                                          log.Print( err)
                                    }
                        log.Print("Pod name: ", string(out))
                podN := string(out)
                return podN
            }
        return ""

}

Что здесь происходит: функция смотрит в cgroup процесса f, err := os.Open(fmt.Sprintf("/proc/%s/cgroup", pid)), далее, по заранее созданному паттерну внутри cgroup, находит СontainerId cId[2]

cId := kubePattern.FindStringSubmatch(line) //паттерн для docker
cId := kubePatternConD.FindStringSubmatch(line) //паттерн для containerd

после этого, с помощью команд в зависимости от системы контейнеризации (docker или conteinerd), находит и возвращает название Pod'а

out, err := exec.Command("bash","-c", argCmd ).Output()
  • для docker: argCmd := fmt.Sprintf(docker inspect --format '{{index .Config.Labels "io.kubernetes.pod.name"}}' %s | tr -d '\n' , cId[2])

  • для containerd: argCmd = fmt.Sprintf(runc --root /run/containerd/runc/k8s.io/ state %s | grep '"io.kubernetes.cri.sandbox-name":' | sed 's/.*"io.kubernetes.cri.sandbox-name": "\|".*//g' , cId[2])

Возвращаемся к заранее созданным паттернам. В нашем случае они выглядят так:

kubePattern = regexp.MustCompile(`\d+:.+:.*/(pod[^/]+)/([0-9a-f]{64})`)

kubePatternConD = regexp.MustCompile(`.*/(kubepods-burstable-.*)/cri-containerd-(.*).scope`)

Исходный вид строчки из cgroup:

------- docker -------
11:blkio:/kubepods/pod387d67a0-2e0a-4418-bacb-435kk85a1b37/bbb083cc31864b731b273ab74628e7hdfg85d7cd1224391dee7c62b69201dbc6

-------containerd-----
0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod6ee8558e_a273_417d_9a57_57f3c7773bd4.slice/cri-containerd-2cbbbe2a9f38fa2255006fd6faed661a55f92bf6f924aae08261634jbv5ff0f67.scope

В нашем случае containerId будут такими (берем примеры из приведенных выше строчек из cgroup):

  • Для docker
    cId[2]=bbb083cc31864b731b273ab74628e7hdfg85d7cd1224391dee7c62b69201dbc6

  • для containerd
    cId[2]= 2cbbbe2a9f38fa2255006fd6faed661a55f92bf6f924aae08261634jbv5ff0f67

Для поиска конечного нейминга Pod'а используется команда docker inspect или, для случая с containerd, runc с дальнейшими преобразованиями по строке:

------- docker -------
docker inspect --format '{{index .Config.Labels "io.kubernetes.pod.name"}}' cId[2] | tr -d '\n'

------- containerd -----
runc --root /run/containerd/runc/k8s.io/ state cId[2] | grep '"io.kubernetes.cri.sandbox-name":' | sed 's/.*"io.kubernetes.cri.sandbox-name":"\|".*//g'

Таким образом, функция вытаскивает имя Pod'а и присваивает его переменной podName, которая используется для получения значения нового label pod_name в метрике nvidiasmi_process_used_memory_bytes. Простыми словами, мы добавляем новый label pod_name в метрику nvidiasmi_process_used_memory_bytes для каждого существующего процесса, который использует ресурсы видеокарты.

io.WriteString(w, formatValue("nvidiasmi_process_used_memory_bytes", "id=\""+GPU.Id+"\",uuid=\""+GPU.UUID+"\",name=\""+GPU.ProductName+"\",process_pid=\""+Process.Pid+"\", pod_name=\""+podName+"\",process_name=\""+Process.ProcessName+"\",process_type=\""+Process.Type+"\"", filterUnit(Process.UsedMemory)))

Схема взаимодействия функций:

Для общей картины стоит также выделить функцию main:

func main() {
	testMode = os.Getenv("TEST_MODE")
	if testMode == "1" {
		log.Print("Test mode is enabled")
	}
    LISTEN_ADDRESS, exists_port := os.LookupEnv("NVIDIASMI_EXP_PORT")
    	if exists_port == false {
            	   LISTEN_ADDRESS = ":9202"
          }
    log.Print("Port: ", LISTEN_ADDRESS)
	log.Print("Nvidia SMI exporter listening on " + LISTEN_ADDRESS)
	http.HandleFunc("/", index)
	http.HandleFunc("/metrics", metrics)
	http.ListenAndServe(LISTEN_ADDRESS, nil)
}

В этом фрагменте вызываются отмеченные ранее функции, указывается дефолтный порт. Порт теперь тоже можно поменять через переменную среды NVIDIASMI_EXP_PORT.

Сборка и установка

Сборка кода осуществляется командой:

go build -o ./<name_exporter> ./<path to file.go>

Установить можно по-разному, но в нашем случае экспортер установлен как сервис на каждом worker-узле, который имеет видеокарту (для этого создан ansible-плейбук).

Необходимые компоненты для работы экспортера на сервере:

  • Docker / containerd

  • Плагин nvidia-smi

Сам сервис выглядит таким образом:

[Unit]
Description=Nvidia-smi-exporter
ConditionPathExists=/opt/nvidiasmi-exporter
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/nvidiasmi-exporter
ExecStart=/opt/nvidiasmi-exporter/nvidiasmi-exporter
Restart=on-failure
RestartSec=10
StartLimitInterval=60

[Install]
WantedBy=multi-user.target
Можно добавить в [Unit]
---- для docker---------
After=docker.service 
Requires=docker.service

---- для containerd-----
After= containerd.service
Requires= containerd.service

Вывод метрик в Grafana

Несмотря на то что в получившемся экспортере достаточно много метрик, нас интересует лишь nvidiasmi_process_used_memory_bytes. Она показывает сколько GPU-memories потребляется каждым процессом, который был вызван Pod'ом. Задача стояла так: добавить график с метрикой потребления ресурсов уже в существующий дашборд, который активно использует команда.  В нашем случае это https://grafana.com/grafana/dashboards/14574-nvidia-gpu-metrics/, он работает в паре с экспортером https://github.com/utkuozdemir/nvidia_gpu_exporter?tab=readme-ov-file, который у нас тоже стоит на кластере. И также нужно организовать вывод метрик относительно машины (by Node).

Как можно заметить из метрики, label’ов с названием машины у нас нет, думаю его тоже можно было бы добавить, но нам хватает текущих данных, так как мы можем привязаться к label uuid.

nvidiasmi_process_used_memory_bytes{id="00000000:01:00.0",uuid="GPU-7b85cfd6-ed01-4492-208b-b8b8xxxxxxx",name="Tesla xxxxx",process_pid="87068", pod_name="jupyter-xxxxx",process_name="/opt/java/openjdk/bin/java",process_type="C"} 5.20093696e+08

Дашборд ранее был немного изменен: добавлена переменная Node, которая формирует список машин, к которой, в свою очередь, привязана переменная GPU. Тем самым всплывающий список переменной GPU формируется относительно переменной Node (GPU by Node). На скриншоте можно видеть, что для GPU включена опция Include All option, можно выбрать все и сразу.

Добавляем на дашборд новую панель с графиком и формируем такой запрос:
sum(nvidiasmi_process_used_memory_bytes{uuid=~"GPU-$gpu"}) by (pod_name)

Почему uuid=~"GPU-$gpu"? Переменная GPU отдается экспортером nvidia_gpu_exporter. При создании Query для графика мы используем наш nvidia-smi-exporter, из-за того, что они имеют разные форматы отображения gpu (label uuid) – нужно приспособить выражение, как пример:

----------nvidia_gpu_exporter-------------
{uuid="2f76546a-404c-0563-5f49-815xxxxxxx"}
----------nvidia-smi-exporter-------------
{uuid="GPU-2f76546a-404c-0563-5f49-815xxxxxxx"}

Sum используем, так как одному Pod'у могут соответствовать несколько процессов.

Схема взаимодействия параметров и их источники:

Итоговый график stacked line chart (график с накоплением) будет выглядеть таким образом:

Дашборд готов, метрики снимаются, бизнес радуется.

Вывод

Доработанная версия экспортера позволяет просматривать метрики потребляемых ресурсов GPU в разрезе Pod'а, генерирующего рабочую нагрузку.  Это дает пользователю прямой доступ к данным о потребляемых Pod'ами GPU-ресурсах, что в свою очередь упрощает контроль над ресурсами.