Так, я наиграл в Fire Emblem: Three Houses уже порядка 160 часов в течение последней пары недель, думаю пришло время вновь побыть интернет-экспертом.

Тема, по которой я планировал сделать пост еще очень давно, это синхронизация в Vulkan'е. У многих, изучающих этот API, этот топик вызывает наибольшие трудности, и в этой статье я хотел бы сформировать у читателя ментальную модель, а не просто пересказать техническую спецификацию. Не смотря на кажущуюся неимоверную сложность, синхронизация в Vulkan' е вполне понятна и логична, если преодолеть начальные трудности.

В этом посте, где это уместно, я буду использовать термины, совпадающие со спецификацией Vulkan'а.

Очередь в Vulkan

В первой части нашей статьи мы будем полагать, что у нас есть только одна VkQueue. Даже для такого случая необходимо покрыть очень много тем относительно синхронизации, и по сравнению с ними работа с несколькими очередями представляет собой лишь малое дополнение, которое будет раскрыто в конце статьи, когда мы будем обсуждать семафоры.

Очередь в Vulkan это простая абстракция, в которую отправляются буферы команд и через которую затем проходит GPU, исполняя команды. Мы же для начала рассмотрим самые частые ошибки новичков.

Заблуждения о буферах команд

Многие разработчики думают, что разделение команд по буферам на что-то влияет в Vulkan'е. Здесь очень важно отметить, что синхронизация происходит между всеми командами, отправленными в очередь. В API нет такого концепта, как синхронизация внутри буфера команд.

Пересечение команд

Спецификация утверждает, что все команды начинают исполняться в том порядке, в котором они отправлены в очередь, однако завершаются они в произвольном порядке. Не беспокойтесь, тот факт, что команды начинают исполняться по порядку - просто удобный способ описать спецификацию. Мы же должны считать, что пока мы явно не добавляем синхронизацию, все команды исполняются в произвольном порядке. Перестановка команд может происходить в том числе между буферами команд, и даже между вызовами vkQueueSubmit. И это имеет смысл, так как Vulkan видит только линейный поток команд, отправленных в очередь, и крайне ошибочно полагать, что разделение команд между буферами команд или между vkQueueSubmit добавляет "магическую синхронизацию".

Замечание: В отличие от Vulkan, насколько мне известно, D3D12 запрещает пересечение команд между отправками (submits) в очередь, но, пожалуйста. не верьте мне на слово. В любом случае, не забудьте учесть этот факт, если вы пришли из D3D.

Замечание: Операции на фреймбуфере внутри рендер-пасса происходят конечно же в порядке вызовов API. Это намеренное исключение, на которое явно указывает спецификация.

Стадии пайплайна

Каждая команда, отправленная в Vulkan, проходит через различные стадии пайплайна. Эти стадии описаны в enum'е VK_PIPELINE_STAGE (см. главу 6.1.2 в спецификации). Когда мы синхронизируем исполнение в Vulkan'е, мы синхронизируем исполнение именно между стадиями пайплайна, а не отдельными командами.

Вызовы отрисовки, команды на копирование и запуск compute-шейдеров проходят по стадиям пайплайна друг за другом.

Загадочные стадии TOP_OF_PIPE и BOTTOM_OF_PIPE

Многих очень смущает наличие стадий TOP_OF_PIPE и BOTTOM_OF_PIPE. По факту, это вспомогательные стадии пайплайна, в ходе которых не происходит никакой работы, но которые нужны для одной очень важной цели. Каждая команда в начале исполнения проходит через стадию TOP_OF_PIPE. По сути, в ходе этой стадии, исполнитель команд GPU парсит команды. BOTTOM_OF_PIPE же является последней стадией, которое обозначает, что исполнение команды было завершено. TOP_OF_PIPE и BOTTOM_OF_PIPE полезны в определенных сценариях, так что держим их в голове, ведь они достаточно непросты и многие новички допускают множество ошибок, связанных с ними.

Барьеры исполнения в очередях

Прежде чем мы коснемся барьеров памяти (memory barriers), мы должны полностью понять, как работают барьеры исполнения (execution barriers), ведь они представляют из себя подмножество барьеров памяти. Основным механизмом в Vulkan'е, отвечающим за барьеры исполнения, является пайплайн-барьер. Чтобы определить самый простой вариант зависимости исполнения, мы используем vkCmdPipelineBarrier:

void vkCmdPipelineBarrier(    
    VkCommandBuffer                 commandBuffer,    
    VkPipelineStageFlags            srcStageMask,    
    VkPipelineStageFlags            dstStageMask,    
    VkDependencyFlags               dependencyFlags,    
    uint32_t                        memoryBarrierCount,    
    const VkMemoryBarrier*          pMemoryBarriers,    
    uint32_t                        bufferMemoryBarrierCount,    
    const VkBufferMemoryBarrier*    pBufferMemoryBarriers,    
    uint32_t                        imageMemoryBarrierCount,    
    const VkImageMemoryBarrier*     pImageMemoryBarriers
);

