UnixGPUForge, или Как я решил написать свой демон для работы с видеокартами для Linux

от автора

Введение

Всем привет! Сегодня я расскажу о своем пути к написанию демона для работы с видеокартами (пока только от Nvidia).

Для начала немного предыстории о себе. Для меня Linux — не новинка, но и не та ОС, с которой я долго взаимодействовал в качестве десктопа. Я разработчик с коммерческим стажем в финтехе, и с Linux ранее был знаком только как с очень практичной серверной ОС. Но однажды я стал полноценным «линуксоидом», решив перейти к более гибкой в настройке и кастомизации системе. Я выбрал дистрибутив на основе Arch, а именно CachyOS. И, как и многие, при наличии свободного времени я люблю поиграть в игры.

Моя боль как любителя поиграть

Я столкнулся с проблемой: мне нередко приходится настраивать переменные окружения через Goverlay, Nvidia X Server (nvidia‑smi) и пихать это все в команды запуска в свойствах Steam. Либо использовать сторонние лаунчеры по типу Heroic или Lutris и делать схожие настройки там. Как бывшему пользователю Windows, мне захотелось как‑то упростить для себя процесс настройки видеокарты, запуска игр и подбора оптимальных параметров для Linux‑гейминга.

Мне известны аппки по типу GreenWithEnvy, LACT или CoreCtrl. Но с переходом на Wayland могут всплыть нюансы вроде жестких привязок к X11. Я хотел иметь под рукой что‑то вроде MSI Afterburner или даже Nvidia App на Windows, чтобы можно было настраивать такие параметры, как:

  • Какую модель DLSS использовать.

  • Настройки на уровне драйвера без танцев с бубном

  • Применение некоторых возможностей на уровне драйвера вне зависимости от наличия поддержки функционала в самой игре.

  • Решение проблем с простоями, а именно — излишнего потребления питания и работы кулеров без нагрузки (на Reddit под моим постом писали о кейсах с Intel, когда даже в простое вентиляторы могли постоянно включаться и выключаться).

Также хотелось, отчасти как и в LACT, сделать поддержку нескольких брендов (замечал, что там были Nvidia и Intel). Я хочу объединить в одном приложении все три бренда. Но с учетом того, что библиотеки Nvidia сразу имеют наборы методов для работы с картами не только на обычных юзерских ПК, но и в крупных дата‑центрах, со временем я хочу попробовать сделать что‑то ультимативное.

Архитектура и выбор стека

Не буду скрывать: при написании применялся ИИ для ускорения импорта методов из библиотеки «зеленых» по шаблонам, которые я написал сам. Изначально либка написана под C++, а я пишу на C#. Также я применяю ИИ во фронтенде, в котором не силен, потому что привык работать с бэкендом и не имею достаточно навыков для написания красивого UI. При этом некоторые вещи так или иначе делаю сам. И да, поначалу мой репозиторий в Git был откровенно мусорным — имея основную работу и семью, не всегда находишь силы и время на то, чтобы всё вычистить и аккуратно выложить =).

Если вы видели пост на Reddit об обнове, то могли заметить и гневные негативные комменты о проекте и применении нейросетей в процессе написания кода. Заранее скажу: хейтеры ИИ‑инструментов, я люблю и ценю вас, ведь благодаря вам посты чаще уходят в массы! Вайб‑кодинг — это неплохо (если подходить с умом), но ИИ не должен полностью заменять человека. Нельзя деградировать и опускаться до тупейшего скармливания небольших инструкций и вечных правок кода, написанного через один ИИ с помощью другого.

Итак, возвращаясь непосредственно к самому пет‑проекту и его технической части: ниже скриншот того, как выглядит UI сейчас.

В данном проекте я решил сделать UI с помощью Vue.js. План был простым: запускать аппку и внутри отрисовывать HTML с применением Photino.NET. Ниже — пример того, как это будет преобразовываться из веб‑версии (HTML, CSS, Vue.js) в нативные окна. Окна создаются через GTK3/GTK4, которые отлично работают в Wayland. Photino встраивает в окно очень легкий движок WebKitGTK и загружает туда статичные файлы Vue (понятное дело, сбилженные в маленькую папку типа dist с index.html и парой JS‑файлов).

