steps3D - Tutorials - Vulkan. Часть 4

Vulkan. Часть 4. Шейдеры, pipeline layout, compute pipeline

“Curiouser and curiouser!”

Alice

Создание layout-ов

Теперь мы приступаем к созданию вычислительного конвейера (compute pipeline), который будет содержать в себе все основные параметры для выполнения вычислительного шейдера.

Первое что нам нужно сделать это создать так называемый pipeline layout - объект типа VPipelineLayout. Данный объект содержит информацию обо всех передаваемых в шейдер ресурсов (дескрипторов) - текстуры, буфера. Это как-бы схема для задания наших ресурсов (текстур и буферов), потом можно будет проверять соответствуют и реально переданные ресурсы объявленным в pipeline layout. Каждый такой отдельный ресурс задается при помощи экземпляра структуры VkDescriptorSetLayoutBinding:

typedef struct VkDescriptorSetLayoutBinding {
    uint32_t              binding;
    VkDescriptorType      descriptorType;
    uint32_t              descriptorCount;
    VkShaderStageFlags    stageFlags;
    const VkSampler*      pImmutableSamplers;
} VkDescriptorSetLayoutBinding;

Первое поле (binding) задает точку подключения этого ресурса и также должно быть задано и в шейдере, использующем данный ресурс. Поле descriptorType задает тип ресурса, в нашем случае это будет VK_DESCRIPTOR_TYPE_STORAGE_BUFFER. Поле descriptorCount задает число дескрипторов данного типа. Обычно он применяется когда соответствующий ресурс является массивом, тогда descriptorCount задает число элементов в этом массиве.

Важным полем является stageFlags. Оно задает на каких стадиях конвейера данный ресурс (дескриптор) будет использоваться. Это просто битовая маска, где каждый бит соответствует определенному шагу конвейера, в нашем случае будет всего один шаг VK_SHADER_STAGE_COMPUTE_BIT. Знание о том, на каких стадиях конвейера будут использоваться ресурсы помогает оптимизировать работу.

Поле pImmutableSamplers используется при работе с текстурами и сэмплерами и позволяет создать массив неизменяемых сэмплеров. Мы этим полем не будем пользоваться.

Поскольку у нас может быть несколько различных ресурсов, то мы создадим массив из этих структур (т.е. std::vector<VkDescriptorSetLayoutBinding>) и запишем в него описания всех используемых ресурсов. В нашем случае будет всего один такой ресурс - storage buffer. Обратите внимание, что здесь мы задаем не конкретные ресурсы, а только их тип и точку привязки.

После этого шага нам нужно заполнить вспомогательную структуру VkDescriptorSetLayoutCreateInfo. Через нее мы фактически передаем массив описателей отдельных ресурсов. Заполнив ее мы можем создать descriptor set layout при помощи вызова функции vkCreateDescriptorSetLayout.

// prepare layout bindings data
std::vector<VkDescriptorSetLayoutBinding>   bindings;
VkDescriptorSetLayoutBinding                layoutBinding       = {};
VkDescriptorSetLayout                       descriptorSetLayout = VK_NULL_HANDLE;

layoutBinding.binding            = 0;
layoutBinding.descriptorCount    = 1;       // one storage buffer
layoutBinding.descriptorType     = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
layoutBinding.pImmutableSamplers = nullptr;
                                                // for compute stage only
layoutBinding.stageFlags         = VK_SHADER_STAGE_COMPUTE_BIT;

bindings.push_back ( layoutBinding );

    // now we're ready to create descriptor set layout 
    // for our bindings array
if ( bindings.size () > 0 )
{
    VkDescriptorSetLayoutCreateInfo layoutInfo = {};

    layoutInfo.sType        = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
    layoutInfo.bindingCount = (uint32_t) bindings.size ();
    layoutInfo.pBindings    = bindings.data  ();

    if ( vkCreateDescriptorSetLayout ( device, &layoutInfo, nullptr, &descriptorSetLayout ) != VK_SUCCESS )
        fatal () << "DescSetLayout: failed to create descriptor set layout!";
}

Следующим шагом будет создание pipeline layout - фактически он просто строится по массиву descriptor set layout.