Если не обращать внимания на барьеры памяти и флаги зависимостей в этой функции, то по сути у нас остается два аргумента: srcStageMask и dstStageMask, которые представляют из себя основу модели синхронизации в Vulkan'е. Через них мы делим наш поток команд на две части: то, что происходит до барьера, и то, что происходит после него, тем самым синхронизируя исполнение.

Секция спецификации 6.1 описывает этот процесс в очень странной форме, но мы можем свести все к следующему:

srcStageMask

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

  • vkCmdDispatch(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT)

  • vkCmdCopyBuffer(VK_PIPELINE_STAGE_TRANSFER_BIT)

  • vkCmdDispatch(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT)

  • vkCmdPipelineBarrier(srcStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT)

Мы отсылаемся к командам vkCmdDispatch через COMPUTE_SHADER-стадию, так как именно в ней они исполняются. Даже если мы разделим эти 4 команды между 4 разными vkQueueSubmit'ами, нам все равно будет необходима команда для синхронизации. По факту, в нашем примере мы будем ожидать все команды, которые когда-либо были добавлены в очередь, включая те команды, которые добавлены в текущий командный буфер. srcStageMask выступает фильтром тех команд, завершения которых мы ждем. В данном примере мы имеем дело только с той работой, которая происходит на стадии COMPUTE_SHADER. Как следует из названия, srcStageMask является битовой маской, поэтому абсолютно нормально к примеру ждать завершения одновременно COMPUTE_SHADER и TRANSFER стадий.

в API также есть флаг под названием ALL_COMMANDS, который заставляет ждать завершения всех команд в очереди. ALL_GRAPHICS означает практически то же самое, но относится только к рендер-пассам.

Замечание: В скором времени мы увидим реальный пример использования TOP_OF_PIPE. srcStageMask с использованием TOP_OF_PIPE по сути говорит "ничего не ждать", или, есть быть более точным, мы ждем, когда GPU закончит парсить наши команды, что является noop-ом, так как нам в любом случае придется распарсить все предыдущие команды, прежде чем добраться до команды пайплайн барьера. Когда мы будем рассматривать барьеры памяти, эта стадия окажется нам очень полезной.

dstStageMask

Эта маска представляет из себя вторую половину барьера. Любая работа, подходящая под эту маску и отправленная в очередь после этого барьера, будет ждать завершения работы из srcStageMask, прежде чем начать выполняться. К примеру, если dstStageMask содержит FRAGMENT_SHADER, то vertex-шейдер в последующих командах может исполняться сразу, а ожидание будет происходить только по достижению стадии fragment-шейдера.

Замечание: как аналог TOP_OF_PIPE для srcStageMask, для dstStageMask существует BOTTOM_OF_PIPE. Этот флаг полезен, когда мы хотим указать, что никакая работа не должна ожидать завершения предыдущей. Сейчас этот флаг может казаться бессмысленным, но мы увидим, где это может быть полезно, когда дойдем до барьеров памяти и семафоров.

Небольшой пример

Давайте предположим, что мы записали в буфер и отправили несколько команд в нашу очередь:

  1. vkCmdDispatch

  2. vkCmdDispatch

  3. vkCmdDispatch

  4. vkCmdPipelineBarrier(srcStageMask = COMPUTE, dstStageMask = COMPUTE)

  5. vkCmdDispatch

  6. vkCmdDispatch

  7. vkCmdDispatch

Нашим барьером (4) мы делим набор команд на "до" (1, 2, 3) и "после" (5, 6, 7). Потенциальным вариантом исполнения команд может быть:

  • #3

  • #2

  • #1

  • #7

  • #6

  • #5

(1, 2, 3) могут исполняться в любом порядке, ровно как и (5, 6, 7), но эти наборы команд никогда не пересекутся. На языке спецификации мы говорим, что (1, 2, 3) происходит до (happens before) (5, 6, 7).

https://github.com/KhronosGroup/Vulkan-Docs/wiki/Synchronization-Examples - здесь можно посмотреть, как на практике стадии пайплайна используются для синхронизации.

Event aka разделяющий барьер

Vulkan предоставляет возможность исполнить какую-либо работу в момент ожидания барьера. Ключевая идея vkEvent - дать возможность размещать независимые команды между двумя наборами команд, которые разграничивает барьер, то есть:

  1. vkCmdDispatch

  2. vkCmdDispatch

  3. vkCmdSetEvent(event, srcStageMask = COMPUTE)

  4. vkCmdDispatch

  5. vkCmdWaitEvent(event, dstStageMask = COMPUTE)

  6. vkCmdDispatch

  7. vkCmdDispatch

