Введение

Всем привет! Сегодня я расскажу о своем пути к написанию демона для работы с видеокартами (пока только от 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