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

Алексей Примак
«Vulkan и многопоточность для смертных» или: как заставить работать видеокарту и все 16 ядер, а не себя
Предисловие
Эта книга за 31 главу проводит тебя от пустого окна до многопоточного рендерера на Vulkan и современном C++. Опыт в графике и многопоточности не нужен. Нужны C++ на базовом уровне и терпение.
Итак, ты решил выучить Vulkan. Смело. Обычно к этому решению приходят в три этапа: «OpenGL устарел», «DirectX 12 есть только на Windows», «да насколько сложно нарисовать треугольник?». Ответ на последний вопрос: около тысячи строк кода. Добро пожаловать.
Хорошая новость в том, что Vulkan не сложный, а многословный. Каждая строка делает одну понятную вещь, просто строк много. OpenGL угадывал твои намерения и иногда ошибался. Vulkan ничего не угадывает: делает ровно то, что ты сказал. Глупости тоже.
Теперь о том, при чём тут многопоточность. Vulkan с самого начала проектировали под многоядерные процессоры. В OpenGL весь рендеринг жил в одном потоке, а остальные 15 ядер смотрели на это, как коллеги на джуна, который в пятницу вечером в одиночку чинит прод. В Vulkan команды для GPU можно записывать из всех потоков сразу, если знать правила. Про эти правила книга и написана.
Для кого эта книга
• Ты знаешь C++ на уровне классов, ссылок, конструкторов и деструкторов, std::vector, std::unique_ptr и лямбд.
• Ты помнишь из школы, что такое вектор и матрица. Остальную математику объясним.
• Ты никогда не писал на Vulkan, OpenGL или многопоточный код. Именно под тебя книга и рассчитана.
Железо: видеокарта с драйвером Vulkan 1.3 или новее. Это почти любая дискретная или встроенная карта примерно с 2016 года при свежих драйверах. Проверка через vulkaninfo разобрана в главе 1.
Технологический стек

