Читать книгу «Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя (Алексей Примак) онлайн бесплатно на Bookz (2-ая страница книги)
«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя
«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя
Оценить:

4

Полная версия:

«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя

Vulkan SDK от LunarG — это заголовки (включая Vulkan-Hpp), слои валидации, компилятор шейдеров slangc, утилиты vulkaninfo и vkconfig. Драйвер видеокарты SDK не содержит: он приходит от NVIDIA, AMD, Intel или Mesa.

• Windows: установщик с vulkan.lunarg.com (https://vulkan.lunarg.com). Он сам пропишет переменную VULKAN_SDK.

• Linux: архив SDK с того же сайта, распаковать и выполнить source setup-env.sh (лучше дописать в ~/.bashrc). Драйверы Mesa и пакет загрузчика libvulkan1 ставятся из репозитория дистрибутива.

• macOS: SDK содержит MoltenVK — прослойку Vulkan поверх Metal. Работает, но с нюансами (portability, глава 2).

Проверка в терминале:





В выводе vulkaninfo ищи строку apiVersion у своей видеокарты. Нужно 1.3 или выше. Если там 1.2 — обнови драйвер, это лечит в 90% случаев. Заодно поставь RenderDoc (https://renderdoc.org): он понадобится, когда на экране окажется чёрный прямоугольник. А он окажется.

1.2. Структура проекта




Зависимости тянем через vcpkg (https://vcpkg.io) в режиме манифеста. Клонируешь vcpkg, запускаешь bootstrap-скрипт и прописываешь переменную VCPKG_ROOT. Дальше всё делает CMake.





Пока нам нужны только GLFW и GLM. Остальное пригодится в главах 10 (VMA), 13 (stb_image), 14 (fastgltf) и 29 (Tracy). Vulkan-Hpp берём из SDK, а не из vcpkg: так версии заголовков и слоёв валидации точно совпадут.

1.3. CMakeLists.txt построчно






Сборка:





Разбор.

• Шаг 1. CMAKE_CXX_STANDARD 23 — включаем C++23 ради std::print и std::expected. Основная часть кода книги — чистый C++20. EXTENSIONS OFF убирает расширения GNU, чтобы код не сломался на MSVC. EXPORT_COMPILE_COMMANDS создаёт compile_commands.json — его читают clangd и другие IDE.

• Шаг 2. find_package(Vulkan) — встроенный модуль CMake. Он находит SDK по переменной VULKAN_SDK. CONFIG у других пакетов означает «искать конфиг, который положил vcpkg».

• Шаг 3. Vulkan::Vulkan — загрузчик Vulkan. vk::raii::Context подгружает его и сам, но линковка нужна VMA в главе 10. PRIVATE — зависимости не протекают наружу.

• Шаг 4. Три дефайна, без которых всё пойдёт не так. VULKAN_HPP_NO_STRUCT_CONSTRUCTORS убирает конструкторы из vk::-структур и делает их агрегатами. Только тогда работает запись { .semaphore = … }. GLM_FORCE_DEPTH_ZERO_TO_ONE заставляет glm::perspective строить матрицу под диапазон глубины Vulkan. Забудешь — половина сцены исчезнет по непонятной причине.

• Шаг 5. Предупреждения на максимум. /utf-8 для MSVC обязателен, иначе русские комментарии и строки превратятся в мусор. /permissive- включает строгое соответствие стандарту.

• Шаг 6. Санитайзеры — встроенные в компилятор ловцы ошибок. ASan ловит выход за границы и use-after-free, TSan — гонки данных. Вместе они не работают, поэтому elseif. TSan станет твоим главным инструментом в части III.

1.4. Окно GLFW в RAII-обёртке

GLFW — сишная библиотека с парами init/terminate и create/destroy. Оборачиваем их так, чтобы забыть вызвать очистку было невозможно.










Разбор.

• Шаг 1. GlfwLibrary — объект без данных, вся его ценность в конструкторе и деструкторе. Обрати внимание на ветку glfwVulkanSupported: если конструктор бросает исключение, деструктор этого объекта не вызывается. Всё, что конструктор успел захватить, он освобождает сам. Следующая строка кода после throw уже не выполнится.

• Шаг 2. std::unique_ptr умеет владеть чем угодно, если дать ему удалитель. Удалитель — пустая структура с operator(), поэтому размер WindowPtr равен размеру обычного указателя. Бесплатная абстракция, за что мы и любим C++.

• Шаг 3. GLFW_CLIENT_API = GLFW_NO_API — самая важная строка. По умолчанию GLFW создаёт контекст OpenGL, а с ним создать Vulkan surface нельзя.

• Шаг 4. glfw объявлен раньше window, значит окно уничтожится до glfwTerminate. Та же иерархия, что и в главе 0.

• Шаг 5. enumerateInstanceVersion спрашивает загрузчик, какую версию API он поддерживает. Версия упакована в одно 32-битное число, макросы VK_API_VERSION_* достают её части. Это версия загрузчика, а не видеокарты: про карту узнаем в главе 3.

• Шаг 6. glfwPollEvents забирает события ОС: нажатия, движение мыши, крестик. Без него ОС решит, что программа зависла.

• catch (const std::exception&) в main — последний рубеж. Vulkan-Hpp сообщает об ошибках исключениями (vk::SystemError и наследники), и они прилетят сюда с понятным текстом. При раскрутке стека все RAII-объекты корректно уничтожатся.

ГРАБЛИ. Порядок #include имеет значение. glfw3.h объявляет glfwCreateWindowSurface, только если до него уже подключён vulkan.h. Переставишь — в главе 4 получишь «функция не объявлена» и час весёлого дебага.

1.5. Разминка по C++: как устроен vk::raii

Чтобы vk::raii не был магией, напишем его игрушечную версию: универсальный владелец хэндла, который можно перемещать, но нельзя копировать.







Разбор.

• std::exchange(other.handle_, Handle{}) возвращает старое значение и кладёт на его место новое. Одна строка вместо трёх, и забыть обнулить источник невозможно.

• noexcept на перемещении обязателен. std::vector при росте перемещает элементы только если перемещение не бросает исключений. Иначе он попытается копировать, а копирование запрещено.

• if (this != &other) защищает от x = std::move(x). Без проверки reset() уничтожил бы хэндл, который мы тут же пытаемся забрать.

• vk::raii::Buffer устроен так же, только внутри лежат ещё указатель на родительское устройство и таблица функций. Отсюда важное следствие: vk::raii-объект помнит своего родителя по указателю. Если родитель умрёт первым, ребёнок вызовет функцию через висячий указатель.

ЗАПОМНИ. Объект, владеющий ресурсом: копирование запрещено, перемещение noexcept, деструктор безопасен для пустого состояния. Это правило пяти в самой полезной форме.

ПОТОКИ. Большинство функций GLFW можно вызывать только из главного потока: glfwInit, glfwCreateWindow, glfwPollEvents. Это ограничение самих ОС, особенно macOS. Поэтому в нашей архитектуре главный поток всегда владеет окном и событиями, а работу мы раздаём остальным.

Шпаргалка главы 1

• SDK = заголовки + валидация + slangc. Драйвер ставится отдельно.

• vulkaninfo --summary — первая команда при любых странностях.

• Дефайны: VULKAN_HPP_NO_STRUCT_CONSTRUCTORS, GLFW_INCLUDE_NONE, GLM_FORCE_DEPTH_ZERO_TO_ONE.

• GLFW_CLIENT_API = GLFW_NO_API, иначе surface не создать.

• vulkan_raii.hpp подключаем до glfw3.h.

• RAII-владелец: без копирования, с noexcept-перемещением.

Задачи для разминки мозгов

Задача 1.1. «Дескриптор с подвохом». В POSIX файловый дескриптор — это int, и ноль там валидное значение (stdin). Невалидное — -1. Доработай шаблон Unique так, чтобы «пустое» значение задавалось снаружи, и покажи UniqueFd.

Задача 1.2. «Инвентаризация». Напиши программу, которая через vk::raii::Context печатает все доступные instance-расширения и слои, а в конце сообщает, есть ли VK_LAYER_KHRONOS_validation. Без ручных циклов поиска — только алгоритмы std::ranges.

Решения — в приложении A.

Часть I. Первый треугольник

Восемь глав от пустого окна до цветного треугольника, который переживает изменение размера окна. Каждая глава добавляет в класс Pepelac один-два метода create…().

Глава 2. Instance и слои валидации: заводим лучшего друга

Instance — это твоё подключение к Vulkan. При создании ты говоришь, какую версию API хочешь, какие расширения и какие слои. Слой валидации — единственное, что стоит между тобой и UB. Поэтому в этой главе мы не только создадим Instance, но и научим программу ругаться на саму себя.

2.1. Расширения и слои

Расширение — дополнительная функциональность, не входящая в ядро. Расширения бывают уровня instance (работа с окнами ОС, отладка) и уровня device (swapchain, новые фичи GPU). Префикс в имени подсказывает, кто его придумал: KHR — Khronos, EXT — несколько компаний, NV/AMD/ARM — один производитель.

Слой — библиотека, которая встаёт между твоим кодом и драйвером и перехватывает все вызовы. Слой VK_LAYER_KHRONOS_validation проверяет каждый вызов на соответствие спецификации и сообщает о нарушениях.





Слои валидации замедляют программу в разы, поэтому включаются только в debug-сборке.

2.2. Код: создаём Instance










2.3. Разбор по шагам

Шаг 1. Макрос NDEBUG стандартно определён в релизных сборках (именно он отключает assert). Мы превращаем его в constexpr bool, чтобы дальше писать if constexpr вместо #ifdef. Код внутри if constexpr компилируется и проверяется всегда, но в релизе выбрасывается. Опечатка в debug-ветке не доживёт до пятницы. Анонимный namespace { } делает всё внутри видимым только в этом .cpp.

Шаг 2. Сигнатура callback строго задана спецификацией. VKAPI_ATTR и VKAPI_CALL — макросы соглашения о вызове. На десктопах они обычно пустые, но на 32-битном Windows и Android без них стек разъедется. severity — ровно один бит, поэтому подходит switch. type — набор флагов, vk::to_string превратит его в читаемое { Validation }. Возвращаемое vk::False означает «продолжай вызов». vk::True резервирован для разработчиков самих слоёв.

Структура makeDebugMessengerInfo выбирает, что слушать: только предупреждения и ошибки всех типов. eVerbose и eInfo вывалят сотни строк на старте, включай их, только когда расследуешь что-то конкретное.

Шаг 3. GLFW сам знает, какие расширения нужны для окон на твоей ОС: VK_KHR_surface плюс платформенное (VK_KHR_win32_surface, VK_KHR_xcb_surface, VK_KHR_wayland_surface…). Он отдаёт массив строк, которым владеет сам, поэтому мы копируем указатели в свой std::vector. Конструктор vector(first, last) принимает пару указателей как итераторы. Константы вроде vk::EXTDebugUtilsExtensionName — те же строки, что макросы VK_EXT_DEBUG_UTILS_EXTENSION_NAME, только в пространстве имён vk.

Шаг 4. Если запросить несуществующее расширение, vkCreateInstance вернёт VK_ERROR_EXTENSION_NOT_PRESENT, и ты не узнаешь, какое именно. Поэтому проверяем заранее и с человеческим сообщением. std::span — невладеющий вид на массив: функция примет и vector, и array, и сишный массив без копирования. extensionName в Vulkan-Hpp — обёртка над char[256]. .data() даёт указатель на строку с нулём в конце, из него строим string_view для честного сравнения. Сравнение двух const char* через == сравнивает адреса, а не текст, и это классический баг новичка.

Шаг 5. ApplicationInfo представляет тебя драйверу. Имя и версия приложения не просто украшение: драйверы иногда включают по ним специальные оптимизации для известных движков. Главное поле — apiVersion: максимальная версия Vulkan, которую ты собираешься использовать. Это не гарантия, что видеокарта её поддерживает: это проверим в главе 3.

В InstanceCreateInfo самое интересное — pNext. Через него структуры сцепляются в цепочки, так Vulkan расширяет структуры без ломки ABI. Здесь мы прицепляем DebugUtilsMessengerCreateInfoEXT. Для чего? Постоянный messenger создаётся после instance и уничтожается до него. Ошибки в самих vkCreateInstance и vkDestroyInstance он не увидит. Структура в pNext создаёт временный messenger ровно на эти два вызова.

instance = vk::raii::Instance(context, createInfo); — создаём временный объект и перемещаем его в поле, которое было nullptr. При ошибке конструктор бросит исключение, и поле останется пустым. Строки расширений и слоёв Vulkan копирует внутрь себя, так что векторы можно спокойно уничтожить после вызова. Это общее правило: всё, что ты передаёшь в vkCreate*, нужно только на время вызова.

Шаг 6. В чистом C функцию vkCreateDebugUtilsMessengerEXT пришлось бы доставать вручную через vkGetInstanceProcAddr: функции расширений не экспортируются загрузчиком. vk::raii::Instance делает это сам в конструкторе для всех функций сразу.

2.4. То же самое на голом C

Спецификация и половина примеров в интернете написаны на C API. Посмотри один раз на соответствие, и дальше читать спеку будет легко.






2.5. Как читать сообщения валидации

У каждой ошибки валидации есть VUID (Valid Usage ID), например VUID-vkDestroyDevice-device-05137. Это точный адрес правила в спецификации. Сообщение состоит из трёх частей: VUID, список объектов (тип и хэндл) и текст правила. Алгоритм работы с ним такой:

1. Читаешь текст правила целиком. Он почти всегда точно говорит, что нарушено.

2. Ищешь VUID в спецификации на docs.vulkan.org (https://docs.vulkan.org) и смотришь контекст.

3. По хэндлам понимаешь, какой объект виноват. В главе 28 мы дадим объектам имена, и вместо 0x7f3a… в сообщении будет «Вершинный буфер корабля».

Полезный приём: поставь точку останова в debugCallback на ветку ошибки. Стек вызовов в отладчике покажет строку твоего кода, которая нарушила правило.

ЗАПОМНИ. Правило нулевого лога: при включённой валидации программа не должна печатать ни одной ошибки и предупреждения. Никаких «это старое, оно всегда там было». Сегодняшнее предупреждение — завтрашний краш на чужой видеокарте.

ГРАБЛИ. Старые SDK (до 2025 года) объявляли pfnUserCallback с C-типами (VkBool32, VkDebugUtilsMessageSeverityFlagBitsEXT и т.д.). Если компилятор ругается на тип callback, обнови SDK или перепиши сигнатуру на C-типы.

ПОТОКИ. Callback валидации вызывается в том потоке, который сделал ошибочный вызов Vulkan. Когда в части IV команды начнут записываться из восьми потоков, callback станет вызываться параллельно. Один вызов std::println пишет строку целиком. А вот если callback пишет в общий std::vector или счётчик, нужна синхронизация.

Шпаргалка главы 2

• Instance = версия API + расширения instance + слои.

• Расширения для окна берём у GLFW, VK_EXT_debug_utils добавляем в debug.

• Перед созданием проверяем наличие всего запрошенного.

• DebugUtilsMessengerCreateInfoEXT в pNext instance ловит ошибки создания и уничтожения instance.

• apiVersion — максимум, который ты собираешься использовать, а не гарантия поддержки.

• Данные для vkCreate* нужны только на время вызова.

• Нулевой лог валидации — непреложное правило.

Задачи для разминки мозгов

Задача 2.1. «Бухгалтер ошибок». Передай в callback через pUserData структуру со счётчиками ошибок и предупреждений. При выходе из программы напечатай итоги, а в debug-сборке завершайся с кодом ошибки, если была хотя бы одна. Учти, что callback может вызываться из разных потоков.

Задача 2.2. «Все претензии сразу». Версия ensureSupported из главы падает на первом ненайденном расширении. Перепиши её так, чтобы она собирала все отсутствующие расширения и слои и бросала одно исключение со списком. Используй std::views::filter.

Решения — в приложении A.

Глава 3. Физическое и логическое устройство: собеседование для видеокарты

В компьютере может быть несколько GPU: дискретная карта, встроенная графика, программный рендерер llvmpipe. Vulkan показывает всех и предлагает тебе выбрать. В этой главе мы проведём собеседование, наймём лучшего кандидата и выдадим ему почтовые ящики — очереди.

3.1. Физическое и логическое

• PhysicalDevice — конкретная видеокарта как она есть. У неё можно только спрашивать: как зовут, что умеешь, сколько памяти. Мы её не создаём и не уничтожаем.

• Device (логическое устройство) — наш рабочий сеанс с этой картой. При создании мы перечисляем, какие фичи и очереди нам нужны. Всё не включённое запрещено, даже если железо умеет.

• Queue family — группа очередей с одинаковыми возможностями. Характерная картина для дискретной карты:





Разные семейства — это разные аппаратные блоки, которые могут работать одновременно. Это многопоточность уже на стороне GPU, и мы воспользуемся ею в части IV. А пока запомним номера всех трёх.

3.2. Surface заранее

Чтобы выбрать карту, надо знать, умеет ли она выводить картинку в наше окно. Для этого нужен surface. Подробно о нём в главе 4, а создаётся он одним вызовом GLFW:





GLFW — сишная библиотека, она принимает и возвращает C-хэндлы. static_cast(*instance) превращает vk::Instance в VkInstance. Конструктор vk::raii::SurfaceKHR(instance, raw) «усыновляет» чужой хэндл: теперь его уничтожит деструктор. window — невладеющий GLFWwindow*, который Pepelac получает в конструкторе. Окном владеет main, и оно живёт дольше рендерера.

3.3. Код: выбор видеокарты












3.4. Разбор

Шаг 1. std::optional — честный способ сказать «может и не быть». Магическое значение вроде UINT32_MAX работает до первой забытой проверки. optional же не даст достать значение без явного *. std::array kDeviceExtensions = {…} — CTAD: тип и размер компилятор выведет сам (std::array).

Шаг 2. queueFlags — набор флагов. Выражение flags & vk::QueueFlagBits::eGraphics даёт набор, а static_cast превращает его в ответ «есть хотя бы один бит». В Vulkan-Hpp флаги типобезопасны: случайно смешать QueueFlags с BufferUsageFlags не выйдет. getSurfaceSupportKHR(i, *surface) спрашивает, умеет ли семейство i выводить в наше окно. На десктопах графическое семейство почти всегда умеет, но спецификация этого не обещает (задача 3.2).

Логика поиска «выделенных» семейств строгая. Async compute — это compute без graphics: такая очередь с большой вероятностью работает на отдельном аппаратном блоке. Transfer — только transfer: это DMA-движок, копирующий данные параллельно с рендером. На встроенных картах их часто нет, и это нормально: тогда оптионалы останутся пустыми, и мы откатимся на графическую очередь.

Шаг 3. Собеседование из двух этапов. Сначала жёсткие требования, провал любого — отказ. Потом баллы за приятное. На жёсткие требования смотри внимательно:

• props.apiVersion — версия Vulkan, которую поддерживает драйвер этой карты. Версии упакованы так, что их можно сравнивать обычным <.

• getFeatures2<…>() — шаблонная функция Vulkan-Hpp. Она создаёт цепочку структур через pNext, вызывает vkGetPhysicalDeviceFeatures2 и отдаёт заполненную цепочку. Достаём звенья через .get(). На C это десять строк ручной связки указателей, в которых легко ошибиться.

• Каждая фича проверяется не просто так. samplerAnisotropy нужен для текстур (глава 13), shaderDrawParameters — Slang-шейдерам, timelineSemaphore — части IV, synchronization2 и dynamicRendering — всей книге. В Vulkan 1.3 последние две обязательны, но проверка ничего не стоит, а от багов в драйверах спасает.

Шаг 4. instance.enumeratePhysicalDevices() возвращает вектор vk::raii::PhysicalDevice. В отличие от остальных raii-классов, PhysicalDevice копируется: он ничем не владеет и ничего не уничтожает. Выражение score < 0 ? std::string(…) : std::to_string(score) обязано иметь один тип в обеих ветках, поэтому литерал обёрнут в std::string.

Шаг 5. Самое интересное:

• DeviceQueueCreateInfo — по одной структуре на каждое семейство. Семейства в списке не должны повторяться, иначе валидация покраснеет. У нас они разные по построению, потому что у них разные флаги.

• pQueuePriorities — приоритет очереди от 0 до 1. Это подсказка драйверу, а не гарантия. Указатель должен жить до вызова vkCreateDevice, поэтому priority — локальная переменная функции, а не временный объект.

• vk::StructureChain<…> — жемчужина Vulkan-Hpp. Конструктор цепочки сам связывает pNext: DeviceCreateInfo → Features2 → Vulkan11 → Vulkan12 → Vulkan13. А ещё на этапе компиляции проверяет, что каждая структура имеет право расширять голову. Попробуй запихнуть туда vk::ImageCreateInfo — не скомпилируется.

• Фичи версии 1.0 (samplerAnisotropy) идут через PhysicalDeviceFeatures2 в цепочке. При этом поле pEnabledFeatures в DeviceCreateInfo обязано остаться nullptr. Одно из двух, не оба.

• vk::raii::Device(physicalDevice, …) загружает указатели на device-функции напрямую из драйвера, минуя промежуточные функции загрузчика. Это бесплатно экономит пару переходов на каждый вызов.

• vk::raii::Queue(device, family, 0) — получаем очередь номер 0 из семейства. Очереди создаются вместе с устройством и умирают с ним, деструктор raii::Queue ничего не делает.

В заголовок Pepelac добавляются поля GLFWwindow* window, QueueFamilies families, vk::raii::Queue computeQueue = nullptr и vk::raii::Queue transferQueue = nullptr — сразу после graphicsQueue.

ЗАПОМНИ. Фича, которую ты не включил при создании Device, запрещена, даже если железо её умеет. Валидация поймает, а без неё получишь UB. Добавил в код новую фичу — добавь её и в проверку (rateDevice), и в создание устройства.

ГРАБЛИ. Ноутбуки с двумя GPU любят по умолчанию запускать приложения на встроенной графике. А в виртуалках и CI иногда есть только llvmpipe — рендерер на CPU. Печать имени выбранной карты в лог — не украшение, а первое, что ты попросишь у тестировщика с багом «у меня 3 FPS».

bannerbanner