From 10b79a14bb800f49784cc9461a9ae70eebcf213f Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Mon, 8 Sep 2025 17:41:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(gpu):=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BB=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20GPU=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20Linux=20(nvidia-smi/rocm-smi);=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=BA=D1=83=20=D0=B8=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 4 +- bin/agent/config.yaml | 8 ++ src/collectors/gpu/gpu_linux.go | 184 ++++++++++++++++++++++++++ src/collectors/gpu/gpu_unsupported.go | 16 +++ src/collectors/gpu/main.go | 48 +++++++ 5 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 src/collectors/gpu/gpu_linux.go create mode 100644 src/collectors/gpu/gpu_unsupported.go create mode 100644 src/collectors/gpu/main.go diff --git a/Makefile b/Makefile index 220f86a..108bf57 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ collectors: else \ docker run --rm -v $$PWD:/workspace -w /workspace \ -e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ - sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker"; \ + sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu"; \ fi @# Убедимся, что скрипты исполняемые @chmod +x ./bin/agent/collectors/*.sh 2>/dev/null || true @@ -61,7 +61,7 @@ collectors-linux: # Кросс-сборка коллекторов для Linux @mkdir -p ./bin/agent/collectors .cache/go-build .cache/go-mod; \ docker run --rm -v $$PWD:/workspace -w /workspace -e GOOS=linux -e GOARCH=amd64 -e GOCACHE=/workspace/.cache/go-build -e GOMODCACHE=/workspace/.cache/go-mod golang:1.22 \ - sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker" + sh -c "go mod tidy >/dev/null 2>&1 && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/uptime ./src/collectors/uptime && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/macos ./src/collectors/macos && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/system ./src/collectors/system && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/hba ./src/collectors/hba && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/sensors ./src/collectors/sensors && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/docker ./src/collectors/docker && CGO_ENABLED=0 go build -trimpath -o ./bin/agent/collectors/gpu ./src/collectors/gpu" collectors-windows: # Кросс-сборка коллекторов для Windows diff --git a/bin/agent/config.yaml b/bin/agent/config.yaml index 8b25957..6792e9e 100644 --- a/bin/agent/config.yaml +++ b/bin/agent/config.yaml @@ -69,6 +69,14 @@ collectors: timeout: "20s" exec: "./collectors/docker" platforms: [darwin, linux] + gpu: + enabled: true + type: exec + key: gpu + interval: "30s" + timeout: "8s" + exec: "./collectors/gpu" + platforms: [linux] diff --git a/src/collectors/gpu/gpu_linux.go b/src/collectors/gpu/gpu_linux.go new file mode 100644 index 0000000..0fb9c2b --- /dev/null +++ b/src/collectors/gpu/gpu_linux.go @@ -0,0 +1,184 @@ +//go:build linux + +package main + +// Автор: Сергей Антропов, сайт: https://devops.org.ru +// Linux-реализация gpu: NVIDIA через nvidia-smi, AMD через rocm-smi. + +import ( + "context" + "encoding/json" + "os/exec" + "fmt" + "strconv" + "strings" +) + +// collectGPU возвращает данные в формате {"gpu": [ {...}, ... ]} +func collectGPU(ctx context.Context) (map[string]any, error) { + // Сначала пробуем NVIDIA + if exists("nvidia-smi") { + if arr := collectNvidia(ctx); len(arr) > 0 { + return map[string]any{"gpu": arr}, nil + } + } + // Затем AMD ROCm + if exists("rocm-smi") { + if arr := collectRocm(ctx); len(arr) > 0 { + return map[string]any{"gpu": arr}, nil + } + } + return map[string]any{"gpu": []any{}}, nil +} + +// collectNvidia парсит вывод nvidia-smi в csv,noheader,nounits +func collectNvidia(ctx context.Context) []map[string]any { + q := "nvidia-smi --query-gpu=index,name,driver_version,memory.total,memory.used,utilization.gpu,utilization.memory,temperature.gpu,power.draw --format=csv,noheader,nounits" + out, err := run(ctx, "sh", "-c", q+" 2>/dev/null") + if err != nil || strings.TrimSpace(out) == "" { return nil } + lines := strings.Split(strings.TrimSpace(out), "\n") + res := make([]map[string]any, 0, len(lines)) + for _, line := range lines { + parts := splitCSV(line) + if len(parts) < 9 { continue } + item := map[string]any{} + item["id"] = atoi(parts[0]) + item["name"] = parts[1] + item["driver_version"] = parts[2] + item["mem_total_mb"] = atoi(parts[3]) + item["mem_used_mb"] = atoi(parts[4]) + item["gpu_util_pct"] = atoi(parts[5]) + item["mem_util_pct"] = atoi(parts[6]) + item["temperature_c"] = atoi(parts[7]) + item["power_watt"] = atof(parts[8]) + res = append(res, item) + } + return res +} + +// collectRocm пытается получить данные через rocm-smi в формате JSON +func collectRocm(ctx context.Context) []map[string]any { + // Предпочитаем JSON-вывод, чтобы избежать парсинга неструктурированного текста + // Набор метрик: id, name, driver_version, vram total/used, gpu/mem util, temp, power + out, err := run(ctx, "sh", "-c", "rocm-smi --showid --showproductname --showdriverversion --showmeminfo vram --showuse --showtemp --showpower --json 2>/dev/null") + if err != nil || strings.TrimSpace(out) == "" { return nil } + // Ответ обычно представляет собой JSON-объект с ключами по картам (card0, card1 ... или "card": {"0": {...}}) + // Используем динамический парсинг + var m map[string]any + if e := json.Unmarshal([]byte(out), &m); e != nil { return nil } + // Попробуем разные формы структуры + cards := []map[string]any{} + if cardMap, ok := m["card"].(map[string]any); ok { + for k, v := range cardMap { + if obj, ok2 := v.(map[string]any); ok2 { cards = append(cards, normalizeRocmCard(k, obj)) } + } + } else { + // некоторые версии отдают плоские ключи card0, card1 + for k, v := range m { + if strings.HasPrefix(strings.ToLower(k), "card") { + if obj, ok2 := v.(map[string]any); ok2 { cards = append(cards, normalizeRocmCard(strings.TrimPrefix(strings.ToLower(k), "card"), obj)) } + } + } + } + // Удаляем пустые + res := []map[string]any{} + for _, c := range cards { + if len(c) > 0 { res = append(res, c) } + } + return res +} + +// normalizeRocmCard приводит карту полей rocm-smi к нашей схеме +func normalizeRocmCard(idx string, obj map[string]any) map[string]any { + item := map[string]any{} + if i := atoi(strings.TrimSpace(idx)); i >= 0 { item["id"] = i } + // Возможные ключи в разных версиях rocm-smi + // Имя + if v, ok := firstString(obj, + "Card series", "Device SKU", "Product", "Card model", "card_model", "Card Model"); ok { item["name"] = v } + // Версия драйвера + if v, ok := firstString(obj, "Driver version", "driver_version"); ok { item["driver_version"] = v } + // Память + // Некоторые версии дают байты: "VRAM Total Memory (B)", "VRAM Used Memory (B)" + if tStr, ok := firstString(obj, "VRAM Total Memory (B)", "vram_total"); ok { + t := atoui64(tStr) + if t > 0 { item["mem_total_mb"] = t / (1024 * 1024) } + } + if uStr, ok := firstString(obj, "VRAM Used Memory (B)", "vram_used"); ok { + u := atoui64(uStr) + if u > 0 { item["mem_used_mb"] = u / (1024 * 1024) } + } + // Загрузка ядра/памяти + if v, ok := firstString(obj, "GPU use (%)", "GPU use", "gpu_use"); ok { item["gpu_util_pct"] = atoi(v) } + if v, ok := firstString(obj, "GPU memory use (%)", "GPU memory use", "mem_use"); ok { item["mem_util_pct"] = atoi(v) } + // Температура + if v, ok := firstString(obj, "Temperature (Sensor die) (C)", "Temperature (Sensor edge) (C)", "temperature"); ok { item["temperature_c"] = atof(v) } + // Питание + if v, ok := firstString(obj, "Power (W)", "Average Graphics Package Power (W)", "power"); ok { item["power_watt"] = atof(v) } + + return item +} + +// exists проверяет наличие бинаря в $PATH +func exists(bin string) bool { + _, err := exec.LookPath(bin) + return err == nil +} + +// splitCSV аккуратно разделяет CSV без кавычек (nvidia-smi выводит простой csv) +func splitCSV(line string) []string { + parts := strings.Split(line, ",") + for i := range parts { parts[i] = strings.TrimSpace(parts[i]) } + return parts +} + +func atoi(s string) int { + s = strings.TrimSpace(s) + i, _ := strconv.Atoi(s) + return i +} + +func atoui64(s string) uint64 { + s = strings.TrimSpace(s) + // удаляем возможные единицы измерения + s = strings.TrimSuffix(s, "B") + v, _ := strconv.ParseUint(s, 10, 64) + return v +} + +func atof(s string) float64 { + s = strings.TrimSpace(s) + f, _ := strconv.ParseFloat(s, 64) + return f +} + +func run(ctx context.Context, bin string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, bin, args...) + b, err := cmd.Output() + if err != nil { return "", err } + return string(b), nil +} + +// firstString возвращает первое непустое строковое представление значения по перечню ключей +func firstString(m map[string]any, keys ...string) (string, bool) { + for _, k := range keys { + if v, ok := m[k]; ok { + switch t := v.(type) { + case string: + s := strings.TrimSpace(t) + if s != "" { return s, true } + case float64: + // JSON числа парсятся в float64 + return strings.TrimSpace(fmt.Sprintf("%v", t)), true + case int, int32, int64, uint64, uint32, uint8: + return strings.TrimSpace(fmt.Sprintf("%v", t)), true + case map[string]any: + // Иногда значение вложено; попытаемся взять common поле value + if s, ok2 := firstString(t, "value", "Value"); ok2 { return s, true } + } + } + } + return "", false +} + + diff --git a/src/collectors/gpu/gpu_unsupported.go b/src/collectors/gpu/gpu_unsupported.go new file mode 100644 index 0000000..8b353d4 --- /dev/null +++ b/src/collectors/gpu/gpu_unsupported.go @@ -0,0 +1,16 @@ +//go:build !linux + +package main + +// Автор: Сергей Антропов, сайт: https://devops.org.ru +// Заглушка для неподдерживаемых платформ: возвращает пустой массив gpu. + +import ( + "context" +) + +func collectGPU(ctx context.Context) (map[string]any, error) { + return map[string]any{"gpu": []any{}}, nil +} + + diff --git a/src/collectors/gpu/main.go b/src/collectors/gpu/main.go new file mode 100644 index 0000000..fb317dc --- /dev/null +++ b/src/collectors/gpu/main.go @@ -0,0 +1,48 @@ +package main + +// Автор: Сергей Антропов, сайт: https://devops.org.ru +// Коллектор gpu. Собирает информацию о видеокартах NVIDIA (через nvidia-smi) +// и AMD (через rocm-smi). Возвращает массив видеокарт под ключом "gpu". + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +// collectGPU реализуется платформенно. + +func main() { + // Таймаут можно переопределить окружением COLLECTOR_TIMEOUT + timeout := parseDurationOr("COLLECTOR_TIMEOUT", 8*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + data, err := collectGPU(ctx) + if err != nil || data == nil { + // Всегда возвращаем пустой блок gpu, если нет данных + fmt.Println("{\"gpu\":[]}") + return + } + // Если ключ gpu отсутствует, нормализуем к пустому массиву + if _, ok := data["gpu"]; !ok { + data["gpu"] = []any{} + } + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + _ = enc.Encode(data) +} + +// parseDurationOr парсит длительность из переменной окружения или возвращает дефолт +func parseDurationOr(env string, def time.Duration) time.Duration { + v := strings.TrimSpace(os.Getenv(env)) + if v == "" { return def } + d, err := time.ParseDuration(v) + if err != nil { return def } + return d +} + +