Набор команд "до" в данном примере это (1, 2), а "после" - (6, 7). В этом случае команда (4) не синхронизирована относительно остальных команд и может исполняться в тот момент, когда GPU ждет завершения команд (1, 2, 3). Для продвинутых compute-вычислений очень полезно знать об этой фиче, но, к сожалению, в данный момент далеко не все GPU и драйвера используют ее.

Цепочка зависимостей исполнения

Это небольшая, но очень важная тема, которая, как мне кажется, далеко не всем очевидна. Основная суть заключается в том, что когда мы используем dstStageMask для описания стадий, которые блокируются синхронизацией, зависимости из srcStageMask, распространяются на далее идущие барьеры, использующие те же стадии в srcStageMask. Иными словами, ожидание стадий из dstStageMask также означает ожидание всего того, что ждет dstStageMask. Легче всего это проиллюстрировать примером ниже:

  1. vkCmdDispatch

  2. vkCmdDispatch

  3. vkCmdPipelineBarrier(srcStageMask = COMPUTE, dstStageMask = TRANSFER)

  4. vkCmdPipelineBarrier(srcStageMask = TRANSFER, dstStageMask = COMPUTE)

  5. vkCmdDispatch

  6. vkCmdDispatch

В данном случае у нас будет зависимость между (1, 2) и (5, 6), так как мы создали цепочку зависимостей COMPUTE -> TRANSFER -> COMPUTE. Ожидая завершения стадииTRANSFER, мы также ждем завершения всего того, что блокирует стадию TRANSFER. Вначале это может показаться неочевидным, но все приобретает смысл, если мы чуть изменим наш пример:

  1. vkCmdDispatch

  2. vkCmdDispatch

  3. vkCmdPipelineBarrier(srcStageMask = COMPUTE, dstStageMask = TRANSFER)

  4. vkCmdMagicDummyTransferOperation

  5. vkCmdPipelineBarrier(srcStageMask = TRANSFER, dstStageMask = COMPUTE)

  6. vkCmdDispatch

  7. vkCmdDispatch

В данном сценарии очевидно что (4) может начаться только после завершения (1, 2), а (6, 7) только после завершения (4). По сути, мы создали цепочку (1, 2) -> (4) -> (6, 7), а если мы положим, что (4) играет роль noop'а, то цепочка превращается в (1, 2) -> (6, 7), о которой мы и говорили изначально.

Стадии пайплайна и рендер-пассы

COMPUTE и TRANSFER команды относительно просты, если дело касается стадий пайплайна. Единственные стадии, через которые проходит их исполнение, это:

  • TOP_OF_PIPE

  • DRAW_INDIRECT (только для indirect compute команд)

  • COMPUTE / TRANSFER

  • BOTTOM_OF_PIPE

Рендер-пассы в этом плане намного интереснее, и очень легко ошибиться, за что отвечает каждая из стадий пайплайна внутри них.

В рендер-пассах есть два "семейства" стадий пайплайна, где одна из них отвечает за обработку геометрии, а другая за процесс растеризации (фрагмент шейдер, операции на фреймбуферах и т.д.).

Помимо TOP_OF_PIPE / BOTTOM_OF_PIPE, у нас есть:

Обработка геометрии

  • DRAW_INDIRECT - парсит indirect-буферы

  • VERTEX_INPUT - получает данные из vertex-буферов и index-буферов

  • VERTEX_SHADER - собственно vertex-шейдер

  • TESSELLATION_CONTROL_SHADER

  • TESSELLATION_EVALUATION_SHADER

  • GEOMETRY_SHADER

Растеризация

  • EARLY_FRAGMENT_TESTS

  • FRAGMENT_SHADER

  • LATE_FRAGMENT_TESTS

  • COLOR_ATTACHMENT_OUTPUT

Больше всего вопросов возникает именно относительно стадий, которые отвечают за растеризацию, так как каждую из них приходится использовать в том или ином случае.

EARLY_FRAGMENT_TESTS

Это та стадия, на которой происходит ранний тест глубины или stencil-тест. Эта стадия не особо полезна, за исключением уникальных случаев с зависимостями внутри фреймбуфера (к примеру в GL_ARB_texture_buffer). На этой стадии рендер-пасс выполняет loadOp для depth/stencil аттачмента (texture attachment).

LATE_FRAGMENT_TESTS

На этой стадии происходит поздний тест глубины, а также выполняется storeOp для depth/stencil аттачмента.