VkPipelineLayoutCreateInfo      pipelineLayoutInfo = {};

pipelineLayoutInfo.sType          = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 0;

        // set this layout for pipeline layout
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts    = &descriptorSetLayout;

        // now create pipeline layout
VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;

if ( vkCreatePipelineLayout ( device, &pipelineLayoutInfo, nullptr, &pipelineLayout ) != VK_SUCCESS )
    fatal () << "Pipeline: failed to create pipeline layout!";

Загрузка шейдера

В Vulkan используются шейдеры, уже откомпилированные в специальный низкоуровневый бинарный формат под названием SPIR-V (Standart Portable Intermediate Representation). и уже этот формат драйвером компилируется в код для конкретного GPU. Это позволяет убрать все проблемы OpenGL, связанные с наличием различных компиляторов, не всегда одинаково трактующих исходный код. В качестве исходного языка используется GLSL с небольшими изменениями и он компилируется в SPIR-V при помощи утилиты glslangValidator. На самом деле эта утилита умеет компилировать не только GLSL, но HLSL. На самом деле теоретически можно использовать любой язык для написания шейдеров (включая свой собственный), при условии, что его можно откомпилировать в SPIR-V. Ниже приводится вычислительный шейдер, который мы будем использовать в этом примере.

#version 450
#extension GL_ARB_separate_shader_objects : enable

layout( local_size_x = 1024 ) in;

layout(std430, binding = 0) buffer In 
{
    float values [];
};

void main() 
{
    uint idx = gl_GlobalInvocationID.x;
    
    if ( idx >= 1024 )
        return;
    
    values [idx] ++;        
}

Сам загруженный шейдер в Vulkan называется шейдерным модулем и обозначается при помощи значения типа VkShaderModule. Сама процедура загрузки очень проста, поэтому ниже просто приводится соответствующий код. Обратите внимание, что код в формате SPIR-V трактуется как просто массив из uint32_t.

   // load compute shader from .spv file
VkShaderModule  shader = VK_NULL_HANDLE;
Data            shaderSource ( "shaders/test-compute.comp.spv" );

VkShaderModuleCreateInfo moduleCreateInfo = {};

moduleCreateInfo.sType    = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
moduleCreateInfo.codeSize = shaderSource.getLength ();
moduleCreateInfo.pCode    = reinterpret_cast<const uint32_t*>( shaderSource.getPtr () );

if ( vkCreateShaderModule ( device, &moduleCreateInfo, nullptr, &shader ) != VK_SUCCESS )
    fatal () << "Failed to create shader module! ";

Создание compute pipeline

Теперь, когда мы выполнили все предварительные шаги, мы можем приступить к созданию объекта-конвейера соответствующего типа. Вычислительный конвейер (pipeline) имеет тип VkPipeline и создается при помощи вызова функции vkCreateComputePipeline. На вход это функции надо передать структуру типа VkComputePipelineCreateInfo, задающие все свойства создаваемого конвейера. Для вычислительного конвейера таких свойств всего два - layout и stage. Первое поле содержит ранее созданный объект pipeline layout, а второе поле является экземпляром структуры VkPipelineShaderStageInfo, задающей шейдер, используемый на единственной стадии этого конвейера.

    // prepare to create compute pipeline
VkPipeline                  pipeline        = VK_NULL_HANDLE;
VkComputePipelineCreateInfo pipelineInfo    = {};

    // we have only one stage - compute 
stageInfo.sType     = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stageInfo.stage     = VK_SHADER_STAGE_COMPUTE_BIT;
stageInfo.module    = shader;
stageInfo.pName     = "main";

    // prepare data
pipelineInfo.sType  = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO;
pipelineInfo.stage  = stageInfo;
pipelineInfo.layout = pipelineLayout;

    // now we can create compute pipeline
if ( vkCreateComputePipelines ( device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &pipeline ) != VK_SUCCESS )
    fatal () << "Pipeline: failed to create compute pipeline!";

Подготовка descriptor set

Ранее мы уже создали descriptor set layout, который содержит информацию о том, какие типы ресурсов (дескрипторов в терминологии Vulkan) будут использоваться в конвейере. Но это была информация именно о типах ресурсов без указания конкретных используемых ресурсов. Теперь нам надо суметь задать конкретный ресурс - storage buffer для нашего конвейера.

