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

Vulkan. Работа с памятью и буферами

“Well! I’ve often seen a cat without a grin,” thought Alice;

“but a grin without a cat! It’s the most curious thing I ever saw in all my life!”

Память

В этой части мы рассмотрим выделение и освобождение памяти в Vulkan, а также создание буферов в памяти GPU.

В отличии от CPU, где есть просто оперативная память, на GPU есть несколько различных типов памяти. Поэтому когда мы делаем запрос на выделение памяти, то нам нужно указать не только объем выделяемой памяти, но и память какого типа мы хотим получить.

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

Фактически данная структура состоит из двух массивов - массив типов памяти (memoryTypes, число элементов в нем задается в memoryTypeCount) и массив куч (heap) памяти (memoryHeaps, число элементов в нем задается в memoryHeapCount).

Каждая куча (heap) это некоторый объем памяти, причем одна куча может содержать в себе память нескольких различных типов. Тип памяти описывается всегда двумя величинами - индексом кучи heapIndex, из которой можно выделять память этого типа, и флагами propertyFlags, описывающими свойства данной памяти.

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

Обычно для данных, постоянно хранящихся в памяти GPU и используемых только GPU (таких как вершинные и индексные буфера, текстуры и т.п.) лучше всего подходит тип памяти с только одним флагом - VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT.

Комбинации флагов VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT и VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT обычно соответствует некоторая область памяти (обычно фиксированного размера), в которую CPU может писать на каждом кадре. Этот тип лучше всего подходит для постоянно изменяющихся данных, таких как uniform-буфера и динамические вершинные буфера.

