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

Vulkan. Библиотека VMA (Vulkan Memory Allocator)

"You useed to be much more muchier

You've lost your muchness".

The Mad Hatter

Как вы уже могли ранее заметить выделение памяти GPU в Vulkan дело очень непростое. Есть разные типы памяти и разные требования к ней (для буферов и изображений). И очень важно выбрать правильный тип памяти, иначе вы получите ошибку на этапе выполнения или просто неэффективную работу на GPU.

Кроме этого есть еще одно хитрое место - по стандарту Vulkan вы можете гарантированно выделить только 4096 выделенных участков памяти. Возможно у вас GPU/драйвер поддерживает большее число максимальных выделений памяти, но всеже не на столько много, как этого бы хотелось (или требовалось). И, что также справедливо, выделение и освобождение памяти GPU это дорогая операция, поэтому лучше минимизировать число этих операций.

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

К счастью есть довольно удобная и простая библиотека VMA (Vulkan Memory Allocator), которая может всю эту работу взять на себя. Подобно библиотеке STB она вся содержится в одном заголовочном файле - vk_mem_alloc.h. В этом файле содержатся и описания всех функций и их реализации. Поэтому один раз перед включением этого файла нужно включить определить макрос VMA_IMPLEMENTATION.

#define  VMA_IMPLEMENTATION
#include <vk_mem_alloc.h>

Обратите внимание, что данный файл включает файл vulkan/vulkan.h, а он (под Windows) включает файл windows.h. У этой библиотеки С-интерфейс, хотя внутри она написана на самом деле на С++.

Инициализация

Библиотеку VMA необходимо проинициализировать перед использованием и деиницилизировыать перед завершением работы с Vulkan (иначе вы получите предупреждение о не освобожденной памяти в слоях валидации).

Инициализация состоит в создании аллокатора, который будет использоваться для выделения памяти:

vmaDestroyAllocator  ( allocator );

Перед завершением работы созданный аллокатор необходимо уничтожить при помощи вызова функции vmaDestroyAllocator:

VmaAllocator        allocator;
VmaAllocatorCreateInfo  allocatorCreateInfo = {};

allocatorCreateInfo.vulkanApiVersion = VK_API_VERSION_1_0;
allocatorCreateInfo.physicalDevice   = physicalDevice;
allocatorCreateInfo.device           = device;
allocatorCreateInfo.instance         = instance;

if ( vmaCreateAllocator ( &allocatorCreateInfo, &allocator ) != VK_SUCCESS )
    fatal () << "vmasCreateAllocator failure" << std::endl;

Создание буферов и изображений

Ранее мы отдельно создавали буфер/изображение, запрашивали свойства требуемой памяти, выделяли ее и подключали к буферу/изображению. Библиотека VMA предлагает для создания буферов и изображений использовать специальные функции vmaCreateBuffer и vmaCreateImage.

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

VkImageCreateInfo   imageInfo       = {};
VkImage     image           = VK_NULL_HANDLE;
VmaAllocationCreateInfo allocCreateInfo = {};
VmaAllocation       allocation      = VK_NULL_HANDLE;

imageInfo.sType         = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType     = VK_IMAGE_TYPE_2D;
imageInfo.extent.width  = 512;
imageInfo.extent.height = 512;
imageInfo.extent.depth  = 1;
imageInfo.mipLevels     = 1;
imageInfo.arrayLayers   = 1;
imageInfo.format        = VK_FORMAT_R8G8B8A8_UNORM;
imageInfo.tiling        = VK_IMAGE_TILING_OPTIMAL;
imageInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
imageInfo.usage         = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT |  VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
imageInfo.samples       = VK_SAMPLE_COUNT_1_BIT;
imageInfo.sharingMode   = VK_SHARING_MODE_EXCLUSIVE;

allocCreateInfo.usage    = VMA_MEMORY_USAGE_AUTO;
allocCreateInfo.flags    = 0;
allocCreateInfo.priority = 1.0f;

if ( vmaCreateImage ( allocator, &imageInfo, &allocCreateInfo, &image, &allocation, nullptr ) != VK_SUCCESS ) 
    fatal () << "vmaImage: Cannot create image" << std::endl;

Для уничтожения созданных таким образом буферов и изображений также следует использовать функции из библиотеки VMA - vmaDestroyBuffer и vmaDestroyImage.

vmaDestroyImage  ( allocator, image,       allocation );

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

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

VMA_ALLOCATION_CREATE_MAPPED_BIT - обозначает, что мы хотим, чтобы эта память была все время отображена в память CPU.

VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT - память может быть отображена в адресное пространство CPU и пригодна для последовательной записи.

VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT - отображение в адресное пространство CPU должно поддерживать произвольный доступ на чтение и запись.

Поле usage позволяет задать планируемое использование выделяемой памяти. Основными возможными значениями являются:

VMA_MEMORY_USAGE_AUTO - выбрать наиболее подходящий тип памяти автоматически. Скорее всего вам нужно именно это значение. Если вы хотите, чтобы выделенную память можно было отобразить в адресное пространство CPU, то надо задать флаги *_CREATE_MAPPED_BIT и один из флагов *_HOST_ACCESS_RANDOM_BIT или *_SEQUENTIAL*.

VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE - по возможности выбрать память GPU

VMA_MEMORY_USAGE_AUTO_PREFER_HOST - по возможности выбрать память CPU

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

void vmaSetAllocationUserData ( VmaAllocator allocator, VmaAllocation allocation, void * pUserData );
void vmaSetAllocationName     ( VmaAllocator allocator, VmaAllocation allocation, const char * name );

В любой момент можно получить информацию о выделенном участке памяти при помощи функций vmaGetAllocationInfo и vmaGetAllocationMemoryProperties.

void vmaGetAllocationInfo             ( VmaAllocator allocator, VmaAllocation allocation, VmaAllocationInfo * pAllocationInfo );
void vmaGetAllocationMemoryProperties ( VmaAllocator allocator, VmaAllocation allocation, VkMemoryPropertyFlags * flags );

Информацию о выделенном участке памяти возвращается в полях структуры VmaAllocationInfo:

memoryType - индекс типа памяти

deviceMemory - хэндл бока памяти

offset - смещение внутри выделенного блока памяти

size - размер выделенного ока памяти в байтах

pMappedData - указатель на начало отображенной памяти

pUserData - заданный пользовательский указатель

pName - заданное пользователем имя

Для отображения выделенной памяти в адресное пространство CPU служат функции:

VkResult vmaMapMemory   ( VmaAllocator allocator, VmaAllocation allocation, void **ppData );
void     vmaUnmapMemory ( VmaAllocator allocator, VmaAllocation allocation );

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

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