using PhotinoNET;class Program{    [STAThread]    static void Main(string[] args)    {        // Создаем нативное окно        var window = new PhotinoWindow()            .SetTitle("UnixGPUForge")            .SetSize(800, 600)            // Загружаем наши скомпилированные Vue-файлы            .Load("ui/index.html");         window.WaitForClose();    }}

Любой, кто откроет проект, спросит, почему я выбрал.NET. Отвечу: C#.NET компилируется в нативный AOT‑бинарник, потребление памяти минимально, плюс очень удобно асинхронно работать с потоками I/O (что критично при работе с датчиками температуры, потребления и так далее).

Для работы с видеокартами и с заделом на будущее я строил архитектуру абстрактно, так как планирую поддержку карт не только от Nvidia, но и от AMD и Intel. Я накидал интерфейс IGpuProvider, в котором нужно реализовывать базовые методы получения телеметрии и стейтов.

Как я писал взаимодействие с NVML

Самое главное при работе с Nvidia у меня происходит в провайдере NvidiaGpuProvider. В нем идет импорт NVML — так я работаю с картой напрямую и избавляюсь от необходимости парсить выводы консоли nvidia-smi.

Ниже выложен полный код провайдера (последней версии на момент написания статьи).
using System.Runtime.InteropServices;using UnixGPUForge.Daemon.Core.Interfaces;using UnixGPUForge.Shared.Models;namespace UnixGPUForge.Daemon.Core.Providers;public partial class NvidiaGpuProvider : IGpuProvider, IDisposable{    private const string NvmlLibrary = "libnvidia-ml.so";    private readonly IntPtr _deviceHandle;    [StructLayout(LayoutKind.Sequential)]    private struct nvmlUtilization_t    {        public uint gpu;        public uint memory;    }    [StructLayout(LayoutKind.Sequential)]    private struct nvmlMemory_t    {        public ulong total;        public ulong free;        public ulong used;    }    [StructLayout(LayoutKind.Sequential)]    private struct nvmlProcessInfo_t    {        public uint pid;        public ulong usedGpuMemory;        public uint gpuInstanceId;        public uint computeInstanceId;    }    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlInit_v2")]    private static partial int NvmlInit();    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlShutdown")]    private static partial int NvmlShutdown();    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetHandleByIndex_v2")]    private static partial int NvmlDeviceGetHandleByIndex(uint index, out IntPtr device);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetName", StringMarshalling = StringMarshalling.Utf8)]    private static partial int NvmlDeviceGetName(IntPtr device, IntPtr name, uint length);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetTemperature")]    private static partial int NvmlDeviceGetTemperature(IntPtr device, uint sensorType, out uint temp);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetPowerUsage")]    private static partial int NvmlDeviceGetPowerUsage(IntPtr device, out uint power);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetUtilizationRates")]    private static partial int NvmlDeviceGetUtilizationRates(IntPtr device, out nvmlUtilization_t utilization);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetMemoryInfo")]    private static partial int NvmlDeviceGetMemoryInfo(IntPtr device, out nvmlMemory_t memory);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetPowerManagementLimitConstraints")]    private static partial int NvmlDeviceGetPowerManagementLimitConstraints(IntPtr device, out uint minLimit, out uint maxLimit);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceSetPowerManagementLimit_v2")]    private static partial int NvmlDeviceSetPowerManagementLimit(IntPtr device, uint limit);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceSetGpuLockedClocks")]    private static partial int NvmlDeviceSetGpuLockedClocks(IntPtr device, uint minGpuClockMHz, uint maxGpuClockMHz);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceResetGpuLockedClocks")]    private static partial int NvmlDeviceResetGpuLockedClocks(IntPtr device);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceSetMemoryLockedClocks")]    private static partial int NvmlDeviceSetMemoryLockedClocks(IntPtr device, uint minMemClockMHz, uint maxMemClockMHz);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceResetMemoryLockedClocks")]    private static partial int NvmlDeviceResetMemoryLockedClocks(IntPtr device);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetGraphicsRunningProcesses_v3")]    private static partial int NvmlDeviceGetGraphicsRunningProcesses(IntPtr device, ref uint infoCount, [Out] nvmlProcessInfo_t[] infos);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetComputeRunningProcesses_v3")]    private static partial int NvmlDeviceGetComputeRunningProcesses(IntPtr device, ref uint infoCount, [Out] nvmlProcessInfo_t[] infos);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceSetFanSpeed_v2")]    private static partial int NvmlDeviceSetFanSpeed(IntPtr device, uint fan, uint speed);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceSetDefaultFanSpeed_v2")]    private static partial int NvmlDeviceSetDefaultFanSpeed(IntPtr device, uint fan);    [LibraryImport(NvmlLibrary, EntryPoint = "nvmlDeviceGetNumFans")]    private static partial int NvmlDeviceGetNumFans(IntPtr device, out uint numFans);    public NvidiaGpuProvider()    {        if (NvmlInit() != 0)            throw new Exception("Не удалось инициализировать NVML драйвер.");        if (NvmlDeviceGetHandleByIndex(0, out _deviceHandle) != 0)            throw new Exception("Видеокарта NVIDIA не найдена.");    }    public GpuTelemetry GetTelemetry()    {        IntPtr namePtr = Marshal.AllocHGlobal(96);        string gpuName = "Unknown GPU";        if (NvmlDeviceGetName(_deviceHandle, namePtr, 96) == 0)        {            gpuName = Marshal.PtrToStringUTF8(namePtr) ?? gpuName;        }        Marshal.FreeHGlobal(namePtr);        NvmlDeviceGetTemperature(_deviceHandle, 0, out uint temp);        NvmlDeviceGetPowerUsage(_deviceHandle, out uint powerMw);        NvmlDeviceGetUtilizationRates(_deviceHandle, out nvmlUtilization_t util);        NvmlDeviceGetMemoryInfo(_deviceHandle, out nvmlMemory_t mem);        NvmlDeviceGetPowerManagementLimitConstraints(_deviceHandle, out uint minLimitMw, out uint maxLimitMw);        return new GpuTelemetry        {            Name = gpuName,            Temperature = temp,            PowerUsage = powerMw / 1000,            CoreLoad = util.gpu,            VramUsed = mem.used / (1024 * 1024),            VramTotal = mem.total / (1024 * 1024),            MinLimit = minLimitMw / 1000,            MaxLimit = maxLimitMw / 1000        };    }    public bool SetPowerLimit(uint watts)    {        return NvmlDeviceSetPowerManagementLimit(_deviceHandle, watts * 1000) == 0;    }    public bool SetGpuLockedClocks(uint minMHz, uint maxMHz)    {        return NvmlDeviceSetGpuLockedClocks(_deviceHandle, minMHz, maxMHz) == 0;    }    public bool ResetGpuLockedClocks()    {        return NvmlDeviceResetGpuLockedClocks(_deviceHandle) == 0;    }    public bool SetMemoryLockedClocks(uint minMHz, uint maxMHz)    {        return NvmlDeviceSetMemoryLockedClocks(_deviceHandle, minMHz, maxMHz) == 0;    }    public bool ResetMemoryLockedClocks()    {        return NvmlDeviceResetMemoryLockedClocks(_deviceHandle) == 0;    }    public bool SetFanSpeed(uint fanIndex, uint speedPercent)    {        int result = NvmlDeviceSetFanSpeed(_deviceHandle, fanIndex, speedPercent);        if (result != 0)        {            Console.WriteLine($"[NVML] Ошибка управления винтом {fanIndex}. Код ошибки NVML: {result}");            return false;        }        return true;    }    public bool ResetFanSpeed(uint fanIndex)    {        return NvmlDeviceSetDefaultFanSpeed(_deviceHandle, fanIndex) == 0;    }    public uint GetFanCount()    {        if (NvmlDeviceGetNumFans(_deviceHandle, out uint fanCount) == 0)        {            return fanCount;        }                return 2;    }    public List<uint> GetRunningPids()    {        var pids = new HashSet<uint>();        var dummy = Array.Empty<nvmlProcessInfo_t>();        uint graphicsCount = 0;        int resGraphics = NvmlDeviceGetGraphicsRunningProcesses(_deviceHandle, ref graphicsCount, dummy);        if (resGraphics == 7 && graphicsCount > 0)        {            var graphicsInfos = new nvmlProcessInfo_t[graphicsCount];            if (NvmlDeviceGetGraphicsRunningProcesses(_deviceHandle, ref graphicsCount, graphicsInfos) == 0)            {                foreach (var info in graphicsInfos) pids.Add(info.pid);            }        }        uint computeCount = 0;        int resCompute = NvmlDeviceGetComputeRunningProcesses(_deviceHandle, ref computeCount, dummy);        if (resCompute == 7 && computeCount > 0)        {            var computeInfos = new nvmlProcessInfo_t[computeCount];            if (NvmlDeviceGetComputeRunningProcesses(_deviceHandle, ref computeCount, computeInfos) == 0)            {                foreach (var info in computeInfos) pids.Add(info.pid);            }        }        return pids.ToList();    }    public void Dispose()    {        NvmlShutdown();        GC.SuppressFinalize(this);    }}