Для задания конкретных используемых ресурсов (текстур, буферов) служат множества дескрипторов (descriptor set). В отличии от других объектов Vulkan они не создаются, а выделяются из пулов, которые необходимо заранее создать. При создании этого пула мы должны заранее сообщить максимальное количество ресурсов каждого типа и общее максимальное число. В нашем случае будет всего один ресурс - это наш storage buffer. Для задания максимального числа служит структура типа VkDescriptorPoolSize.

    // prepare actual descriptor set
    // start with createing descriptor pool
VkDescriptorPool                    descriptorPool = VK_NULL_HANDLE;
VkDescriptorSet                     set            = VK_NULL_HANDLE;
std::vector<VkDescriptorPoolSize>   poolSizes      = {};
uint32_t                            maxSets        = 10;
VkDescriptorPoolSize                item;

item.type            = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
item.descriptorCount = 1;

poolSizes.push_back ( item );

VkDescriptorPoolCreateInfo descSetPoolInfo = {};

descSetPoolInfo.sType         = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
descSetPoolInfo.poolSizeCount = (uint32_t) poolSizes.size ();
descSetPoolInfo.pPoolSizes    = poolSizes.data();
descSetPoolInfo.maxSets       = maxSets;

if ( vkCreateDescriptorPool ( device, &descSetPoolInfo, nullptr, &descriptorPool ) != VK_SUCCESS )
    fatal () << "DescriptorPool: failed to create descriptor pool!";

Теперь мы можем создать descriptor set из нашего пула. Дя этого служит функция VkAllocateDescriptorSet, которая на вход получает структуру VkDescriptorSetAllocateInfo, содержащую информацию о выделяемых дескрипторах.

    // now allocate descriptor set
VkDescriptorSetAllocateInfo descSetAllocInfo = {};

descSetAllocInfo.sType              = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
descSetAllocInfo.descriptorPool     = descriptorPool;
descSetAllocInfo.descriptorSetCount = 1;
descSetAllocInfo.pSetLayouts        = &descriptorSetLayout;

if ( vkAllocateDescriptorSets ( device, &descSetAllocInfo, &set ) != VK_SUCCESS )
    fatal () << "DescriptorSet: failed to allocate descriptor sets!";

После того, как мы создали descriptor set дя задания нашего буфера, нам нужно сделать так, чтобы он ссылался именно на наш storage buffer. Для записи значений в descriptor set служит функция vUpdateDescritprSets

void vkUpdateDescriptorSets(
    VkDevice                                    device,
    uint32_t                                    descriptorWriteCount,
    const VkWriteDescriptorSet*                 pDescriptorWrites,
    uint32_t                                    descriptorCopyCount,
    const VkCopyDescriptorSet*                  pDescriptorCopies);

Фактически мы передаем ей на вход два массива - первый задает какие значения нужно записать в дескрипторы, а второй - какие значения нужно скопировать из других descriptor sets. Мы рассмотрим сейчас только запись значений. Каждая отдельная запись задается при помощи одной структуры типа VkWriteDescriptorSet.

std::vector<VkWriteDescriptorSet>    writes;
VkDescriptorBufferInfo             * descBufferInfo   = new VkDescriptorBufferInfo {};
VkWriteDescriptorSet                 descriptorWrites = {};

descBufferInfo->buffer = buffer;
descBufferInfo->offset = 0;
descBufferInfo->range  = size;

descriptorWrites.sType           = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites.dstSet          = set;
descriptorWrites.dstBinding      = 0;
descriptorWrites.dstArrayElement = 0;
descriptorWrites.descriptorType  = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
descriptorWrites.descriptorCount = 1;
descriptorWrites.pBufferInfo     = descBufferInfo;

writes.push_back ( descriptorWrites );

vkUpdateDescriptorSets ( device, static_cast<uint32_t>(writes.size()), writes.data(), 0, nullptr );

Создание и заполнение командного буфера

