UnixGPUForge: разработка собственного Linux-демона для управления видеокартами
Введение
Приветствую! В этой статье я хочу поделиться опытом разработки системного демона, предназначенного для мониторинга и управления видеокартами под управлением Linux (на текущем этапе реализована поддержка адаптеров от Nvidia).
Несколько слов о моём бэкграунде. Linux для меня — система не новая, однако долгое время моё взаимодействие с ней ограничивалось серверной инфраструктурой в рамках коммерческой разработки для финтеха. Со временем я решил перейти на Linux и в качестве основной десктопной платформы, выбрав гибкий Arch-based дистрибутив CachyOS. Как и многие разработчики, в часы досуга я люблю проводить время за видеоиграми.
Проблематика: с чем сталкивается геймер на Linux
Главным источником неудобств стала необходимость регулярной ручной конфигурации: проброс переменных окружения через Goverlay, постоянные манипуляции с Nvidia X Server и nvidia-smi, а также перегруженные строки параметров запуска внутри Steam. Использование альтернативных лаунчеров вроде Heroic или Lutris требовало аналогичных повторяющихся действий. Как человеку, привыкшему к экосистеме Windows, мне захотелось максимально упростить подготовку системы к запуску игр и автоматизировать тюнинг видеокарты.
Безусловно, в open-source сегменте уже существуют решения вроде GreenWithEnvy, LACT или CoreCtrl. Однако с постепенной миграцией графического стека на Wayland многие утилиты сталкиваются с ограничениями из-за жесткой привязки к X11. Моей целью было получить гибкий инструмент уровня MSI Afterburner или современного Nvidia App, способный обеспечить:
-
Выбор и переопределение пресетов и моделей DLSS;
-
Быстрое применение глобальных параметров видеодрайвера без ручного редактирования конфигов;
-
Форсирование функций на уровне драйвера, даже если игра нативно их не поддерживает;
-
Устранение проблем энергоэффективности в режиме простоя (включая неадекватное поведение системы охлаждения — например, циклические включения вентиляторов без нагрузки, что часто встречается и на картах Intel).
Кроме того, в архитектуру изначально закладывается универсальность: объединить под одним капотом адаптеры Nvidia, AMD и Intel. А поскольку API от Nvidia предоставляет богатый инструментарий для управления не только потребительскими, но и серверными ускорителями, в перспективе проект может вырасти в комплексное решение для самых разных сценариев.
Архитектура и выбор стека
Не стану скрывать: для ускорения рутинных операций (например, генерации C#-биндингов к нативной C++ библиотеке Nvidia по заранее подготовленным шаблонам) я активно задействовал нейросетевые инструменты. Аналогично ИИ помог в проектировании фронтенда — будучи бэкендером, я предпочитаю делегировать создание визуальной части. Разумеется, архитектура, интеграция и контроль логики остаются за мной. Первоначальное состояние репозитория было далеко от идеала, ведь при наличии основной занятости и семейных задач не всегда удаётся сразу оформить кодовую базу начисто.
Реакция сообщества на Reddit на использование LLM была неоднозначной — встречался и откровенный хейт. Однако конструктивная критика и внимание аудитории лишь помогают продвижению. Разумное использование ИИ-ассистентов ускоряет разработку, главное — не превращать процесс в слепую генерацию неконтролируемого кода без понимания фундаментальных принципов.
Что касается технической реализации, текущее состояние пользовательского интерфейса представлено на скриншоте ниже.
Для построения GUI был выбран стек Vue.js в связке с легковесным фреймворком Photino.NET. Архитектура проста: нативный оконный контейнер на базе GTK3/GTK4 (обеспечивающий превосходную совместимость с Wayland) инициализирует компонент WebKitGTK, внутри которого рендерится скомпилированное SPA-приложение.
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 обусловлен возможностью сборки в нативный бинарник через Native AOT, минимальным потреблением оперативной памяти и развитой моделью асинхронного ввода-вывода, что крайне важно для непрерывного опроса телеметрии и аппаратных сенсоров.
Для обеспечения масштабируемости уровень взаимодействия с оборудованием абстрагирован через интерфейс IGpuProvider, описывающий базовые контракты снятия метрик и смены состояний.
Реализация взаимодействия с NVML
Ключевая логика взаимодействия с чипами NVIDIA сосредоточена в классе NvidiaGpuProvider. Вместо ресурсоёмкого парсинга вывода утилиты nvidia-smi работа осуществляется напрямую через биндинги к динамической библиотеке NVML (libnvidia-ml.so).
Исходный код провайдера NVIDIA (актуальная версия)
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 GetRunningPids()
{
var pids = new HashSet();
var dummy = Array.Empty();
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);
}
}
Специфическая сложность возникла с управлением кулерами: проприетарный драйвер нередко блокирует прямое регулирование скорости. В классическом X11 для этого требовалось включение опции Coolbits, однако под сессией Wayland данный механизм функционирует нестабильно. В качестве альтернатив рассматриваются прямая работа с шиной I2C адаптера либо вызов низкоуровневых интерфейсов NVML через P/Invoke для принудительной установки ШИМ-скважности в обход графического сервера.
Автоматическое профилирование
Параллельно ведётся работа над модулем автоматического применения профилей при запуске конкретных игровых процессов. В идеале планируется реализовать прозрачную динамическую инъекцию параметров среды выполнения, исключающую ручное заполнение аргументов в Steam.
На данный момент профайлер уже способен отслеживать активные процессы и автоматически корректировать Power Limit, а также фиксировать тактовые частоты чипа и видеопамяти на основе конфигураций в 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 _logger;
private List _profiles = new();
private string? _activeProfileName = null;
private static readonly object _nvmlLock = new();
public AutoProfileService(IGpuProvider gpu, ILogger logger)
{
_gpu = gpu;
_logger = logger;
LoadProfiles();
}
private void LoadProfiles()
{
try
{
if (File.Exists("profiles.json"))
{
string json = File.ReadAllText("profiles.json");
_profiles = JsonSerializer.Deserialize>(json) ?? new List();
_logger.LogInformation($"Загружено {_profiles.Count} профилей автоматизации.");
}
else
{
// Генерируем дефолтный шаблон
_profiles = new List
{
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 и расширение настроек до паритета с инструментами под Windows. Подобные решения призваны снизить порог входа и сделать гейминг на Linux более комфортным для широкого круга пользователей.
Хотя игровой сегмент под Linux развивается впечатляющими темпами, многие пользователи, переходящие ради производительности, всё ещё сетуют на дефицит привычных утилит комплексного управления «железом» из коробки.
Исходный код проекта опубликован на GitHub. Буду рад конструктивным предложениям, pull request’ам и замечаниям сообщества.
Пиксельные блокбастеры 80-х: взлёт игр по мотивам кино на ZX Spectrum, Commodore и Atari
Показаны первые элементы обложки книги «Heroes of Might and Magic. Пошаговая история бессмертной серии»
Почему первый Quake остается актуальным даже спустя 30 лет?
Sony назвала дату публикации рецензий на Marvel’s Wolverine
Авторы «аниме-GTA» Ananta представили новый трейлер и назвали дату выхода
ASUS назвала стоимость портативной консоли ROG Xbox Ally X20 в $1300
Объявлена дата выхода крупного графического обновления для 007 First Light
Petit Planet от HoYoverse выйдет зимой 2026 года — авторы опубликовали новый ролик