TL;DR

Мы пытались запустить LLM inference на старой AMD RX580 (8 VRAM) через ROCm в Kubernetes. GPU корректно определялся, VRAM использовалась, но inference падал с ошибками вида:

hipMemGetInfo(free, total) CUDA error: invalid argument

После серии экспериментов с ROCm userspace, Docker‑образами и Kubernetes deployment выяснилось, что проблема лежит на границе:

kernel → ROCm runtime → ggml backend

Финальное решение включало:

  • переход на kernel 6.8

  • стабилизацию ROCm runtime

  • использование llama.cpp + ROCm

  • grammar‑constrained decoding для strict sanity prompts

В итоге мы получили стабильный GPU inference:

  • ~42 токен/сек

  • gpu_busy_percent → до 100%

на обычной RX580.

Введение

Большинство гайдов по запуску LLM предполагают NVIDIA GPU и CUDA. Если у вас AMD, особенно старая карта вроде RX580 - готовьтесь к расследованию.

Большинство примеров и гайдов ориентированы на NVIDIA:

  • CUDA

  • TensorRT

  • готовые контейнеры и helm-чарты

С AMD всё сложнее. Основная экосистема строится вокруг ROCm, который:

  • Официально поддерживает ограниченный набор GPU, особенно старых, особенно старый ROCm

  • Часто имеет несовместимости на границе kernel / userspace

  • Хуже документирован для старых карт

При этом RX580 - одна из самых распространённых видеокарт:

  • дешёвая на вторичном рынке

  • 8GB VRAM

  • достаточная для небольших LLM

Контекст и цель

Задача была прикладной: получить стабильный GPU inference на AMD RX580 (gfx803) в Kubernetes-контуре. Казалось что задачу получится решить дефолтным образом, но... 

.. на практике упёрлись в ограничения совместимости.

Образ rocm/llama.cpp:llama.cpp-b6652.amd0_rocm7.0.0_ubuntu24.04_server даже не увидел gfx803. Workaround через HSA_OVERRIDE_GFX_VERSION не помог

ggml_cuda_init: failed to initialize ROCm: no ROCm-capable device is detected

Чтобы исключить догадки, диагностику вели послойно:

  • Helm/Argo-манифесты и корректность владения GPU через device plugin.системные и контейнерные логи;

  • runtime-ошибки (hipMemGetInfoloader failure, деградация качества до gibberish-output);

  • GPU-метрики (gpu_busy_percent, VRAM, температура, частоты);

Первая ипотеза: проблема в ROCm userspace

Мы предположили, что проблема может быть в userspace‑части ROCm. Попробовали альтернативный вариант - взять более "готовый" образ из гитхаба woodrex83/ROCm-For-RX580

GPU корректно определился:

library=rocm
compute=gfx803

Но ошибка hipMemGetInfo никуда не исчезла. Оно и понятно, поддержка этого семейства видеокарт прекратилась в ROCm 4.5

Поднимаем Ollama на ROCm

Первый шаг — убедиться, что контейнер видит GPU. В Kubernetes доступ к девайсам обеспечил AMD GPU Operator, в докере для дебага нужно смонтировать /dev/kfd,/dev/dri

Запускаем

docker run -d \
  --device /dev/kfd \
  --device /dev/dri \
  --group-add video \
  -e HSA_OVERRIDE_GFX_VERSION=8.0.3 \
  ollama:v0.1.24-rocm431

В логах Ollama мы увидели:

library=rocm compute=gfx803 name=1002:67df

Это означало, что ROCm успешно обнаружил GPU.

Вторая проблема проблема: GPU есть, inference падает

При запуске модели:

ollama run tinylama

Появлялась ошибка CUDA error: invalid argument hipMemGetInfo(free, total)

Интересно, что при этом:

  • pod был healthy

  • API отвечал

  • VRAM резервировалась

На первый взгляд система выглядела рабочей. Но inference либо падал, либо выдавал мусор. Это уже четко указывало на runtime-цепочку

