Приветствие
Приветствую Вас, дорогой читатель! В этот раз представляю Вашему вниманию цикл статей, который будет посвящён одной из важнейших тем в программировании на Vulkan API - использование асинхронности и параллелизма для написания производительных движков.
Данный цикл не является туториалом, а лишь моей попыткой исследования основных аспектов асинхронного программирования на Vulkan API. Целью данного исследования - разработать архитектуру, упрощающую управление многопоточным выполнением, в основу которой будет положено использование корутин c++20.
Это статья будет посвящена вводной части. Мы разработаем терминологию, погрузимся в детальное исследование аспектов синхронизации, а также введём требования для нашей архитектуры.
Введение
Прежде, чем погружаться в тему, необходимо обозначить мотивацию применения асинхронности и параллелизма. Важно отметить то, что это разные понятия.
Асинхронность — модель взаимодействия, при которой момент инициирования операции отделён от момента получения её результата; результат доставляется позднее через callback/future/promise/poll/awaitable.
Параллелизм - модель выполнения, при которой разные задачи исполняются на разных вычислительных ресурсах.
Хотя асинхронность и параллелизм и являются различными понятиями, тем не менее, в основе реализации асинхронности может лежать параллелизм.
Мотивация использования асинхронности обусловлена следующим образом. Для ряда компонентов критичен интервал между итерациями основного цикла обработки событий (tick interval); в частности, главный цикл событий не должен блокироваться продолжительным ожиданием операции рендеринга, поскольку это увеличивает задержку обработки событий (latency) и ухудшает восприятие отзывчивости интерфейса (UX). Если скорость поступления событий превышает скорость их обработки, очередь растёт и задержки накапливаются — в системах с ограниченным буфером это может приводить к потере событий. Многие этапы рендера (подготовка command buffers, culling, staging uploads) можно выполнять параллельно, тогда как submit → GPU execute → present требуют сохранения порядка. Перенос подготовки или других тяжёлых задач в рабочий поток уменьшает нагрузку главного цикла обработки событий, но требует явной синхронизации и аккуратного управления временем жизни ресурсов (см. раздел о примитивах синхронизации).