Есть один нюанс при работе с кулерами. Иногда проприетарный драйвер Nvidia может не отдать управление вентиляторами. Для перехвата контроля надо передавать параметры Coolbits в конфиг X‑сервера, но в Wayland это штатно не работает (либо работает кривовато — до конца пока не разобрался). Окончательного решения я еще не принял, но вижу такие варианты: делать внутри демона прямое взаимодействие с интерфейсом I2C видеокарты или использовать низкоуровневые обертки над NVML с API через P/Invoke, чтобы форсировать PWM (ШИМ‑сигнал) вентиляторов в обход дисплейного сервера.

Попытки сделать профилирование

Также я пробую реализовать автопрофилирование для игр. В будущем хочу научиться инжектить переменные в рантайм автоматически, чтобы не прописывать их руками (как я сейчас делаю через Goverlay, формируя конфиг и вставляя его в Steam как команду запуска).

Но это планы. Сейчас профайлер помогает не выставлять вручную настройки карты для управления лимитами питания и частотами ядра/памяти, сохраняя конфиг в JSON.

Код профайлера
using System.Diagnostics;using System.Text.Json;using UnixGPUForge.Daemon.Core.Interfaces;using UnixGPUForge.Shared.Models;namespace UnixGPUForge.Daemon.Core.Services;public class AutoProfileService : BackgroundService{    private readonly IGpuProvider _gpu;    private readonly ILogger<AutoProfileService> _logger;    private List<GameProfile> _profiles = new();    private string? _activeProfileName = null;    private static readonly object _nvmlLock = new();    public AutoProfileService(IGpuProvider gpu, ILogger<AutoProfileService> logger)    {        _gpu = gpu;        _logger = logger;        LoadProfiles();    }    private void LoadProfiles()    {        try        {            if (File.Exists("profiles.json"))            {                string json = File.ReadAllText("profiles.json");                _profiles = JsonSerializer.Deserialize<List<GameProfile>>(json) ?? new List<GameProfile>();                _logger.LogInformation($"Загружено {_profiles.Count} профилей автоматизации.");            }            else            {                // Генерируем дефолтный шаблон                _profiles = new List<GameProfile>                {                    new() { ProcessName = "Cyberpunk2077", PowerLimit = 300, CoreClock = 2650, MemoryClock = 11200 },                    new() { ProcessName = "dota2", PowerLimit = 150, CoreClock = 1800, MemoryClock = 10000 },                    new() { ProcessName = "HuntGame", PowerLimit = 280, CoreClock = 2500, MemoryClock = 11200 },                    new() { ProcessName = "bg3", PowerLimit = 200, CoreClock = 2000, MemoryClock = 10500 }                };                File.WriteAllText("profiles.json", JsonSerializer.Serialize(_profiles, new JsonSerializerOptions { WriteIndented = true }));            }        }        catch (Exception ex)        {            _logger.LogError($"Ошибка загрузки profiles.json: {ex.Message}");        }    }    protected override async Task ExecuteAsync(CancellationToken stoppingToken)    {        while (!stoppingToken.IsCancellationRequested)        {            try            {                LoadProfiles();                var pids = _gpu.GetRunningPids();                GameProfile? matchedProfile = null;                foreach (var pid in pids)                {                    try                    {                        var process = Process.GetProcessById((int)pid);                        string procName = process.ProcessName.Replace(".exe", "", StringComparison.OrdinalIgnoreCase);                                                matchedProfile = _profiles.FirstOrDefault(p =>                             procName.Contains(p.ProcessName.Replace(".exe", ""), StringComparison.OrdinalIgnoreCase));                                                if (matchedProfile != null) break;                    }                    catch (Exception ex)                    {                        _logger.LogError($"Ошибка получения информации о процессе с PID {pid}: {ex.Message}");                    }                }                if (matchedProfile != null && _activeProfileName != matchedProfile.ProcessName)                {                    _logger.LogInformation($"[AUTO] Обнаружена игра: {matchedProfile.ProcessName}. Применяем настройки железа...");                    lock (_nvmlLock)                    {                    _gpu.SetPowerLimit(matchedProfile.PowerLimit);                    _gpu.SetGpuLockedClocks(200, matchedProfile.CoreClock);                    _gpu.SetMemoryLockedClocks(400, matchedProfile.MemoryClock);                    }                    _activeProfileName = matchedProfile.ProcessName;                }                else if (matchedProfile == null && _activeProfileName != null)                {                    _logger.LogInformation("[AUTO] Игра закрыта. Возврат к дефолтным частотам...");                    _gpu.ResetGpuLockedClocks();                    _gpu.ResetMemoryLockedClocks();                                        _activeProfileName = null;                }            }            catch (Exception ex)            {                _logger.LogError($"Ошибка мониторинга процессов: {ex.Message}");            }            await Task.Delay(5000, stoppingToken);        }    }}

Мои заключения

Сейчас я думаю над тем, как сделать все максимально стабильным. Хочется внедрить такие фишки, как выбор предустановок DLSS у Nvidia (что на Windows легко настраивается через Nvidia App), и в целом добавить весь набор настроек, доступный пользователям «окон». Считаю, что это поможет сделать Linux более дружелюбным к играм.

Хотя сейчас с геймингом проблем всё меньше, многие переходят на Linux именно за производительностью, и удивляются (по крайней мере, в моем окружении), что не могут настраивать железо так же гибко, как на Windows. Несмотря на море открытого софта в любом направлении, далеко не все пользователи знакомы со специфичными полезными утилитами для Linux.

Ниже оставляю ссылку на GitHub для тех, кому стал интересен проект. Я открыт к критике и советам. Спасибо за выделенное на прочтение статьи время, и прошу не судить строго — это мой первый опыт написания подобных материалов.

https://github.com/omaeou/UnixGPUForge

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