Все запросы на вычисления и рендеринг мы передаем при помощи командных буферов (command buffer). В эти буфера записываются соответствующие команды и при этом для них выполняются все необходимые проверки. Далее эти буфера помещаются в очереди GPU для выполнения. Важно то, что эти буфера могут переиспользоваться - мы можем один раз создать такой буфер и потом использовать его все время рендеринга. И все, связанные с ним проверки, будут выполнены только в момент его создания.

Командные буфера также выделяются из пула командных буферов, который необходимо заранее создать.

    // create command pool for our (logical) device
VkCommandPoolCreateInfo poolInfo    = {};
VkCommandPool           commandPool = VK_NULL_HANDLE;

poolInfo.sType            = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = computeFamily;

if ( vkCreateCommandPool ( device, &poolInfo, nullptr, &commandPool ) != VK_SUCCESS )
    fatal () << "VulkanWindow: failed to create command pool!" << Log::endl;

После создания пула командных буферов нам нужно выделить из него один буфер и записать туда команду для запуска вычислительного шейдера (вместе с заданием соответствующего descriptor set).

// allocate command buffer from pool
VkCommandBuffer                 commandBuffer = VK_NULL_HANDLE;
VkCommandBufferAllocateInfo     commandBufferAllocInfo = {};

commandBufferAllocInfo.sType              = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
commandBufferAllocInfo.commandPool        = commandPool;
commandBufferAllocInfo.level              = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
commandBufferAllocInfo.commandBufferCount = 1;

if ( vkAllocateCommandBuffers ( device, &commandBufferAllocInfo, &commandBuffer ) != VK_SUCCESS )
    fatal () << "VulkanWindow: failed to allocate command buffers!";

Теперь мы готовы записать в этот буфер нужные нам команды. Обратите внимание, что перед записью команды запуска вычислительного шейдера (vkCmdDispatch) необходимо сначала выбрать соответствующий конвейер и подключить descriptor set.

    // fill command buffer with compute dispatch command
VkCommandBufferBeginInfo beginInfo = {};

beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;

if ( vkBeginCommandBuffer ( commandBuffer, &beginInfo ) != VK_SUCCESS )
    fatal () << "VulkanWindow: failed to begin recording command buffer!";

    // bind compute pipeline, descriptor set and issue dispatch command
vkCmdBindPipeline       ( commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline );
vkCmdBindDescriptorSets ( commandBuffer, VK_PIPELINE_BIND_POINT_COMPUTE, pipelineLayout, 0, 1, &set, 0, nullptr );
vkCmdDispatch           ( commandBuffer, 1000, 1, 1 );
vkEndCommandBuffer      ( commandBuffer );

После того, как мы подготовили наш командный буфер, мы можем поместить его в очередь для вычислений нашего устройства при помощи команды vkQueueSubmit. Она получает основные параметры из структуры VkSubmitInfo, кроме того она также получает fence (или VK_NULL_HANDLE если он не нужен). Fence это один из объектов для синхронизации в Vulkan (всего их два - fence и semaphore) и он будет взведен, когда все переданные команды завершат свое выполнение. Объект fence служит для того, чтобы CPU мог ожидать завершение работы GPU. ы будем использовать его для того, чтобы дождаться того момента, когда наш вычислительный шейдер завершит свою работу и данные будут готовы для чтения со стороны CPU.

VkResult vkQueueSubmit(
    VkQueue                                     queue,
    uint32_t                                    submitCount,
    const VkSubmitInfo*                         pSubmits,
    VkFence                                     fence);

Основная информация о передаваемых командных буферах содержится в массиве структур VkSubmitInfo:

typedef struct VkSubmitInfo {
    VkStructureType                sType;
    const void*                    pNext;
    uint32_t                       waitSemaphoreCount;
    const VkSemaphore*             pWaitSemaphores;
    const VkPipelineStageFlags*    pWaitDstStageMask;
    uint32_t                       commandBufferCount;
    const VkCommandBuffer*         pCommandBuffers;
    uint32_t                       signalSemaphoreCount;
    const VkSemaphore*             pSignalSemaphores;
} VkSubmitInfo;

Поля commandBufferCount и pCommandBuffers задают массив передаваемых на выполнение командных буферов. Поля waitSemaphoreCount и pWaitSemaphores задают массив семафоров (еще один объект синхронизации в Vulkan). Прежде чем начать выполнение переданных командных буферов (в этом экземпляре VkSubmitInfo) необходимо дождаться всех указанных в этом массиве семафоров. В массиве pSignalSemaphores (размером signalSemaphoreCount) для каждого из этих семафоров указывается стадия конвейера, на которой нужно ждать этот семафор.