kernel -> ROCm runtime -> ggml backend.

Следующая гипотеза: ggml runtime

Следующим подозреваемым стал runtime внутри inference backend.

Ollama использует ggml, который взаимодействует с ROCm через HIP. Но на этом этапе было непонятно — проблема в runtime или в устаревшем железе

Vulkan как диагностический инструмент

Чтобы проверить гипотезу, мы попробовали альтернативный backend llama.cpp + Vulkan

docker pull ghcr.io/ggml-org/llama.cpp:full-vulkan
docker run --rm -it \
  --privileged \
  --device /dev/dri:/dev/dri \
  -v /home/user/models:/models:ro \
  --entrypoint /app/llama-cli \
  ghcr.io/ggml-org/llama.cpp:full-vulkan \
  -m /models/tiny.gguf \
  -ngl 999 \
  -n 128 \
  -p "Write a long detailed story about space exploration."

Результат оказался неожиданным. Inference заработал с первого запуска. Это означало:

  1. GPU исправен

  2. Модель работает

  3. gglm не причем

Vulkan подтвердил, что проблема не в железе. После этого мы вернулись к ROCm и начали искать системные несовместимости.
Vulkan подтвердил, что проблема не в железе. После этого мы вернулись к ROCm и начали искать системные несовместимости.

Проверка userspace-образов ROCm и эксперименты с kernel

Мы проверили еще несколько вариантов userspace:

  • GPU по-прежнему детектился;

  • класс ошибок hipMemGetInfo не исчезал полностью;

  • часть симптомов менялась, но root cause не уходил.

Хост работал на: kernel 5.15.0-171

Симптомы:

  • ROCm видел GPU

  • runtime иногда падал при старте

Мы попробовали более новое ядро: 6.8.0–101 как проверка одной из гипотез совместимости версий kernel ↔ ROCm userspace ↔ ggml

После перезагрузки поведение изменилось радикально. Модель начала стабильно загружаться и выдавать токены.

Grammar‑constrained decoding

После стабилизации ROCm осталось узкое место: strict sanity-промпты вида:

  • Reply with exactly hi

  • What is 1+1? Reply with exactly 2

  • Say only the number 7