Полезный совет относительно FRAGMENT_TESTS стадий

Вас может немного смутить, что у нас есть две стадии пайплайна, которые отвечают за одно и то же.

Если вы ждете, когда отрендерится карта глубины в рендер-пассе, вам нужно использовать srcStageMask = LATE_FRAGMENT_TESTS_BIT, так как необходимо дождаться завершения storeOp.

Когда вы блокируете рендер-пасс с помощью dstStageMask, просто используйте маску EARLY_FRAGMENT_TESTS | LATE_FRAGMENT_TESTS.

Замечание: dstStageMask = EARLY_FRAGMENT_TESTS также может сработать, так как блокирует исполнение loadOp, но придется повозится с барьерами, чтобы быть уверенным, что доступ к памяти произойдет не раньше LATE_FRAGMENT_TESTS. Но в целом, если вы блокируете более раннюю стадию, то нет ничего плохого в блокировке и более поздней.

COLOR_ATTACHMENT_OUTPUT

Это та стадия, на которой выполняется loadOp, storeOp, разрешение MSAA и блендинг фреймбуфера - то есть все, что затрагивает аттачменты внутри рендер-пасса каким-либо образом. Если вы хотите дождаться завершения записи цвета в текстуру внутри рендер-пассе, используйте srcStageMask = COLOR_ATTACHMENT_OUTPUT, и схожуюdstStageMask, если необходимо заблокировать выполнение рендер-пасса.

Барьеры памяти

Теперь, когда у нас есть понимание барьеров исполнения, давайте двинемся дальше и посмотрим, как работают барьеры памяти.

Порядок исполнения и порядок обращения к памяти происходят совершенно по-разному. GPU известны тем, что содержат несколько независимых (incoherent) кешей, с которым необходимо работать очень аккуратно, чтобы не сломать весь рендеринг. Это означает, что синхронизации исполнения недостаточно для корректной передачи данных между различными частями GPU.

Если вы знакомы с тем, как в C++11 работает порядок обращения к памяти и атомарные переменные, то вам будет чуть легче, однако, насколько мне известно, модель памяти в C++11 не предполагает, что обращения к памяти будут происходить в произвольном порядке. Хоть все обращения к памяти в CPU предполагаются последовательными (coherent), однако важно помнить, что слабый порядок обращений (weak memory ordering) используется практически везде за пределами x86. Vulkan идет еще дальше, и расширяет этот концепт.

Два концепта в спецификации Vulkan'а, которые нам нужно понять, это доступность памяти (available memory) и видимость памяти (visible memory). Это абстракция над тем фактом, что кеши GPU работают независимо, и, если вы знакомы с тем, как в целом работают кеши, то ментальная модель гипотетического GPU, которую я приведу ниже, поможет вам понять эти концепты.

Замечание: Сейчас уже есть формальная модель памяти в Vulkan, которая покрывает все описанное мною ниже. Признаю, я не слишком хорошо изучал ее, чтобы ссылаться на нее здесь, но среднему разработчику и нет необходимости знать во всех подробностях такие детали, если он просто хочет работать с Vulkan.

Кеш L2 / основная память

Для простоты назовем последнюю ступень в иерархии кешей "основной памятью". В GPU к ней подключены все остальные кеши и управляется она контроллером памяти. В итоге этот кеш будет связан со всеми кешами L1 и внешней DDR памятью, которая в свою очередь соединена с контроллером CPU посредством PCI/UMA и т.п.

Когда наш L2 кеш содержит самые последние записанные данные, мы будем говорить, что память доступна (available), потому что L1 кеши, соединенные с L2, могут запросить актуальные данные.

Независимые L1 кеши

В Vulkan'е у нас есть набор флагов, объявленных в enum'е VK_ACCESS. Эти флаги обозначают, каким образом будет осуществляться доступ к памяти. Стадии пайплайна могут обращаться к памяти разными способами, и, скомбинировав набор стадий пайплайна с маской доступа, мы увидим, как много в теории у GPU может быть независимых кешей. Также помним, что у каждого ядра GPU есть свой набор L1 кешей.

Конечно же, реальные GPU имеют лишь часть от потенциально возможного набора кешей и драйвер может упростить обращение к ним в случае надобности. Однако это не позволяет нам опустить информацию в барьерах, ведь мы работаем с API, а не с конкретным железом.

Под секцией 6.1.3 спецификации (таблица 4), вы можете увидеть, какие маски доступа могут быть использованы для каждой стадии пайплайна. В нашей ментальной модели по этим маскам мы пишем или читаем из L1 кеша.

Мы говорим, что память видима (visible) в конкретной стадии пайплайна с определенной маской обращения, если память доступна (available), и мы явно указали, что мы будем обращаться к ней в конкретной стадии пайплайна определенным образом (по access mask).