Таким образом, асинхронность позволяет эффективно использовать многопоточную архитектуру. Однако следует внимательно следить за изменяемыми объектами, которыми могут управлять несколько потоков. Это делается либо при помощи мьютексов, либо при помощи специальных структур данных, таких, как lock-free очереди. Состояние исполнения, при котором конкурентный (одновременный) доступ к разделяемым данным приводит к неопределённому поведению, называется состоянием гонки (англ. race condition).
1. Модель исполнения Vulkan API
В отличие от OpenGL, Vulkan API не использует глобальный текущий контекст для потока. Вместо этого состояние контекста исполнения команд инкапсулировано в специальные объекты. Как правило, эти объекты являются неизменяемыми. Иными словами, если, например, необходимо изменить состояние конвейера рендеринга, следует создать новый объект VkPipeline. Это позволяет безопасно использовать эти объекты в разных потоках.
Тем не менее, существуют такие команды, которые могут изменять внутреннее состояние некоторых объектов. В таком случае разработчику требуется обеспечить для них внешнюю синхронизацию.
Внешняя синхронизация (External Synchronisation) - это требование спецификации Vulkan API для некоторых команд, применительно к тем параметрам, которые могут изменять своё состояние. Она должна предотвращать любой конкурентный доступ к данному параметру во время исполнения указанной команды.
Итак, для описания модели исполнения Vulkan API мы будем руководствоваться разделом Threading Behavior спецификации. Этот раздел содержит перечисление всех команд, которые принимают аргументы для которых требуется внешняя синхронизация.
Весь список команд можно разделить на две категории:
Команды хоста. Это синхронные вызовы. Как правило, это запросы к драйверу Vulkan API. К ним относятся, например, команды создания и удаления объектов, использования очереди устройства, работа с памятью и так далее. Существует расширение
VK_KHR_deferred_host_operations, которое позволяет выполнять асинхронные вызовы на стороне хоста, но это специфично для весьма ограниченного набора задач.Команды устройства. Это асинхронные вызовы. Это непосредственно те команды, которые выполняет устройство. К ним относится рендеринг, копирование данных, вычисления, показ. Такие команды требуют очереди для отправки команд, а также командные буфера для управления последовательностью задач. Для ожидания используют специальные примитивы синхронизации (
VkFence).
2. Механизмы синхронизации
Множество команд в Vulkan API являются асинхронными. Кроме того ресурсы, используемые в командах, выполняемых на устройстве, также могут требовать синхронизации. Для этого Vulkan API предоставляет примитивы синхронизации.
Для синхронизации между командами внутри одного командного буфера используют барьеры памяти. Барьеры памяти обеспечивают видимость операций для новых. Например, барьеры могут использоваться для того, чтобы все операции чтения из текстуры предшествовали окончанию рендеринга в него.
Для синхронизации между разными Submit'ами используются объекты
VkSemaphore. Например, они могут обеспечить порядок операций запись в изображение → использование в качестве текстуры.Для синхронизации между устройством и хостом используются объекты
VkFence. Они используются тогда, когда нужно дождаться завершения выполнения команд на стороне хоста. Например, если требуется дождаться готовности рендеринга мы используемfence, чтобы убедиться в том, что командный буфер выполнился и мы сможем писать в него команды для отрисовки нового кадра.Для особых случаев синхронизации может использоваться также
VkEvent. Его использование не так часто требуется, поэтому мы его рассматривать не будет.
В версии 1.2 или с расширением VK_KHR_timeline_semaphore появились timeline semaphores. Это позволяет для последовательно идущих операций использовать один семафор. Например, для рендеринга в swapchain можно использовать один семафор для всех операций рендеринга, а затем использовать vkWaitSemaphores для ожидания завершения всех операций. Это позволяет упростить код и избежать необходимости создавать множество семафоров для каждой операции.
Избегайте чрезмерной синхронизации, особенно использования VKFence. Он полезен именно только тогда, когда ожидание завершения операции на устройстве является необходимым разграничением для избежания состояния гонок, а также если действительно необходимо обеспечить строгий порядок операций. Это лишает преимуществ параллелизма, поскольку превращает асинхронный код в последовательность. Отдавайте приоритет барьерам и семафорам, если это возможно. Так вы сможете не только сократить использование VkFence, но и минимизировать количество вызовов vkQueueSubmit. Помните, что каждый вызов Submit — это прямое обращение к драйверу, которое является дорогостоящей операцией. Группировка команд и использование внутренних механизмов синхронизации GPU позволяют максимально эффективно использовать ресурсы хоста.
3. Требования к внешней синхронизации
Здесь начинается самое интересное. Я настоятельно рекомендую вам обратиться к разделу 3.6 спецификации, чтобы просмотреть список команд. Он разделён на три списка
Команды, требующие синхронизации параметров или членов структур, которые передаются в качестве параметров вызова.
Команды, требующие синхронизации параметров элементов массива параметров.
Команды, неявно требующие синхронизации объектов, которые связаны с передаваемыми параметрами.
Весь список можно разделить на следующие категории.
Очереди команд (
VkQueue):Сложность: Отправка командных буферов на исполнение (
vkQueueSubmit), sparse binding, операции показа (vkQueuePresentKHR) и другие команды, изменяющие состояние очереди, требуют внешней синхронизации.Проблема: Ограниченное количество очередей на устройстве приводит к конкуренции за их использование в многопоточной среде. Невозможно создать новую очередь без пересоздания всего устройства, что делает их критическим общим ресурсом.
Пулы команд и командные буферы (
VkCommandPool,VkCommandBuffer):Сложность: Запись команд в буферы (
vkCmd…), сброс (vkResetCommandBuffer) и выделение новых командных буферов (vkAllocateCommandBuffers), а также сброс пулов (vkResetCommandPool) требуют внешней синхронизации.Проблема: Командные буферы привязаны к своему пулу. Для параллельной записи команд в разных потоках необходимо использовать буферы, выделенные из разных пулов команд (или из пулов, созданных с флагом
VK_COMMAND_POOL_CREATE_TRANSIENT_BITиVK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT).
Примитивы синхронизации (фенсы, семафоры, события):
Сложность: Использование примитивов синхронизации (например,
VkFenceдля ожидания завершения операций) требует внешней синхронизации.Проблема: Нельзя, например, использовать один и тот же
VkFenceдля ожидания нескольких задач одновременно без риска состояний гонки или неопределенного поведения. Сброс состояния примитивов также требует синхронизации.
Память устройства (
VkDeviceMemory):Сложность: Операции отображения (
vkMapMemory), отмены отображения (vkUnmapMemory), а также освобождения памяти (vkFreeMemory) требуют внешней синхронизации.
Ресурсы (буферы и изображения
VkBuffer,VkImage):Сложность: Привязка ресурсов к памяти (
vkBindBufferMemory,vkBindImageMemory), а также их удаление (vkDestroyBuffer,vkDestroyImage) требуют внешней синхронизации.
Операции показа (
VkSwapchainKHR,VkSurfaceKHR):Сложность: Все операции, связанные с презентацией изображения на экран, такие как получение следующего изображения (
vkAcquireNextImageKHR) и его показ (vkQueuePresentKHR), требуют внешней синхронизации.Рекомендация: Из-за их критичности и частой зависимости от оконной системы, эти операции часто рекомендуется выполнять в главном потоке приложения.
Дескрипторы (
VkDescriptorPool,VkDescriptorSet):Сложность: Выделение новых дескрипторных наборов (
vkAllocateDescriptorSets), их уничтожение (vkFreeDescriptorSets), обновление содержимого (vkUpdateDescriptorSets), а также очистка пулов (vkResetDescriptorPool) требуют внешней синхронизации.
Завершение работы устройства:
Сложность: Команды ожидания завершения всех операций на устройстве (
vkDeviceWaitIdle) или в конкретной очереди (vkQueueWaitIdle) требуют внешней синхронизации для всех связанных объектов.
Удаление объектов (
vkDestroy…):Сложность: Удаление любого объекта Vulkan должно осуществляться только тогда, когда этот объект не используется ни в каких активных операциях на GPU и не требуется для будущих команд.
Проблема: Несвоевременное удаление может привести к неопределенному поведению, крашам или утечкам памяти.
4. Требования к архитектуре
Просмотрев все нюансы, которые следует учитывать, опишем требования к нашей архитектуре.
Отсутствие блокировок. Блокировки могут блокировать потоки из пула. Недопустима такая ситуация, когда все потоки из пула оказываются в состоянии блокировки. Вместо того чтобы блокировать поток для ожидания, мы будем переключаться на новую задачу.
Требуется обеспечить эксклюзивный доступ к объектам, для которых требуется внешняя синхронизация.
Архитектура должна обеспечить безопасность многопоточности для объектов, требующих внешней синхронизации при использовании в командах устройства (те, что исполняются в буферах или через использование очереди).
Архитектура должна учитывать некоторые ограничения, которые накладывает использование API (Например, ограниченное количество очередей).
5. Задачи, решаемые предлагаемой архитектурой
Учитывая сложности, связанные с ручным управлением внешней синхронизацией в Vulkan API, а также ограничения традиционных подходов к асинхронному программированию, перед нами стоят следующие ключевые задачи при разработке новой архитектуры:
Создание потокобезопасных структур данных: Разработка и реализация структур данных, которые обеспечат безопасный и эффективный конкурентный доступ к ресурсам Vulkan, требующим внешней синхронизации. Это позволит абстрагироваться от низкоуровневых механизмов защиты (мьютексов, lock-free очередей) и сосредоточиться на логике приложения.
Реализация неблокирующего ожидания примитивов синхронизации: Разработка специализированных awaitable-типов для корутин C++20, которые позволят неблокирующе ожидать завершения операций, сигнализируемых примитивами синхронизации Vulkan (такими как VkFence, VkSemaphore или VkEvent). Это позволит потокам CPU эффективно переключаться на другие задачи вместо блокирующего ожидания GPU.
Формулирование условий неблокирующего ожидания: Определение и реализация механизмов для формулирования и неблокирующего ожидания завершения асинхронных операций Vulkan, требующих внешней синхронизации. Цель — избежать блокировки потоков CPU и позволить им выполнять другие полезные задачи.
Разработка корутин для упрощения работы: Проектирование и внедрение специализированных корутин C++20, которые позволят значительно упростить написание асинхронного кода для Vulkan API. Это сделает код более линейным, читаемым, поддерживаемым и менее подверженным ошибкам, связанным с ручной синхронизацией.
Заключение вводной статьи
Как можно убедиться, фронт работ по созданию эффективной и безопасной асинхронной архитектуры для Vulkan API довольно-таки обширен. В рамках данного цикла статей мы будем поэтапно разбирать поставленные задачи, чтобы в конечном итоге объединить мощь корутин C++20 с низкоуровневым контролем Vulkan API.
В следующей статье мы сосредоточимся на первой задаче из нашего списка: создании потокобезопасных структур данных для конкурентного доступа к ресурсам Vulkan. Мы подробно рассмотрим существующие ограничения и возможности, которые могут быть использованы для проектирования нашей архитектуры.
Благодарю за внимание и до встречи в следующей части!