В обычном unconstrained-режиме модель не всегда отвечала точно (Hello1The, а то и ##### или G G G) — это была проблема декодирования/формата, а не GPU runtime.

Чтобы закрыть кейс, добавили грамматики на уровне декодера:

  • для hiroot ::= «hi»

  • для 2root ::= «2»

  • для 7root ::= «7»

И включили их в запросы к llama-server. Заработало. Это хороший диагностический инструмент, но плохой production-режим для обычной генерации.

После Grammar: снятие костыля и переход на нормальный профиль

Grammar жёстко ограничивает декодер и «прячет» часть поведенческих проблем, поэтому мы использовали его как контроль, а затем вернулись к unconstrained-декодингу и стабилизировали качество настройками.

Финальный профиль. Цель: убрать «почти правильные» ответы и снизить дрейф генерации без искусственных ограничений.

--n-gpu-layers 999
--ctx-size 2048
--batch-size 512
--ubatch-size 128
temperature=0
top_p=1
top_k=1
min_p=0
repeat_penalty=1.05
max_tokens=256-1024 #для рабочих запросов

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

Для API-сценариев со строгим парсингом используемjson schema (где это поддерживается).
Итого - строгие форматы решаются на уровне контракта ответа, а не форсированием каждого токена грамматикой.

Путаница с метриками (3 экспортера + CPU fallback)

Проблема была не только в inference, но и в наблюдаемости.

Мы одновременно работали с тремя источниками:

  1. default-metrics-exporter (от AMD GPU Operator - изначально показался наиболее логичным и prod-ready)

  2. radeon-exporter ( kmulvey/radeon_exporter:latest - в итоге именно он неизменно отдавал хоть и мало, но точных метрик)

  3. Грубый fallback на прямое чтение sysfs (/sys/class/drm/card*/device/gpu_busy_percent)

Вот такой дэшборд собрали перепробовав 3 разных экспортера
Вот такой дэшборд собрали перепробовав 3 разных экспортера

Cкрипт для настройки вентиляторов

Также выяснилось что стандартный драйвер видеокарты поддерживает управление вентиляторами только auto/manual

  • На auto температура улетала в небеса

  • На manual только фиксированное значение оборотов

-> Пользуемся знаниями теории управления и пишем простой PID-регулятор оборотов

Ниже минимальный script для ручного fan-control
#!/usr/bin/env python3
import glob
import os
import signal
import sys
import time
from dataclasses import dataclass


@dataclass
class Config:
    # Цель по температуре
    target_temp_c: float = 45.0

    # Температурные зоны
    idle_temp_c: float = 42.0
    warm_temp_c: float = 48.0
    hot_temp_c: float = 55.0
    very_hot_temp_c: float = 68.0
    emergency_temp_c: float = 75.0

    # PWM границы
    min_pwm: int = 88
    idle_pwm: int = 95
    base_pwm: int = 108
    max_pwm: int = 255
    emergency_pwm: int = 255

    # PID-подобные коэффициенты
    kp: float = 7.0
    ki: float = 0.05
    kd: float = 14.0

    # Упреждающая реакция на загрузку GPU
    busy_gain: float = 0.8
    busy_threshold: float = 5.0

    # Поведение
    interval_sec: float = 2.0
    hysteresis_c: float = 0.5

    # Ограничение изменения PWM за шаг
    max_pwm_step_up: int = 16
    max_pwm_step_down: int = 8

    # Чтобы не дёргать ШИМ по мелочи
    min_effective_pwm_delta: int = 2

    # Антивиндап
    integral_min: float = -250.0
    integral_max: float = 350.0

    # Если очень холодно и GPU почти не занят
    very_cool_temp_c: float = 38.0
    very_cool_busy_max: float = 10.0

    # Усиление реакции в горячих зонах
    hot_zone_boost_pwm: int = 12
    very_hot_zone_boost_pwm: int = 28


class AmdGpuFanController:
    def __init__(self, cfg: Config):
        self.cfg = cfg
        self.hwmon_path = self._find_hwmon()
        self.card_path = "/sys/class/drm/card0/device"

        self.temp_path = os.path.join(self.hwmon_path, "temp1_input")
        self.pwm_path = os.path.join(self.hwmon_path, "pwm1")
        self.pwm_enable_path = os.path.join(self.hwmon_path, "pwm1_enable")
        self.fan_rpm_path = os.path.join(self.hwmon_path, "fan1_input")
        self.gpu_busy_path = os.path.join(self.card_path, "gpu_busy_percent")

        self.integral = 0.0
        self.last_temp_c = None
        self.last_busy = None
        self.last_pwm = None
        self.running = True

    def _find_hwmon(self) -> str:
        # сначала старый путь (иногда используется)
        matches = glob.glob("/sys/class/drm/card0/device/hwmon/hwmon*")
        if matches:
            return matches[0]
    
        # стандартный путь через /sys/class/hwmon
        for path in glob.glob("/sys/class/hwmon/hwmon*"):
            try:
                with open(os.path.join(path, "name")) as f:
                    if f.read().strip() == "amdgpu":
                        return path
            except Exception:
                pass

        raise RuntimeError("amdgpu hwmon device not found")
    def _read_int(self, path: str, default: int = 0) -> int:
        try:
            with open(path, "r") as f:
                return int(f.read().strip())
        except Exception:
            return default

    def _write_int(self, path: str, value: int) -> None:
        with open(path, "w") as f:
            f.write(str(value))

    def read_temp_c(self) -> float:
        return self._read_int(self.temp_path) / 1000.0

    def read_pwm(self) -> int:
        return self._read_int(self.pwm_path)

    def read_rpm(self) -> int:
        return self._read_int(self.fan_rpm_path, default=-1)

    def read_gpu_busy(self) -> float:
        return float(self._read_int(self.gpu_busy_path, default=0))

    def set_manual_mode(self) -> None:
        self._write_int(self.pwm_enable_path, 1)

    def set_auto_mode(self) -> None:
        self._write_int(self.pwm_enable_path, 2)

    def set_pwm(self, pwm: int) -> None:
        pwm = max(self.cfg.min_pwm, min(self.cfg.max_pwm, int(round(pwm))))
        self._write_int(self.pwm_path, pwm)
        self.last_pwm = pwm

    @staticmethod
    def clamp(value: float, lo: float, hi: float) -> float:
        return max(lo, min(hi, value))

    def rate_limit_pwm(self, target_pwm: int) -> int:
        if self.last_pwm is None:
            return target_pwm
        if target_pwm > self.last_pwm:
            return min(target_pwm, self.last_pwm + self.cfg.max_pwm_step_up)
        return max(target_pwm, self.last_pwm - self.cfg.max_pwm_step_down)

    def compute_target_pwm(self, temp_c: float, busy: float, dtemp_dt: float) -> int:
        if temp_c >= self.cfg.emergency_temp_c:
            self.integral = 0.0
            return self.cfg.emergency_pwm

        if temp_c <= self.cfg.very_cool_temp_c and busy <= self.cfg.very_cool_busy_max:
            self.integral *= 0.85
            return self.cfg.idle_pwm

        error = temp_c - self.cfg.target_temp_c
        effective_error = 0.0 if abs(error) < self.cfg.hysteresis_c else error

        self.integral += effective_error * self.cfg.interval_sec
        self.integral = self.clamp(
            self.integral,
            self.cfg.integral_min,
            self.cfg.integral_max,
        )

        busy_term = 0.0
        if busy > self.cfg.busy_threshold:
            busy_term = (busy - self.cfg.busy_threshold) * self.cfg.busy_gain

        pwm = (
            self.cfg.base_pwm
            + self.cfg.kp * effective_error
            + self.cfg.ki * self.integral
            + self.cfg.kd * dtemp_dt
            + busy_term
        )

        if temp_c >= self.cfg.hot_temp_c:
            pwm += self.cfg.hot_zone_boost_pwm
        if temp_c >= self.cfg.very_hot_temp_c:
            pwm += self.cfg.very_hot_zone_boost_pwm

        if temp_c >= self.cfg.warm_temp_c and busy >= 40:
            pwm = max(pwm, self.cfg.base_pwm + 18)

        if temp_c <= self.cfg.idle_temp_c and busy < 20:
            pwm = min(pwm, self.cfg.idle_pwm + 8)

        return int(round(self.clamp(pwm, self.cfg.min_pwm, self.cfg.max_pwm)))

    def control_step(self) -> None:
        temp_c = self.read_temp_c()
        rpm = self.read_rpm()
        busy = self.read_gpu_busy()

        if self.last_pwm is None:
            self.last_pwm = self.read_pwm()

        if self.last_temp_c is None:
            dtemp_dt = 0.0
        else:
            dtemp_dt = (temp_c - self.last_temp_c) / self.cfg.interval_sec

        target_pwm = self.compute_target_pwm(temp_c=temp_c, busy=busy, dtemp_dt=dtemp_dt)
        limited_pwm = self.rate_limit_pwm(target_pwm)

        if self.last_pwm is None or abs(limited_pwm - self.last_pwm) >= self.cfg.min_effective_pwm_delta:
            self.set_pwm(limited_pwm)
        else:
            limited_pwm = self.last_pwm

        error = temp_c - self.cfg.target_temp_c

        print(
            f"temp={temp_c:5.1f}C "
            f"busy={busy:5.1f}% "
            f"rpm={rpm:4d} "
            f"err={error:+5.1f} "
            f"dT/dt={dtemp_dt:+5.2f}C/s "
            f"int={self.integral:+7.1f} "
            f"pwm={limited_pwm:3d}",
            flush=True,
        )

        self.last_temp_c = temp_c
        self.last_busy = busy

    def run(self) -> None:
        self.set_manual_mode()

        if self.last_pwm is None:
            try:
                self.last_pwm = self.read_pwm()
            except Exception:
                self.last_pwm = self.cfg.base_pwm
                self.set_pwm(self.last_pwm)

        print(f"Using hwmon path: {self.hwmon_path}")
        print("Manual fan control enabled.")
        print(
            f"Target={self.cfg.target_temp_c}C, "
            f"idle={self.cfg.idle_temp_c}C, "
            f"warm={self.cfg.warm_temp_c}C, "
            f"hot={self.cfg.hot_temp_c}C, "
            f"very_hot={self.cfg.very_hot_temp_c}C, "
            f"emergency={self.cfg.emergency_temp_c}C",
            flush=True,
        )

        while self.running:
            self.control_step()
            time.sleep(self.cfg.interval_sec)

    def stop(self, restore_auto: bool = True) -> None:
        self.running = False
        if restore_auto:
            try:
                self.set_auto_mode()
                print("Restored automatic fan control.", flush=True)
            except Exception as e:
                print(f"Failed to restore auto mode: {e}", file=sys.stderr, flush=True)


def main() -> int:
    cfg = Config()
    ctl = AmdGpuFanController(cfg)

    def _handle_signal(signum, frame):
        ctl.stop(restore_auto=True)
        raise SystemExit(0)

    signal.signal(signal.SIGINT, _handle_signal)
    signal.signal(signal.SIGTERM, _handle_signal)

    try:
        ctl.run()
        return 0
    except KeyboardInterrupt:
        ctl.stop(restore_auto=True)
        return 0
    except Exception as e:
        print(f"Fatal error: {e}", file=sys.stderr, flush=True)
        ctl.stop(restore_auto=True)
        return 1


if __name__ == "__main__":
    sys.exit(main())
# Manual fan control enabled.
temp= 64.0C rpm=2275 err=+34.0 dT/dt=+0.50C/s pwm=180
temp= 63.0C rpm=2448 err=+33.0 dT/dt=-0.50C/s pwm=198
temp= 63.0C rpm=2593 err=+33.0 dT/dt=+0.00C/s pwm=216
temp= 63.0C rpm=2735 err=+33.0 dT/dt=+0.00C/s pwm=234
temp= 63.0C rpm=2937 err=+33.0 dT/dt=+0.00C/s pwm=255
temp= 61.0C rpm=2937 err=+31.0 dT/dt=-1.00C/s pwm=255
#температура падает     <--            вентиляторы растут
Слева - обороты и момент включения скрипта. Справа - температура и ее падение ниже порога алерта
Слева - обороты и момент включения скрипта. Справа - температура и ее падение ниже порога алерта

Производительность

На длинной генерации удалось получить ~42 токен/сек для модели: Ministral 3B Q6_K

Финальная рабочая конфигурация

Host

  • OS: Ubuntu 22.04

  • kernel: 6.8.0-101-generic

  • GPU: AMD RX580 (gfx803)

Kubernetes (финальный ROCm профиль)

  • image: rocm/llama.cpp:llama.cpp-b6356_rocm6.4.3_ubuntu24.04_server

  • model: Ministral-3b-instruct.Q6_K.gguf

Ключевые env:

HSA_OVERRIDE_GFX_VERSION=8.0.3
HIP_VISIBLE_DEVICES=0
ROCR_VISIBLE_DEVICES=0
GPU_MAX_HW_QUEUES=1
LD_LIBRARY_PATH= #с путями ROCm runtime

Заключение

RX580 — не самая очевидная карта для LLM. Но наш эксперимент показывает:

Даже старая RX580 способна запускать современные LLM. И даже в k8s окружении.

Главное — понимать границы совместимости ROCm и внимательно диагностировать каждый слой системы.