Как только в какой-либо стадии пайплайна происходит запись в память, L2 кеш более не содержит актуальные данные, и память больше не может считаться доступной. Если другие кеши попытаются прочитать из L2 кеша, то мы не сможем сказать, какие данные они получат. Тот, кто пишет в память, впоследствии должен сделать память доступной, прежде чем данные в памяти снова станут видимыми.

Инвалидация и синхронизация кеша

Вообще, когда мы говорим, что хотим сделать память "доступной", мы имеем в виду синхронизацию кеша (cache flush), а под становлением "видимой" мы подразумеваем инвалидацию кеша (cache invalidation). Думаю, так рассуждать будет немного проще, однако в дальнейшем мы все же будем использовать термины из спецификации.

VkMemoryBarrier

Вернувшись чуть назад, заметим, что в вызове vkCmdPipelineBarrier мы можем передать список глобальных барьеров памяти.

typedef struct VkMemoryBarrier {    
    VkStructureType sType;    
    const void* pNext;    
    VkAccessFlags srcAccessMask;    
    VkAccessFlags dstAccessMask;
} VkMemoryBarrier;

Глобальный барьер памяти покрывает все ресурсы программы и является самым простым барьером памяти. Это значит, что функция vkCmdPipelineBarrier описывает, что следующие вещи происходят в определенном порядке:

  • мы ждем завершения srcStageMask.

  • Ждем, когда все записи в память, объявленные комбинацией srcStageMask + srcAccessMask. завершаться, и память вновь станет доступной.

  • Делаем доступную память видимой из комбинации dstStageMask + dstAccessMask.

  • Разблокируем исполнение стадий из dstStageMask

Распространенная ошибка, которую я наблюдал, это установка флагов на чтение в srcStageMask, что бесполезно. Нет никакого смысла сбрасывать кеш, если из него происходило лишь чтение, так как данные не изменялись.

Доступ к памяти и TOP_OF_PIPE / BOTTOM_OF_PIPE

Никогда не используйте ненулевую AccessMask для этих стадий. Эти стадии не обращаются к памяти, и поэтому ненулевые srcAccessMask и dstAccessMask не представляют никакого смысла, и спецификация запрещает устанавливать их. TOP_OF_PIPE и BOTTOM_OF_PIPE нужны только для барьеров исполнения, не для барьеров памяти.

Разделяемые барьеры памяти

Здесь очень важно отметить, что абсолютно нормально разделять операции, делающие память доступной и операции, делающие ее видимой. Это очень похоже на цепочку зависимостей, о которой мы говорили ранее.

Мы можем к примеру написать что-то подобное:

  • vkCmdDispatch – пишет в SSBO, маска: SHADER_WRITE

  • vkCmdPipelineBarrier(srcStageMask = COMPUTE, dstStageMask = TRANSFER, srcAccessMask = SHADER_WRITE, dstAccessMask = 0)

  • vkCmdPipelineBarrier(srcStageMask = TRANSFER, dstStageMask = COMPUTE, srcAccessMask = 0, dstAccessMask = SHADER_READ)

  • vkCmdDispatch – читаем из SSBO, маска: SHADER_READ

Отметим, что маска стадий пайплайна (stage mask) не может быть нулевой, однако маска доступа (access mask) вполне может быть нулевой.

VkBufferMemoryBarrier

Этот барьер не представляет для нас никакого интереса, так как в нем мы просто ограничиваем доступ к памяти и обращение к ней для определенного буфера. Не знаю ни одного GPU, которое бы как-то это использовало, кажется проще писать VkMemoryBarrier, чем возится с барьерами на отдельные буферы.

VkImageMemoryBarrier

В отличии от VkBufferMemoryBarrier, этот барьер очень важен. В какой-то момент вам потребуется поменять лейаут текстур, а этот процесс как раз описывается в барьере текстуры.

typedef struct VkImageMemoryBarrier {    
    VkStructureType sType;    
    const void* pNext;    
    VkAccessFlags srcAccessMask;    
    VkAccessFlags dstAccessMask;    
    VkImageLayout oldLayout;    
    VkImageLayout newLayout;    
    uint32_t srcQueueFamilyIndex;    
    uint32_t dstQueueFamilyIndex;    
    VkImage image;    
    VkImageSubresourceRange subresourceRange;
} VkImageMemoryBarrier;

Самые интересные части этой структуры - oldLayout и newLayout. Смена лейаута происходит после того, как память становится доступной, но до того, как она становится видимой. Смена лейаута считается операцией чтения/записи, поэтому правила расстановки барьеров требуют, чтобы перед сменой лейаута память была доступной. После смены лейаута память автоматически становится доступной, однако нам все еще необходимо сделать ее видимой. По сути, операция смены лейаута представляет из себя некую манипуляцию над памятью, происходящую где-то в L2 кеше.

