Синхронизации в Vulkan
Автор: /A\
Одно из важных отличий Vulkan от более старых графических API, это больший контроль над синхронизациями как с CPU, так и внутри GPU. И как всегда многопоточность и синхронизация — это достаточно сложная тема. Стоит помнить, что драйвер Vulkan не обязан оптимизировать вызовы API, поэтому для максимальной производительности синхронизации должны быть расставлены наиболее оптимальным образом.
Термины
Синхронизация между командами на Device
Порядок выполнения команд на Device
Синхронизация между очередями и между батчами
Синхронизация между Host и Device
Синхронизация при вызове команд на Host
Термины
Для большей совместимости с оригинальной документацией многие термины не будут переводиться, либо английская версия терминов будет указана рядом в скобках.
Host — тот, кто использует Vulkan API, обычно это код выполняемый на процессоре (CPU).
Device — тот, кто выполняет команды, это драйвер и дискретная или интегрированная видеокарта (GPU), но например при софтварной реализации это может быть и процессор (CPU).
Драйвер — здесь под этим понимается программная и аппаратная часть скрытая за Vulkan API, в документации для этого используется термин implementation.
Очередь (VkQueue) — очередь команд, выполняемых на Device.
Execution dependency — это зависимость от порядка выполнения команд, все что начало выполняться, будет завершено до начала выполнения следующих операций.
Memory dependency — аналогично execution dependency, но еще и вся запись в память будет завершена до начала выполнения следующих операций.
Синхронизация между командами на Device
Все современные GPU выполняют команды параллельно, вызовы отрисовки, вызовы вычислительного шейдера стараются занять все свободные вычислительные ядра GPU, единственное что может им помешать — это наличие зависимостей между командами — синхронизация.
GPU содержит конвейер, который разделен на этапы, на каждом этапе выполняется только определенная операция. Некоторые этапы выполняются последовательно, например фрагментный шейдер (fragment shader) выполняется всегда после вершинного шейдера (vertex shader), а вершинный шейдер — после чтения из вершинного буфера (vertex input). Этапы рисования, вычислений (compute shader) и копирования (transfer) всегда запускаются с начала конвейера (top of pipe) и завершаются в конце (bottom of pipe). В документации используется понятия logically earlier и logically latest, они означают этапы, расположенные выше и ниже по схеме от выбранного этапа, и не включают в себя этапы, выполняемые параллельно. (глава 6.1.2 pipeline stages)
Для установки зависимости между разными командами или между этапами конвейера используется команда vkCmdPipelineBarrier. Параметр srcStageMask указывает какие этапы должны завершиться до того, как начнут выполняться этапы, указанные в dstStageMask, то есть определяют execution dependency.
На каждом этапе есть доступ только к некоторым видам кэша, они хранят промежуточные значения при записи, либо сохраняют значения для быстрого чтения. В некоторых случаях, рассмотренных ниже, кэш сбрасывается неявно, здесь же рассмотрим явный сброс кэша.(глава 6.1.3 access mask)
Функция vkCmdPipelineBarrier принимает массивы из VkMemoryBarrier, VkBufferMemoryBarrier и VkImageMemoryBarrier, где srcAccessMask и dstAccessMask определяют memory dependency — какие данные из кэша должны быть видимы (srcAccessMask) и где эти данные будут использоваться (dstAccessMask). VkMemoryBarrier создает global memory barrier, который затрагивает все последующие этапы конвейера и все последующие команды внутри очереди (VkQueue). VkBufferMemoryBarrier и VkImageMemoryBarrier позволяют явно указать диапазон данных для которого сбрасывается кэш и дает возможность драйверу выполнять другие команды, которые не зависят от этого диапазона данных.
Операции чтения могут выполняться параллельно, запись после чтения требует только execution dependency, то есть параметры srcAccessMask и dstAccessMask указывать необязательно. Запись после записи и чтение после записи требует memory dependency — параметр srcAccessMask обязательно должен содержать флаг, указывающий где происходила запись, а параметр dstAccessMask — флаг, где будет происходить чтение.
VkImageMemoryBarrier содержит поля oldLayout и newLayout которые нужны для image layout transition. Image layout нужен чтобы оптимизировать доступ к данным текстуры для некоторых видов операций чтения и записи, так например VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL оптимизирует данные текстуры для чтения в шейдере, а VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL позволяет драйверу сжимать данные при рисовании в текстуру, что увеличивает пропускную способность памяти, но переход из сжатого формата в несжатый формат, например в VK_IMAGE_LAYOUT_GENERAL, приводит к разжатию и потере времени.
VkBufferMemoryBarrier и VkImageMemoryBarrier содержат поля srcQuueFamilyIndex и dstQueueFamilyIndex для операций queue family ownership transfer, если передача между очередями не нужна, то указывается флаг VK_QUEUE_FAMILY_IGNORED, подробнее передача между очередями разобрана в примере с асинхронными вычислениями.
Функция vkCmdPipelineBarrier не дает прямого контроля над механизмом инвалидации кэша, так запись из кэша в глобальную память может произойти и без вызова синхронизации, и пропущенный в этом месте барьер никак не отразится на работе программы, но на другом GPU, на другом драйвере или просто при других обстоятельствах пропущенный барьер приведет к неправильной работе программы. Слои валидации не отслеживают насколько правильно настроены memory dependencies, это достаточно сложная задача, но слои валидации проверяют image layout transition и queue ownership transfer, что уже неплохо.
События (VkEvent) работают аналогично барьеру, но разделены на две части — сигнал и ожидание, также как и барьер, события работают только внутри одной очереди и позволяют более явно указать зависимость между командами.
Проход рендера (VkRenderPass) объединяет в себе зависимости для текстур, в которые идет рисование, до и после прохода рендера, а также зависимости между отдельными этапами рисования (subpass). Зависимости указываются через VkSubpassDependency, по аналогии с барьерами. Барьеры внутри прохода рендера разрешены, но для них существуют ограничения: нужно указать VkSubpassDependency, где srcSubpass и dstSubpass равны и параметры барьера должны совпадать с тем, что было указано в VkSubpassDependency. (глава 7 render pass, subpass self-dependency)
Команда vkCmdWriteTimestamp создает execution barrier, что может препятствовать распараллеливанию команд, не стоит использовать эту команду слишком часто, а точное время выполнения каждой команды может показать только специализированный профайлер.
Порядок выполнения команд на Device
В документации явно определен только порядок в котором команды, записанные на стороне Host, будут прочитаны на стороне Device, это submission order. Команды vkQueueSubmit в пределах одной очереди читаются на стороне Device в том же порядке, в каком были вызваны на стороне Host. Каждый командный буфер будет прочитан только после прочтения предыдущего командного буфера в батче, либо последнего командного буфера предыдущего батча. Команды внутри командного буфера читаются в том порядке, в котором они были записаны в буфер. Порядок, в котором выполняются команды, определяется только через зависимости, созданные барьерами и событиями. Командный буфер не создает никаких дополнительных синхронизаций, при переходе на следующий командный буфер происходит только смена состояний (пайпалайны, дескрипторы и так далее).
На схеме красные линии между командами — зависимости для ресурсов (memory dependency), красная полоса — глобальный барьер (global execution barrier).
Для прохода рендера (VkRenderPass) существуют дополнительные правила: каждый отдельный подпроход (subpass) может выполняться параллельно с другими и в любом порядке, если между ними не установлена зависимость через VkSubpassDependency. Внутри подпрохода (subpass) команды рисования выполняются в соответствии с порядком генерации примитивов (primitive order) и порядком растеризации (rasterization order).
Синхронизация между очередями и между батчами
Зависимости между батчами внутри одной очереди или между разными очередями, а также с presentation engine, устанавливается через семафоры (VkSemaphore). На схеме стрелками указаны зависимости, созданные семафорами.
Семафор может быть только в двух состояниях — сигнальном и не сигнальном. Если передается в функции vkQueueSubmit и vkQueueBindSparse в качестве параметра pSignalSemaphores, то ожидает завершения батча на стороне Device и переходит в сигнальное состояние. Если передается в качестве параметра pWaitSemaphores, то блокирует выполнение батча, пока семафор не перейдет в сигнальное состояние, после этого сбрасывает семафор в не сигнальное состояние. Команда vkAcquireNextImageKHR переводит семафор в сигнальное состояние, когда presentation engine завершил работу с изображением и можно его использовать для рисования нового кадра. Несколько батчей не могут ожидать сигнала от одно и того же семафора.
Между операцией сигнала семафора и ожиданием создается memory dependency — все данные из кэша будут записаны в глобальную память и все команды в очереди завершат выполнение до начала этапов указанных в pWaitDstStageMask. (глава 6.4.1 semaphore signaling, глава 6.4.2 semaphore waiting)
Функция vkQueuePresentKHR дополнительно гарантирует, что все записи в текстуры свопчейна, на которые указывают индексы (pImageIndices) будут видимы при выводе изображения на экран.
Синхронизация между Host и Device
Для этого есть явный примитив синхронизации VkFence, а также неявная синхронизация при вызове некоторых функций.
VkFence передается в функции vkQueueSubmit, vkQueueBindSparse, vkAcquireNextImageKHR, при завершении выполнения функций fence переводится в сигнальное состояние и находится в нем до явного вызова vkResetFences, дождаться завершения команд можно вызовом блокирующей функции vkWaitForFences. Команды vkQueueWaitIdle, vkDeviceWaitIdle работают схожим образом, но ожидают завершения абсолютно всех команд в очереди и на всем GPU устройстве соответственно. Вызовы vkWaitForFences, vkQueueWaitIdle, vkDeviceWaitIdle создают global memory dependency, значит все изменения на Device будут видимы для всех последующих командах на Device и на Host.
VkEvent может быть переведен в сигнальное состояние на стороне Host, тогда блокировка произойдет на стороне Device при вызове vkCmdWaitEvents, где в srcStageMask указано VK_PIPELINE_STAGE_HOST_BIT, это можно использовать в редких случаях, когда необходимо передать данные на Device уже после начала выполнения командного буфера. В этом случае нужно явно указать memory dependency между Host и Device добавив VkMemoryBarrier, где в srcAccessMask содержится VK_ACCESS_HOST_WRITE_BIT.
Команда vkQueueSubmit делает неявную синхронизацию при которой все изменения памяти на Host становятся видимы на Device, что аналогично вызову vkFlushMappedMemoryRanges для всех изменений памяти (глава 6.9).
Команда vkFlushMappedMemoryRanges делает видимым на Device все изменения памяти на Host, но только для заданных диапазонов. Для памяти выделенной на куче с флагом VK_MEMORY_PROPERTY_COHERENT_BIT вызов этой функции не имеет эффекта, так как все изменения сразу же отправляются на Device.
Команда vkInvalidateMappedMemoryRanges делает видимым на Host все изменения памяти на Device, но только для заданных диапазонов.
Синхронизация при вызове команд на Host
Vulkan позволяет обращаться к API из любого потока, но требует чтобы доступ к некоторым данным осуществлялся последовательно. Для этого подойдет mutex, spinlock и другие примитивы синхронизации. Для архитектур с relaxed memory ordering, например ARM, требуется инвалидация кэша. При использовании std::mutex это происходит автоматически, но если вы используете самописный spinlock на атомарных операциях, то об этом стоит помнить. (глава 2.6)
Ссылки
Документация по vulkan
Примеры синхронизаций
Объяснение барьеров от AMD
Еще одна попытка объяснить барьеры
Очень подробный разбор устройства GPU, работы драйвера, синхронизации и так далее — стоит прочитать все 6 частей
Объясняют context rolls — ограниченное количество команд, которые могут выполняться параллельно.
vulkan-sync — более детально описано как работает память, кэш, сжатие текстур и так далее.
Теперь, зная о всех сложностях синхронизации, стоит задуматься, а нужно ли вам это? В качестве альтернатив есть множество оберток, которые упрощают использование графических api, ценой некоторых ограничений функционала и небольшого замедления на CPU.
Vulkan-EZ — разработка AMD, автоматизирует расстановку пайплайн барьеров и сохраняет прямой доступ к vulkan api.
FrameGraph — разработано автором статьи, абстрагирует от Vulkan, заменяет все синхронизации на зависимости между батчами и зависимости между тасками внутри батча.
Falcor — более высокоуровневая абстракция от Nvidia, поддерживает DX12, DXR, Vulkan, предназначен для прототипирования.
Granite — рендер граф, автоматизирует расстановку барьеров между рендер пассами.
DiligentEngine — абстракция над всеми графическими API, поддерживает автоматическую и ручную расстановку синхронизаций.
Далее идет подробный разбор примеров.
2. синхронизации между Host и Device, семафоры, многопоточность
3. примеры барьеров
4. синхронизация между этапами конвейера
5. асинхронные вычисления
26 февраля 2019 (Обновление: 29 мар 2019)