Запускаем LLM на AMD RX580: разбор проблем ROCm, Ollama и реальный GPU inference. amd.. amd. docker.. amd. docker. GPGPU.. amd. docker. GPGPU. k8s.. amd. docker. GPGPU. k8s. legacy.. amd. docker. GPGPU. k8s. legacy. llama.cpp.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops. ollama.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops. ollama. Radeon.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops. ollama. Radeon. rx 580.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops. ollama. Radeon. rx 580. искусственный интеллект.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops. ollama. Radeon. rx 580. искусственный интеллект. Отладка.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops. ollama. Radeon. rx 580. искусственный интеллект. Отладка. Программирование.. amd. docker. GPGPU. k8s. legacy. llama.cpp. llm. mlops. ollama. Radeon. rx 580. искусственный интеллект. Отладка. Программирование. Старое железо.
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 и внимательно диагностировать каждый слой системы.

Автор: alexkekiy

Источник

Rambler's Top100