Аналогично signalSemaphoreCount и pSignalSemaphores задают массив семафоров, которые необходимо взвести по завершению выполнения переданных в этой структуре командных буферов.

Гарантируется, что на выполнения командные буфера будут передаваться в том порядке, в котором они были указаны (сперва буфера из первой VkSubmitInfo в том порядке, в котором они заданы, потом буфера из второй VkSubmitInfo и т.д.). Массивы семафоров помогают решать случат зависимости - например один командный буфер зависит от результатов работы другого командного буфера. В этом случае мы помещаем второй буфер (от которого есть зависимость) в первую структуру VkSubmitInfo и задаем семафор, который нужно взвести по окончании работы (т.е. помещаем его в pSignalSemaphores). Командный буфер, который зависит, мы помещаем во вторую структуру VkSubmitInfo и для него задаем этот же семафор, но уже в списке ожидания (т.е. в pWaitSemaphores). Тогда гарантируется, что зависящий командный буфер выполнится строго после того, завершит свою работу буфер, от которого он зависит. Таким образом можно в одной команде vkQueueSubmit передать на выполнение сразу много командных буферов со сложными зависимостями между ними.

Итак, давайте сейчас посмотрим как это делается. Мы сперва создаем и сбрасываем fence, после чего передаем наш командный буфер на выполнение в compute queue нашего GPU.

    // we will need fence to wait till results will be ready,
    // so we create a fence
VkFence                 fence             = VK_NULL_HANDLE;
VkFenceCreateInfo       fenceCreateInfo   = {};

fenceCreateInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;

if ( vkCreateFence ( device, &fenceCreateInfo, nullptr, &fence ) != VK_SUCCESS )
    fatal () << "Semaphore: error creating" << Log::endl;

    // start submitting out command buffer to compute queue
submitInfo.sType                = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.waitSemaphoreCount   = 0;
submitInfo.pWaitSemaphores      = 0;
submitInfo.pWaitDstStageMask    = nullptr;
submitInfo.commandBufferCount   = 1;
submitInfo.pCommandBuffers      = &commandBuffer;
submitInfo.signalSemaphoreCount = 0;
submitInfo.pSignalSemaphores    = nullptr;

vkResetFences ( device, 1, &fence );        // ????

if ( vkQueueSubmit ( computeQueue, 1, &submitInfo, fence ) != VK_SUCCESS )
    fatal () << "failed to submit draw command buffer!";

    // wait till results will be ready 
if ( vkWaitForFences ( device, 1, &fence, VK_TRUE, DEFAULT_FENCE_TIMEOUT  ) != VK_SUCCESS )
    fatal () << "Waiting for fence failed" << Log::endl;

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

// copy them back to CPUs memory
std::vector<float>  outData ( 1024 );

vkMapMemory   ( device, memory, 0, size, 0, &data );
memcpy        ( outData.data (), data,  size );
vkUnmapMemory ( device, memory );

    // free all allocated resources
vkDestroyBuffer              ( device, buffer, nullptr );
vkFreeMemory                 ( device, memory, nullptr );
vkDestroyPipeline            ( device, pipeline,       nullptr );
vkDestroyPipelineLayout      ( device, pipelineLayout, nullptr );
vkDestroyShaderModule        ( device, shader, nullptr );
vkDestroyFence               ( device, fence, nullptr );
vkFreeCommandBuffers         ( device, commandPool, 1, &commandBuffer );
vkDestroyCommandPool         ( device, commandPool, nullptr);
vkDestroyDescriptorSetLayout ( device, descriptorSetLayout, nullptr );
vkDestroyDescriptorPool      ( device, descriptorPool, 0 );
vkDestroyDevice              ( device, nullptr );

destroyDebugUtilsMessengerEXT ( instance, debugMessenger, nullptr );

vkDestroyInstance ( instance, nullptr );

Код к этой статье (исходный код на С++ и проект для cmake) можно скачать по этой ссылке.