Стек совпадает с официальным Khronos Vulkan Tutorial (https://docs.vulkan.org/tutorial/latest/00_Introduction.html): Vulkan 1.4 как база, dynamic rendering вместо render passes, timeline semaphores, Slang, C++20 и Vulkan-Hpp с RAII. Значит, по ходу книги можно сверяться с первоисточником. Старые туториалы 2016–2020 годов учат через VkRenderPass и VkFramebuffer. Это как учиться водить на «Запорожце»: опыт ценный, но зачем.
Сквозной проект «Пепелац»
Вся книга строится вокруг одного проекта, который растёт от главы к главе: из пустого окна получается многопоточный рендерер. Назвали его «Пепелац», потому что сначала он тоже не летает.
Условные обозначения
• ЗАПОМНИ — правила, которые надо знать даже разбуженным в три часа ночи.
• ШПАРГАЛКА — сводка в конце главы для повторения.
• ЦИТАТА — реальные цитаты с указанием автора или мои собственные с пометкой «— автор книги».
• ГРАБЛИ — типичные ошибки новичков и их последствия.
• ПОТОКИ — можно ли трогать объекты главы из нескольких потоков. Такая заметка есть в каждой главе со второй, так что многопоточность идёт через всю книгу.
• ЗАДАЧКИ — задачи для разминки мозгов. Решения в приложении A.
Как читать
Каждая глава устроена одинаково: вступление, теория, код с построчным разбором, шпаргалка, задачи. Код в разборах пронумерован шагами (// ── Шаг 1 ──), и текст разбора идёт по тем же шагам. Читай по порядку: части опираются друг на друга.
Книга разбита на пять частей. Часть 0 даёт общую картину и окружение. Часть I доводит до первого треугольника. Часть II добавляет ресурсы: буферы, текстуры, 3D, барьеры. Часть III учит многопоточности в C++ без всякой графики. Часть IV соединяет Vulkan и потоки, ради этого всё и затевалось. Часть V про отладку, профилирование и финальный проект.
Почему потоки не с первой главы? Если учить Vulkan и потоки одновременно, баги прилетают с двух сторон, и непонятно, откуда именно. Сначала учимся ходить, потом жонглировать, и только потом жонглировать на ходу.
ЗАПОМНИ ДО СТАРТА. В debug-сборке validation layers включены всегда, без исключений: без них Vulkan не выдаёт ошибок, а молча делает UB. Главу 15 не пропускать, даже если очень хочется. Особенно если очень хочется.
ЦИТАТА. «Vulkan не сложный. Он просто честный: всё, что раньше скрывал драйвер, теперь скрываешь ты, причём от начальства». — автор книги
Часть 0. Перед погружением
Две главы без треугольников: сначала модель в голове, потом окружение на диске.
Глава 0. Зачем вам Vulkan, или Что от вас скрывал OpenGL
В этой главе мы не нарисуем ни пикселя. Зато ты узнаешь, из чего состоит Vulkan, как живёт один кадр и почему порядок полей в классе может уронить программу. Код Vulkan без модели в голове — это карго-культ на тысячу строк. Всё работает, пока не перестанет, и тогда никто не знает почему.
0.1. Видеокарта: тысяча художников и один занятой директор
CPU — это несколько очень умных профессоров. Каждый решает любую задачу, ветвится, предсказывает переходы. GPU — это тысячи простых рабочих, которые делают одну и ту же операцию над разными данными. Покрасить два миллиона пикселей — работа для тысячи рабочих. Разобрать JSON с конфигом — работа для профессора.
Главное: CPU не рисует. CPU пишет список инструкций (командный буфер), отправляет его в очередь и сразу занимается другими делами. GPU выполнит его позже — через микросекунды или через целый кадр. Это как письмо по почте: бросил в ящик и пошёл дальше.

Из асинхронности следует вся боль синхронизации. Пока GPU читает буфер, его нельзя перезаписывать. Пока GPU рисует в картинку, её нельзя показывать. Пока GPU использует объект, его нельзя удалять. В OpenGL за всем этим следил драйвер. В Vulkan — ты.
ЗАПОМНИ. CPU и GPU работают параллельно и асинхронно. Вызов «нарисуй» на CPU ничего не рисует, а добавляет команду в список. Всё, что ты отдал GPU, нельзя трогать, пока GPU не скажет «готово».
0.2. Что OpenGL делал у тебя за спиной
Вот типичный код отрисовки на OpenGL. Выглядит невинно.

Внутри glDrawArrays драйвер делает кучу работы, о которой ты не просил:
1. Проверяет ошибки. Слинкована ли программа? Полная ли текстура? Совпадают ли форматы? На каждом вызове, в релизе тоже.
2. Собирает состояние. OpenGL — гигантский автомат состояний. Драйвер смотрит на всё, что ты включил за всю жизнь программы, и собирает из этого конфигурацию для железа.
3. Может перекомпилировать шейдер. На части железа некоторые состояния вшиваются прямо в машинный код шейдера. Поменял смешивание — драйвер тихо перекомпилирует шейдер посреди кадра. Отсюда загадочные подтормаживания.
4. Раскладывает память. Где лежит текстура, в видеопамяти или в оперативке? Не пора ли её перенести? Решает драйвер.
5. Ставит синхронизацию. Ты записал данные в буфер через glBufferSubData, а GPU ещё читает старую версию? Драйвер либо подождёт, либо тайно скопирует буфер.
6. Решает, когда отправить работу. Команды копятся во внутреннем буфере драйвера и уходят, когда он сочтёт нужным.
И всё это в одном потоке, к которому привязан контекст OpenGL. Результат: непредсказуемые фризы, разное поведение у разных производителей и потолок производительности одного ядра.
0.3. Философия Vulkan: «я сам»
Vulkan вышел в 2016 году. Его сделала Khronos Group на основе AMD Mantle. Идея простая: драйвер должен быть тонким и предсказуемым, а всё дорогое должно делаться явно и заранее.

Что ты получаешь: предсказуемое время кадра, низкие накладные расходы на draw call и настоящую многопоточность. Чем платишь: объёмом кода и ответственностью. Ошибка в Vulkan — это не код ошибки, а UB: чёрный экран, мусор на картинке или «у меня работает, а на AMD падает».
Честность прежде всего: Vulkan нужен не всем. Для простой 2D-игры бери SDL, raylib или готовый движок. Vulkan нужен, когда важны производительность и контроль, когда ты пишешь движок или хочешь понять, как на самом деле работает видеокарта. Последнее окупается, даже если потом ты будешь работать в Unreal.
ЦИТАТА. «Явное лучше, чем неявное». — Тим Питерс, «Дзен Python» (PEP 20). Фраза про Python, но лучшего девиза для Vulkan не придумаешь.
ГРАБЛИ. Раз проверка ошибок живёт в слоях валидации, то без них программа с ошибкой может «работать». На твоей карте. Сегодня. Завтра обновится драйвер — и всё. Пустой лог валидации — единственное доказательство корректности.
0.4. Карта объектов Vulkan
Всё в Vulkan — объекты, и они строго иерархичны. Каждый рождается от родителя и должен умереть раньше него.

Способ получения объекта определяет, кто и как его уничтожает:

Buffer и Image — только «описание» ресурса: размер, формат, назначение. Память под них (DeviceMemory) выделяется отдельно и привязывается вручную. Это как договор аренды без квартиры: бумага есть, жить негде. В главе 10 эту работу возьмёт на себя VMA.
0.5. Разбор кода №1: порядок жизни и смерти
Правило «ребёнок умирает раньше родителя» в C++ реализуется бесплатно, через порядок полей класса. Проверим это на программе без Vulkan. Её можно запустить прямо сейчас на godbolt.org (https://godbolt.org): свежий GCC или Clang, галочка Execute.


Разбор построчно.
• explicit Noisy(std::string_view name) — explicit запрещает неявное преобразование. Без него компилятор молча превратил бы любую строку в Noisy там, где ты этого не хотел. Конструктор с одним аргументом делай explicit, если нет особой причины.
• : name_{name} — список инициализации. Поле создаётся сразу с нужным значением, а не пустым с последующим присваиванием.
• = delete для копирования — то же, что делают vk::raii-объекты. У ресурса может быть только один владелец. Перемещать (std::move) vk::raii-объекты можно: владение переходит, а источник становится пустым.
• std::string_view name_ — вид на чужую строку, а не копия. Здесь это безопасно: литералы живут до конца программы. Передай сюда временную std::string — и получишь висячую ссылку.
• Noisy instance_{"Instance"}; — инициализатор поля прямо в объявлении (default member initializer). Не надо перечислять поля в конструкторе.
• Порядок создания полей определяется только порядком объявления в классе. Порядок в списке инициализации конструктора ничего не меняет, компилятор лишь предупредит через -Wreorder. Уничтожение идёт строго в обратном порядке.
• Тело конструктора App() выполняется после создания всех полей. Тело деструктора ~App() выполняется до их уничтожения. Именно в теле деструктора мы будем ждать GPU.
• Фигурные скобки в main создают блок. На закрывающей скобке app умирает, и мы видим всю цепочку деструкторов до строки «финиш».
Теперь то же самое с настоящими объектами Vulkan. Это скелет нашего «Пепелаца». Он скомпилируется после настройки окружения в главе 1, но пока ничего не делает. Создавать объекты мы научимся в главах 2–8.



Разбор.
• vk::raii::Context context; — единственное поле без = nullptr. Конструктор Context находит и загружает системную библиотеку Vulkan и достаёт из неё адреса первых функций. Без него нельзя создать ни один объект.
• = nullptr — у всех vk::raii-объектов есть «пустое» состояние: поле существует, но ничем не владеет. Настоящий объект присвоим позже, когда соберём параметры. Деструктор пустого объекта ничего не делает.
• Порядок полей повторяет карту из раздела 0.4 сверху вниз. Уничтожаться они будут снизу вверх: семафоры, командные буферы, пул… и в самом конце Instance. Ровно то, чего требует Vulkan.
• std::vector
• commandBuffers объявлены после commandPool. Деструктор vk::raii::CommandBuffer возвращает буфер в пул, и пул в этот момент обязан быть жив.
• Размеры массивов синхронизации разные, и это не опечатка. Почему так, разберём в разделе 0.7.
• if (*device) — оператор * у vk::raii-объекта возвращает «голый» хэндл vk::Device, а тот умеет превращаться в bool. Если устройство не создалось (исключение при инициализации), ждать нечего.
• device.waitIdle() блокирует CPU, пока GPU не закончит всю работу. Иначе мы уничтожим буферы и пайплайны, которыми GPU ещё рисует последний кадр.
• try { … } catch (...) {} — деструкторы в C++ неявно noexcept. Исключение, вылетевшее из деструктора, вызовет std::terminate. Глотать исключения обычно плохо, но здесь это осознанный выбор: если GPU потерян, сделать уже ничего нельзя.
• inline перед определением деструктора нужен потому, что оно лежит в заголовочном файле. Без него два .cpp, включивших заголовок, дадут ошибку компоновщика «множественное определение».
ГРАБЛИ. Переставил device ниже commandPool «для красоты»? Устройство умрёт раньше пула, и деструктор пула вызовет vkDestroyCommandPool для уже мёртвого VkDevice. Это UB. В лучшем случае валидация завопит, в худшем программа упадёт на выходе, и только у твоих пользователей.
0.6. Путь одного кадра
Каждый кадр в Vulkan проходит семь шагов. CPU делает их подряд, а GPU и система показа кадров отрабатывают свою часть асинхронно.

А вот как это выглядит во времени при двух кадрах в полёте (frames in flight), когда CPU быстрее GPU:

GPU не простаивает: пока он рисует кадр 0, CPU уже готовит кадр 1. Начать третий кадр CPU не может: его ресурсы (командный буфер, uniform-данные) ещё заняты кадром 0. Поэтому CPU ждёт fence F0. Такая программа упирается в GPU (GPU-bound), и это хорошо: карта загружена на 100%.
Почему два кадра, а не десять? Каждый лишний кадр в полёте — лишний кадр задержки между нажатием кнопки и реакцией на экране. Два — разумный баланс: GPU загружен, задержка маленькая.
0.7. Разбор кода №2: drawFrame построчно
Вот центральная функция любого Vulkan-приложения. Запускать её пока рано: объектам ещё неоткуда взяться. В главе 8 она заработает ровно в таком виде. Но понять её надо уже сейчас: всё остальное в книге нужно, чтобы эта функция работала.


Шаг 1 — waitForFences. Блокирует CPU, пока fence не станет сигнальным. Сигналит его GPU, когда заканчивает командный буфер, отправленный с этим fence два кадра назад. commandBuffers[currentFrame] может всё ещё выполняться на GPU. Перезаписать его сейчас — всё равно что стирать доску, пока студенты переписывают.
• *inFlight[currentFrame] — * достаёт из vk::raii::Fence «голый» vk::Fence. raii-объект владеет ресурсом, а в функции передаётся именно хэндл. Этот приём будет встречаться постоянно.
• vk::True — «ждать все fence из списка». У нас он один, разницы нет.
• UINT64_MAX — тайм-аут в наносекундах, примерно 584 года. То есть «бесконечно».
• Результат помечен [[nodiscard]], поэтому мы его проверяем. Настоящие ошибки (например, потерю устройства) Vulkan-Hpp и так выбросит исключением.
• Почему не зависает самый первый кадр? Потому что fence создают сразу в сигнальном состоянии (флаг vk::FenceCreateFlagBits::eSignaled). Иначе мы вечно ждали бы кадр, которого не было.
Шаг 2 — acquireNextImage. Просим у swapchain номер картинки для рисования. Тонкий момент: номер ты получаешь сразу, а саму картинку — позже. Она ещё может висеть на мониторе. Когда она реально освободится, система сигналит семафор imageAvailable[currentFrame]. Ждать его будет GPU, а не CPU.
• auto [acquireResult, imageIndex] — структурное связывание (C++17). Функция возвращает пару «результат + значение», мы раскладываем её по двум именам.
• imageIndex не обязан идти по порядку 0, 1, 2: система вправе отдать 2, 0, 2, 1. И imageIndex — это не currentFrame! Это два независимых счётчика.
• Код eErrorOutOfDateKHR (окно изменило размер) Vulkan-Hpp выбрасывает как исключение vk::OutOfDateKHRError. Займёмся им в главе 9.
Шаг 3 — resetFences. Возвращает fence в несигнальное состояние, чтобы GPU смог снова его засигналить. Почему после acquire, а не сразу после ожидания? Если acquire выбросит исключение, мы выйдем из функции, не отправив работу. Сброшенный fence тогда никто не засигналит, и следующий кадр зависнет на шаге 1 навсегда.
Шаг 4 — запись. cmd.reset() стирает старые команды. Работает, только если пул создан с флагом eResetCommandBuffer (глава 7). auto& cmd — именно ссылка: vk::raii::CommandBuffer нельзя копировать.
Шаг 5 — submit2. Три структуры говорят GPU, чего ждать, что делать и кого оповестить.
• waitInfo — ждать семафор imageAvailable, но только перед стадией eColorAttachmentOutput (запись цвета в картинку). Вершинные шейдеры могут начать работу заранее, ждёт только запись пикселей.
• cmdInfo — сам командный буфер.
• signalInfo — когда все команды (eAllCommands) завершатся, засигналить renderFinished[imageIndex]. В конце кадра мелочиться со стадиями незачем.
• Второй аргумент submit2 — fence. Когда весь submit выполнится, GPU засигналит inFlight[currentFrame], и через два кадра шаг 1 дождётся именно его.
• Почему submit2, а не старый submit? Это часть synchronization2 из ядра Vulkan 1.3. У каждого семафора своя стадия, и те же типы стадий используются в барьерах. Меньше загадок.
• Имена полей в фигурных скобках (.semaphore = …) — designated initializers из C++20. Поля перечисляются в порядке объявления в структуре, пропускать можно. Поле sType Vulkan-Hpp заполняет сам. Для этого нужен дефайн VULKAN_HPP_NO_STRUCT_CONSTRUCTORS (глава 1).
Шаг 6 — presentKHR. Просим систему показа вывести картинку imageIndex, когда засигналится renderFinished[imageIndex]. Запись &*renderFinished[imageIndex] читается справа налево: * даёт ссылку на хэндл vk::Semaphore внутри raii-объекта, & берёт его адрес. Vulkan ждёт указатель на массив, а указатель на один элемент — это массив из одного. Очередь для показа мы используем ту же, что и для графики. На десктопах это почти всегда работает, проверять это научимся в главе 3.
Теперь главная загадка: почему imageAvailable индексируется currentFrame, а renderFinished — imageIndex? У present нет fence. Мы не знаем, когда система показа «съела» семафор и когда его можно использовать снова. Зато знаем другое: если картинку N нам снова отдали через acquire, её прошлый present точно завершён. Значит, семафор, привязанный к картинке N, свободен. Привяжи renderFinished к currentFrame — и валидация пожалуется на повторное использование семафора, который ещё может быть в работе.
Шаг 7. Переходим к следующему набору ресурсов по кругу: 0, 1, 0, 1…
ЗАПОМНИ. Fence — сигнал от GPU к CPU: «я закончил, можешь трогать». Semaphore — сигнал между операциями на GPU: «сначала это, потом то», CPU его не видит. imageAvailable живёт по кадрам в полёте, renderFinished — по картинкам swapchain.
ГРАБЛИ. Путать imageIndex и currentFrame — самая популярная ошибка новичка. Командные буферы, fences и uniform-данные индексируются currentFrame. Картинки swapchain, их image views и renderFinished — imageIndex.
0.8. А где тут многопоточность?
В большой сцене дороже всего на CPU шаг 4: запись десятков тысяч команд. Командные буферы — независимые объекты, и их можно записывать параллельно:

Кроме записи команд, в отдельные потоки уходят загрузка ресурсов (глава 23) и компиляция пайплайнов (глава 25). А submit и present остаются последовательными: очередь одна, и толкаться у неё из разных потоков нельзя.
ПОТОКИ (первое знакомство). Функции создания объектов на одном VkDevice можно вызывать из разных потоков одновременно. Отправлять работу в одну очередь из двух потоков одновременно нельзя. Командный пул и все его буферы трогает только один поток за раз. Отсюда главное правило части IV: свой пул на каждый поток.
Шпаргалка главы 0
• CPU записывает команды, GPU выполняет их асинхронно.
• Vulkan — явный API: компиляция, память и синхронизация — твоя работа.
• Иерархия: Instance → PhysicalDevice → Device → всё остальное.
• Три способа получить объект: create, allocate, enumerate/get.
• Ребёнок умирает раньше родителя. В C++ это обеспечивает порядок полей класса.
• Перед уничтожением всего — device.waitIdle().
• Кадр: wait fence → acquire → reset fence → record → submit → present.
• Fence: GPU → CPU. Semaphore: GPU → GPU.
• Два кадра в полёте — баланс между загрузкой GPU и задержкой.
• Запись команд параллелится, submit и present — нет.
Задачи для разминки мозгов
Задача 0.1. «Похороны в неправильном порядке». Джун собрал класс. Найди все проблемы с порядком уничтожения, объясни, что произойдёт при выходе из программы, и перепиши правильно. Бонус: почему компилятор не сказал ни слова?

Задача 0.2. «Бухгалтерия конвейера». CPU записывает кадр за 4 мс, GPU рисует его за 10 мс. Vsync выключен, present мгновенный, MAX_FRAMES_IN_FLIGHT = 2. (а) Какой будет FPS в установившемся режиме? (б) Сколько миллисекунд за кадр CPU ждёт fence? (в) Что станет с FPS и задержкой ввода при MAX_FRAMES_IN_FLIGHT = 1 и = 3?
Задача 0.3. «На каком кадре всё зависнет?» Джун «упростил» шаг 5 и убрал fence: graphicsQueue.submit2(submitInfo);. Всё остальное как в главе, fences созданы с eSignaled. (а) На каком кадре (считая с нуля) и на какой строке программа зависнет? (б) А если fences созданы без eSignaled? (в) А при MAX_FRAMES_IN_FLIGHT = 3?
Решения и разбор ваших неизбежных ошибок ищите в конце книги, в приложении A.
Глава 1. Окружение, или Четыре часа CMake ради пустого окна
К концу главы у тебя будет собирающийся проект «Пепелац», окно на GLFW и программа, которая сообщает версию Vulkan. Звучит скромно. На практике эта глава отнимает больше нервов, чем первый треугольник, потому что C++ до сих пор не договорился сам с собой, как подключать библиотеки.
1.1. Vulkan SDK