Реальный пример использования TOP_OF_PIPE

Теперь мы можем привести реальный пример использования TOP_OF_PIPE. Если мы только что выделили память под текстуру и хотим ее использовать, нам нужно лишь провести смену лейаута, и у нас нет нужды в ожидании чего-либо перед этим процессом. В этом случае нам и окажется полезен TOP_OF_PIPE.

Реальный пример использования BOTTOM_OF_PIPE

Мой любимый пример для такого случая - текстуры из свапчейна, которые необходимо перевести в VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, прежде чем отправить в движок презентации.

После перевода в PRESENT лейаут, мы не будем взаимодействовать с текстурой вплоть до того момента, как повторно получим ее от движка презентаций. Именно для такого случая нам будет полезен BOTTOM_OF_PIPE.

  • srcStageMask = COLOR_ATTACHMENT_OUTPUT (предполагается, что мы отрендерили в свапчейн текстуру из рендер-пасса)

  • srcAccessMask = COLOR_ATTACHMENT_WRITE

  • oldLayout = COLOR_ATTACHMENT_OPTIMAL

  • newLayout = PRESENT_SRC_KHR

  • dstStageMask = BOTTOM_OF_PIPE

  • dstAccessMask = 0

Абсолютно нормально использовать dstStageMask = BOTTOM_OF_PIPE и нулевую маску доступа. У нас нет необходимости делать память видимой в какой-либо из следующих стадий, так как мы в любом случае будем использовать семафоры для синхронизации.

Неявный порядок обращения к памяти - семафоры и фенсы

Семафоры (semaphores) и фенсы (fences) в Vulkan'е действуют схожим образом, но нужны для разных задач. Семафоры предназначены для двусторонней синхронизации GPU и CPU в очередях, когда как фенсы используются только для синхронизации при передачи данных из GPU в CPU.

Эти объекты сигнализируются в vkQueueSubmit. Важной деталью, которую стоит отметить, является то, как семафоры и фенсы взаимодействуют с памятью. Необходимо, чтобы завершились все команды, отправленные в очередь, прежде чем активируется семафор или фенс. Проводя аналогию с барьерами, мы бы сказали, что в таком случае srcStageMask = ALL_COMMANDS. К тому же, в данном случае у нас неявно создается полный барьер на память, иначе говоря вся память становится доступной. На языке барьеров имеем srcAccessMask = MEMORY_WRITE.

Неявные гарантии памяти в vkQueueSubmit

В то время, как сигнал семафору или фенсу работает как полная синхронизация кешей, отправка команд в очередь Vulkan'а делает все записи в память из хоста (со стороны CPU) видимыми для всех стадий пайплайна со всеми возможными вариантами доступа, что по сути своей означает инвалидацию всех кешей, связанных с памятью, которая видна из хоста. Очень распространенная ошибка в данном случае считать, что нужно явно вызывать инвалидацию кешей, если вы пишете из хоста в мапнутый буфер и т.п. На самом деле, переводя на язык барьеров:

  • srcStageMask = HOST

  • srcAccessMask = HOST_WRITE_BIT

  • dstStageMask = TRANSFER

  • dstAccessMask = TRANSFER_READ

Будет неявно происходить в тот момент, когда вы вызываете vkQueueSubmit.

Замечание: Такой барьер будет все же необходим, если вы используете vkCmdWaitEvents и ждете, когда хост сигнализирует ивент через vkSetEvent. В таком случае, вы скорее всего отправляете данные с хоста уже после vkQueueSubmit и соответственно вам необходимо поставить соответствующий барьер. Хоть такой случай довольно редок, но лучше все равно понимать, в каких случаях могут потребоваться подобные конструкции.

Неявные гарантии на память во время ожидания семафора

Сигнализирование семафора делает память доступной, а ожидание семафора - видимой. Этот факт по своей сути означает, что вам не нужен барьер на память, если вы используете семафор, так как связка сигнала/ожидания и так работает, как полный барьер на память. В качестве примера давайте рассмотрим случай, в котором очередь #1 пишет в SSBO в compute-шейдере, а затем этот буфер используется в качестве UBO во fragment-шейдере уже в очереди #2. Также мы будет полагать, что буфер создан с использованием QUEUE_FAMILY_CONCURRENT.

Замечание: если вам нужно передать владение в другое семейство очередей, вам будут нужны барьеры на память, по одному на каждую очередь, чтобы синхронизировать дачу/получение владения.

Очередь #1

  • vkCmdDispatch

  • vkQueueSubmit(signal = mySemaphore)