Комбинация VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT и VK_MEMORY_PROPERTY_HOST_COHERENT_BIT обычно обозначает память CPU, которая непосредственно видна GPU. Если нет памяти предыдущего типа, то этот тип лучше всего подходит для постоянно изменяющихся данных.

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

         // get memory properties for physical device
    VkPhysicalDeviceMemoryProperties    memoryProperties;

    vkGetPhysicalDeviceMemoryProperties ( physicalDevice, &memoryProperties );

        // print info about all found types and heaps
    std::cout << "Memory properties:" << std::endl;
    std::cout << "\tmemoryTypeCount " << memoryProperties.memoryTypeCount << std::endl;

    for ( uint32_t i = 0; i < memoryProperties.memoryTypeCount; i++ )
    {
        std::cout << "\t" << i << ": ";

            // most efficient for device access
        if ( memoryProperties.memoryTypes [i].propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT )
            std::cout << "device local, ";

            // can be mapped for host access
        if ( memoryProperties.memoryTypes [i].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT )
            std::cout << "host visible, ";

            // host cache management commands are not needed to flush host writes to device
            // or make device writes visible to host
        if ( memoryProperties.memoryTypes [i].propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT )
            std::cout << "host coherent, ";

            // is cached on the host, uncached memory is slower but are always host coherent
        if ( memoryProperties.memoryTypes [i].propertyFlags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT )
            std::cout << "host cached, ";

            // only allows device access
        if ( memoryProperties.memoryTypes [i].propertyFlags & VK_MEMORY_PROPERTY_PROTECTED_BIT )
            std::cout << "protected (1.1), ";

        std::cout << "Heap index " << memoryProperties.memoryTypes [i].heapIndex << std::endl;
    }

    std::cout << "memoryHeapCount " << memoryProperties.memoryHeapCount << std::endl;

    for ( uint32_t i = 0; i < memoryProperties.memoryHeapCount; i++ )
    {
        std::cout << "\t Heap " << i << ": ";

        std::cout << "\tSize " << memoryProperties.memoryHeaps [i].size;

            // heap belongs to device-only memory
        if ( memoryProperties.memoryHeaps [i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT )
            std::cout << " device local, ";

            // for logical device representing more than one physical device
        if ( memoryProperties.memoryHeaps [i].flags & VK_MEMORY_HEAP_MULTI_INSTANCE_BIT )
            std::cout << " multi instance ";

        std::cout << std::endl;
    }

Для своего GPU (GeForce 3060 RTX) я получаю следующую информацию:

Memory properties:

    memoryTypeCount 5

    0: Heap index 1

    1: device local, Heap index 0

    2: host visible, host coherent, Heap index 1

    3: host visible, host coherent, host cached, Heap index 1

    4: device local, host visible, host coherent, Heap index 2

memoryHeapCount 3

    Heap 0: Size 12726566912 device local,

    Heap 1: Size 8558542848

    Heap 2: Size 224395264 device local,

При работе с такими объектами как буфера и изображения (image) у нас будет постоянно возникать необходимость выделения памяти с заданными свойствами. При этом в эти свойства входят не только требуемые флаги, но и маска допустимых типов памяти. Поэтому будет удобно сразу написать функцию, которая по этим свойствам буфет сама подбирать подходящий тип памяти.

    // return index of memory type with given property bits
    // satifying type bitmask
uint32_t findMemoryType ( VkPhysicalDevice physicalDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties )
{
    VkPhysicalDeviceMemoryProperties    memoryProperties;

        // get memory properties for the device
    vkGetPhysicalDeviceMemoryProperties ( physicalDevice, &memoryProperties );

        // check every nonmasked type to have all required bits
    for ( uint32_t i = 0; i < memoryProperties.memoryTypeCount; i++ )
        if ( (typeFilter & (1 << i)) && (memoryProperties.memoryTypes[i].propertyFlags & properties) == properties ) 
            return i;

        // type was not found
    fatal () << "GpuMemory: failed to find suitable memory type! " << properties;

    return 0;   // we won't get here, but compiler complains about returning no value
}

Для выделения памяти в Vulkan служит функция vkAllocateMemory. Она принимает на вход логическое устройство, указатель на структуру типа VkMemoryAllocateInfo, указывающие требуемые свойства выделяемой памяти, а также свой аллокатор памяти CPU и адрес переменной типа VkDeviceMemory, в которую будет записан хэндл выделенной памяти.

VkResult vkAllocateMemory ( VkDevice device, const VkMemoryAllocateInfo * info,
                            const VkAllocationCallbacks * allocator, VkDeviceMemory * memory );

В структуре VkMemoryAllocateInfo в полях allocationSize и memoryTypeIndex мы передаем требуемый объем памяти в байтах и номер типа памяти соответственно.

При помощи команды vkFreeMemory мы можем освободить выделенную на устройстве память. Если выделялась память, поддерживающая отображение в адресное пространство CPU, то мы можем выполнить это отображение при помощи функции vkMapMemory. В результате ее вызова мы получим указатель в память CPU, по которому можно обращаться к этой памяти со стороны CPU.

        // allocate memory with required properties
    VkMemoryAllocateInfo    allocInfo  = {};
    VkDeviceMemory          memory     = VK_NULL_HANDLE;
    VkMemoryPropertyFlags   properties = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ;

    memRequirements.memoryTypeBits = properties;
    memRequirements.size           = size;

    allocInfo.sType           = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize  = memRequirements.size;
    allocInfo.memoryTypeIndex = findMemoryType ( physicalDevice, memRequirements.memoryTypeBits, properties );

    if ( vkAllocateMemory ( device, &allocInfo, nullptr, &memory ) != VK_SUCCESS )
        fatal () << "Cannot allocate memory for buffer" << std::endl;

        // map allocated memory into CPU address space and copy data
    void * data;
    std::vector<float>  testData ( 1024 );

    for ( int i = 0; i < 1024; i++ )
        testData [i] = i;

    vkMapMemory   ( device, memory, 0, size, 0, &data );
    memcpy        ( (char *) data,  testData.data (), size );
    vkUnmapMemory ( device, memory );

Обратите внимание, что операция отображения памяти в адресное пространство CPU довольно дорогая. Поэтому в тех случаях, когда нам нужно часто обновлять данные (например в случае uniform-буферов) имеет смысл сразу после выделения памяти получить ее отображение и держать это отображение и использовать все время работы (то что в OpenGL называется persistent mapping).

Также очень важной особенностью выделения памяти устройства является то, что во-первых это тоже довольно дорогая операция и во-вторых, гарантируется только 4096 успешных выделений. Поэтому нормальной практикой является памяти сразу большими блоками, после чего память из блоков раздается по частям. Но при этом есть момент, на который нужно обязательно обратить внимание - у буферов, изображений и других объектов могут быть требования по выравниванию выделенной памяти. Сама команда vkAllocateMemory гарантирует выравнивание, достаточное для любых целей. Но когда вы начинаете раздавать большой блок по частям, то важно учитывать требования на выравнивание данных. Обычно требования на выравнивание заметно отличается для буферов и изображений и имеет смысл выделять память под них из разных блоков.

Если у вас память без флага VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, то возможно что какие-то операции записи в эту память находятся в кэше и поэтому не могут быть сразу видны. Для гарантии того, что все подобные записи будут видны на стороне GPU, служит функция vkFlushMappedMemoryRanges:

VkResult vkFlushMappedMemoryRanges(
    VkDevice                                    device,
    uint32_t                                    memoryRangeCount,
    const VkMappedMemoryRange*                  pMemoryRanges );

После вызова этой функции гарантируется, что все записи, со стороны CPU, в области памяти, заданные в pMemoryRanges будут видны на GPU. Структура VkMappedMemoryRange задается следующим образом:

typedef struct VkMappedMemoryRange {
    VkStructureType    sType;
    const void*        pNext;
    VkDeviceMemory     memory;
    VkDeviceSize       offset;
    VkDeviceSize       size;
} VkMappedMemoryRange;

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

VkResult vkInvalidateMappedMemoryRanges (
    VkDevice                                    device,
    uint32_t                                    memoryRangeCount,
    const VkMappedMemoryRange*                  pMemoryRanges );

Буфера

Буфера в Vulkan - это области памяти для хранения данных. Есть много различных типов буферов, таких как вершинных буфера, индексные буфера, uniform-буфера и другие. Важным отличием буферов в Vulkan от буферов в OpenGL является то что они не выделяют сами под себя память - ее необходимо явно выделить и прикрепить к буферу (а также освободить в конце). Каждый буфер в Vulkan идентифицируется при помощи значения типа VkBuffer.

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

        // now create storage buffer for 1024 floats
    VkBuffer                buffer     = VK_NULL_HANDLE;
    VkBufferCreateInfo      bufferInfo = {};
    uint32_t                size       = 1024 * sizeof ( float );
    VkMemoryRequirements    memRequirements;

    bufferInfo.sType       = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size        = size;
    bufferInfo.usage       = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if ( vkCreateBuffer ( device, &bufferInfo, nullptr, &buffer ) != VK_SUCCESS )
        fatal () << "Buffer: failed to create buffer!" << std::endl;;

Поле usage задает допустимое применение буфера и является битовой маской. Допустимыми битами являются VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT, VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT, VK_BUFFER_USAGE_VETEX_BUFFER_BIT и VK_BUFFER_USAGE_INDEX_BUFFER_BIT. Также есть еще дополнительные биты вводимые расширениями Vulkan.

Поле flags позволяет задавать дополнительные параметры буфера и также является битовой маской, содержащей такие биты как VK_BUFFER_CREATE_SPARSE_BINDING_BIT, VK_BUFFER_CREATE_SPARSE_RESIDENCY_BIT и VK_BUFFER_CREATE_SPARSE_ALIASED_BIT. Могут быть и другие биты, вводимые более поздними версиями Vulkan и расширениями.

Поле size задает размер буфера в байтах. Поле sharingMode возможно ли обращаться к буферу сразу из очередей, принадлежащим различным семействам очередей. Допустимыми значениями являются VK_SHARING_MODE_EXCLUSIVE (доступ возможен только из одного семейства очередей) и VK_SHARING_MODE_CONCURRENT (доступ возможен из нескольких семейств очередей).

Поля queueFamilyIndexCount и pQueueFamilyIndices служат для задания массива индексов для семейств очередей, из которых возможен доступ к этому буферу. Игнорируется если sharingMode равно VK_SHARING_MODE_EXCLUSIVE.

После заполнения этой структуры мы создаем буфер при помощи вызова vCreateBuffer. Уничтожить буфер можно при помощи вызова vkDestroyBuffer.

VkResult vkCreateBuffer ( VkDevice device, const VkBufferCreateInfo * info, const VkAllocationCallbacks *, VkBuffer * );

Следующим шагом после создания буфера будет выделение памяти для него и прикрепление выделенной памяти к буферу. При помощи функции vkGetBufferMemoryRequirements можно получить информацию о требуемой для буфера памяти. Это информация возвращается через поля структуры VkMemoryRequirements.

Полями этой структуры являются size (размер в байтах), alignment (требуемое выравнивание в байтах) и memoryTypeBits. Последнее поле является битовой маской , содержащей по одному биту для каждого типа памяти. Бит, равный единице, обозначает, что можно выделять память этого типа под данный буфер.

        // now we need to allocate memory for this buffer
        // so get memory requirements for it
    vkGetBufferMemoryRequirements ( device, buffer, &memRequirements );

        // allocate memory with required properties (
    VkMemoryAllocateInfo    allocInfo  = {};
    VkDeviceMemory          memory     = VK_NULL_HANDLE;
    VkMemoryPropertyFlags   properties = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ;

    memRequirements.memoryTypeBits = properties;
    memRequirements.size           = size;

    allocInfo.sType           = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize  = memRequirements.size;
    allocInfo.memoryTypeIndex = findMemoryType ( physicalDevice, memRequirements.memoryTypeBits, properties );

    if ( vkAllocateMemory ( device, &allocInfo, nullptr, &memory ) != VK_SUCCESS )
        fatal () << "Cannot allocate memory for buffer" << std::endl;

        // map allocated memory into CPU address space and copy data
    void * data;
    std::vector<float>  testData ( 1024 );

    for ( int i = 0; i < 1024; i++ )
        testData [i] = i;

    vkMapMemory   ( device, memory, 0, size, 0, &data );
    memcpy        ( (char *) data,  testData.data (), size );
    vkUnmapMemory ( device, memory );

        // now we can bind allocated and initialized memory to our buffer
    vkBindBufferMemory ( device, buffer, memory, 0 );

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