Здесь нет пайплайн барьеров. После сигнала семафора мы ждем завершения всех команд, и соответственно все записи в память из vkCmdDispatch будут доступны GPU к тому моменту, как семафор будет активирован.

Очередь #2

  • vkCmdBeginRenderPass

  • vkCmdDraw

  • vkCmdEndRenderPass

  • vkQueueSubmit(wait = mySemaphore, pDstWaitStageMask = FRAGMENT_SHADER)

Когда мы ожидаем семафор, мы также указываем, какие стадии пайплайна будут его ожидать, в данном случае это будет стадия FRAGMENT_SHADER. В таком случае все связанные обращения к памяти автоматически станут видимыми, и поэтому мы спокойно можем обращаться из стадии FRAGMENT_SHADER через UNIFORM_READ к нашему буферу, без использования дополнительных барьеров. Семафор сам заботится об этом, что не может нас не радовать.

Цепочка зависимостей исполнения с помощью семафоров

Ровно как с помощью пайплайн барьеров мы можем создавать цепочки зависимостей исполнения, мы также можем использовать для этого и семафоры. pDstWaitStageMask в vkQueueSubmit блокирует нужные стадии, приостанавливая их исполнение.

Если мы создадим пайплайн барьер, в котором srcStageMask ожидает одну из стадий, указанных в маске ожидания vkQueueSubmit, мы также будем ждать сигнала семафора. Это крайне полезно при смене лейаута текстур, полученных из свапчейна. Как нам известно, прежде чем менять лейаут текстуры, нужно дождаться ее получения от движка презентации. Для такого случая лучше всего будет установить pDstWaitStageMask = COLOR_ATTACHMENT_OUTPUT и использовать после сигнала семафора srcStageMask = COLOR_ATTACHMENT_OUTPUT в пайплайн барьере, отвечающим за смену лейаута текстуры свапчейна.

Чтение памяти из хоста

Не смотря на то, что сигнализирование фенса делает всю память доступной для GPU, она все еще недоступна для CPU. Именно для такого случая у нас есть dstStageMask = PIPELINE_STAGE_HOST и dstAccessMask = ACCESS_HOST_READ. Если вы хотите передать данные из GPU в CPU, вам вдобавок ко всему нужно поставить барьер, отвечающий за видимость памяти из хоста. В нашей ментальной модели этому барьеру будет соответствовать синхронизация L2 кеша с основной памятью GPU, из которой в дальнейшем и будем читать CPU по специальному каналу.

Безопасное переиспользование памяти и ее алиасинг

Напомним, что ранее у нас был пример, в ходе которого мы создавали VkImage и меняли его лейаут с UNDEFINED, ожидая завершения стадии TOP_OF_PIPE. Как уже пояснялось, нам не нужно указывать srcAccessMask, так как мы знаем, что память гарантированно доступна. Причина тому тот факт, что синхронизация уже следует из сигнализированного фенса. Чтобы переиспользовать память, нам необходимо проверить с помощью фенса, что GPU более не использует текстуру. Чтобы сигнал был отправлен фенсу, необходимо, чтобы завершились все записи в память и она стала доступной, из чего и следует что память к тому моменту уже можно будет переиспользовать без дополнительных барьеров. Это может показаться вам не столь важным, однако такие рассуждения позволяют нам не потерять рассудок и не начинать бездумно расставлять барьеры на память там, где это не нужно.

Но что нам делать, если мы захотим алиасить память внутри одного буфера команд? Для такого случая существует простое правило: если вы хотите безопасно алиасить память, вам нужно, чтобы вся затронутая память была доступна. Для примерами посмотрим на два VkImage внутри двух рендер-пассов, использующих одну алиаснутую память. В тот момент, когда мы пишем в алиас одной из текстур, все остальные переходят в лейаут UNDEFINED. В спецификации конечно есть исключения, говорящие, когда несколько алиасов могут оставаться валидными одновременно, но мы будем считать, что это к нам никак не относится.

vkCmdPipelineBarrier(    
    image = image1,     
    oldLayout = UNDEFINED,     
    newLayout = COLOR_ATTACHMENT_OPTIMAL,     
    srcStageMask = COLOR_ATTACHMENT_OUTPUT,     
    srcAccessMask = COLOR_ATTACHMENT_WRITE,     
    dstStageMask = COLOR_ATTACHMENT_OUTPUT,     
    dstAccessMask = COLOR_ATTACHMENT_WRITE | COLOR_ATTACHMENT_READ
)

image1 в коде выше содержит мусор, поэтому мы должны сделать перевод лейаута из UNDEFINED. Также нам нужно дождаться завершения всех записей в текстуру, ожидая COLOR_ATTACHMENT_WRITE, прежде чем изменять ее лейаут (мы предполагаем, что данные команды отправляются в цикле каждый кадр). Затем рендер-пасс ждет завершения перевода лейаута через dstStageMask/dstAccessMask.

  • vkCmdBeginRenderPass/EndRenderPass

  • vkCmdPipelineBarrier(image = image2, …)

  • vkCmdBeginRenderPass/EndRenderPass

Мы произвели запись в image1, тем самым инвалидируя image2. Соответственно, после этого нам необходимый схожий барьер и для image2, чтобы перевести ее из UNDEFINED, для чего в свою очередь мы должны дождаться завершения записи в image1. В следующем кадре у image1 снова будет лейаут UNDEFINED, и данный процесс придется повторить.

Внешние зависимости саб-пассов

Рендер-пассы в Vulkan'е вводят концепт внешних (EXTERNAL) зависимостей для саб-пассов. И это чуть ли не самая неверно понимаемая вещь среди всех способов синхронизации в API. Мне бы хотелось посвятить им эту секцию, так как я вижу слишком много разработчиков, которые используют эти зависимости там, где они совершенно не к месту, тем самым плодя только больше багов.

Главной задачей внешних зависимостей саб-пассов является смена лейаута у аттачментов. Если initialLayout не соответствует лейауту текстуры, использованной в первом саб-пассе, рендер-пасс обязан произвести смену лейаута.

Если вы больше ничего не указываете, то смена лейаута произойдет сразу, без ожидания чего-либо, иначе говоря драйвер может вставить зависимость-пустышку с srcStageMask = TOP_OF_PIPE. И это скорее всего не то, чего вы хотите, так как такое положение дел скорее всего приведет к состоянию гонки в приложении. Поэтому не забывайте указывать корректные srcStageMask и srcAccessMask. По своей сути, внешняя зависимость саб-пассов это простой vkCmdPipelineBarrier, который устанавливается вашим драйвером. Такой функционал создавался для того, чтобы у драйвера было больше информации при смене лейаутов, однако идея эта по моему мнению очень сомнительна, как минимум для текущего железа.

Схожим образом внешнюю зависимость саб-пассов можно описать и для случаев с finalLayout. Если finalLayout отличается от того, что был использован в последнем саб-пассе, драйвер автоматически изменит лейаут. В этом случае вам нужно корректно установить dstStageMask/dstAccessMask. Если их опустить, то мы получим BOTTOM_OF_PIPE/0, что на самом деле может быть и приемлемо. Основной причиной использования таких зависимостей будет перевод текстуры свапчейна в finalLayout = PRESENT_SRC_KHR.

В целом, можно вообще игнорировать внешние зависимости саб-пассов, так как они лишь добавляют сложности в программу, а преимуществ практически не дают. К тому же, правила совместимости рендер-пассов предполагают, что даже незначительные изменения, такие как изменение srcStageMask/dstStageMask, требуют создания нового пайплайна! Это совершенно неудобно и глупо, и я надеюсь, что в дальнейших версиях спецификации такие вещи будут исправлены.

Но, не смотря на кажущуюся бесполезность внешних зависимостей саб-пассов, у них есть некоторые уникальные применения, о которых мы и поговорим ниже:

Автоматическая смена лейаута у TRANSIENT_ATTACHMENT текстур

Если вы разрабатываете под мобильные устройства, вы должны использовать transient-текстуры везде, где это возможно. Если вы используете их внутри рендер-пасса, то всегда устанавливайте их initialLayout равным UNDEFINED. Так как использовать их можно только для COLOR_ATTACHMENT_OUTPUT или EARLY / LATE_FRAGMENT_TESTS стадий в зависимости от формата, внешняя зависимость саб-пасса всегда будет обозначать запись в саму себя, что позволяет нам не сильно задумываться о синхронизации transient-текстур. Именно их я использовал в своем движке Granite, и остался очень доволен результатом. Я конечно мог бы вставить барьеры для перевода лейаута, но кода тогда бы вышло гораздо больше.

Автоматическая смена лейаута у свапчейн текстур

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

srcStageMask будет COLOR_ATTACHMENT_OUTPUT, что позволяет нам сделать цепочку зависимостей с помощью семафора, связанного со свапчейном. Для такого случая как раз подойдет внешняя зависимость саб-пасса, в которой мы можем установить dstStageMask = BOTTOM_OF_PIPE, а синхронизация будет происходить уже через наш семафор.

В Granite я использовал именно этот способ.

В завершение

Я надеюсь, что статья оказалась для вас полезной. Пост получился чуть более техническим, чем мне бы хотелось, но тем не менее он явно проще для понимания, чем официальная спецификация.

Комментарии (0)