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

Vulkan. Устройства

"Begin at the beginning" the King said gravely

"and go on till you come to the end: then stop

Устройства и очереди

После создания экземпляра (вместе со списком слоев валидации и списком расширений), а также настройки debug callback нас ждет работа с устройством и очередями.

На самом деле Vulkan различает физические и логические устройства. Физическое устройство (physical device) это реальный GPU, подключенный к системе. И в системе их может быть несколько - например у вас установлен дискретный GPU, но также есть и интегрированный GPU. И вы можете одновременно работать сразу с несколькими GPU, но мы далее будем рассматривать работу только с одним. Логическое устройство это как интерфейс, через который вы можете работать с физическим устройством. Обратите внимание, что у вас может быть сразу несколько логических устройств связанных с одним физическим.

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

Для начала мы просто соберем информацию обо всех доступных физических устройствах в системе. Для этого мы воспользуемся функцией vkEnumeratPhysicalDevices. Как и с остальными функциями vkEnumerate мы вызовем ее два раза - один раз для получения числа физических устройств в системе и второй - для получения списка самих устройств. Обратите внимание, что каждому физическому устройству соответствует значение типа VkPhysicalDevice.

VkPhysicalDevice    physicalDevice = VK_NULL_HANDLE;
uint32_t        deviceCount    = 0;

    // get number of physical device in system
vkEnumeratePhysicalDevices ( instance, &deviceCount, nullptr );

    // allocate memory for them
std::vector<VkPhysicalDevice>   physDevices ( deviceCount );

    // get actual devices
vkEnumeratePhysicalDevices ( instance, &deviceCount, physDevices.data () );

std::cout << "Found " << deviceCount << "physical devices" << std::endl;

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

VkPhysicalDevice    physicalDevice = VK_NULL_HANDLE;
uint32_t            deviceCount    = 0;

    // get number of physical devices in the system
vkEnumeratePhysicalDevices ( instance, &deviceCount, nullptr );

    // allocate memory for them
std::vector<VkPhysicalDevice>   physDevices ( deviceCount );

    // get actual devices
vkEnumeratePhysicalDevices ( instance, &deviceCount, physDevices.data () );

std::cout << "Found " << deviceCount << "physical devices" << std::endl;

    // print some info about each device in turn
for (const auto& device : physDevices ) 
{
    VkPhysicalDeviceProperties deviceProperties;
    VkPhysicalDeviceFeatures   deviceFeatures;

    vkGetPhysicalDeviceProperties ( device, &deviceProperties );        
    vkGetPhysicalDeviceFeatures   ( device, &deviceFeatures   );

    std::cout << deviceProperties.deviceName 
              << (deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU ? 
                 " discrete" : " embedded" ) << std::endl;

    if ( deviceFeatures.geometryShader )
        std::cout << "Geometry shaders supported" << std::endl;

    if ( deviceFeatures.tessellationShader )
        std::cout << "Tessellation shaders supported" << std::endl;
}

Все запросы на рендеринг, которые мы будем отправлять на GPU, будут помещаться в одну из очередей GPU. На самом деле каждый GPU имеет много различных очередей и они группируются в семейства (families) очередей. Семейство очередей объединяет очереди с одинаковыми свойствами. Разные семейства очередей могут обрабатывать разные запросы к GPU, такие как графические команды, счетные задачи, копирование данных и т.п. При помощи функции vkGetPhysicalDeviceQueueFamilyProperties (очень похожей на рассмотренные ранее функции vkEnumerate) мы можем для каждого физического устройства узнать сколько у него есть различных семейств очередей и какие свойства у каждого семейства.

uint32_t queueFamilyCount = 0;

    // get number of queue families for physical device        
vkGetPhysicalDeviceQueueFamilyProperties ( physicalDevice, &queueFamilyCount, nullptr );

    // allocate memory for them
std::vector<VkQueueFamilyProperties> queueFamilies ( queueFamilyCount );

    // get actual info on all families
vkGetPhysicalDeviceQueueFamilyProperties ( physicalDevice, &queueFamilyCount, queueFamilies.data () );

std::cout << "Queue families " << queueFamilyCount << std::endl;

    // dump info about each family in turn
for ( const auto& family : queueFamilies )
{
    std::cout << "\tCount " << family.queueCount;

    	// does the queues support graphics
    if ( family.queueFlags &  VK_QUEUE_GRAPHICS_BIT )
        std::cout << " Graphics ";

    	// does the queues support compute
    if ( family.queueFlags & VK_QUEUE_COMPUTE_BIT )
        std::cout << " Compute";

    	// does the queues support memory transfer
    if ( family.queueFlags & VK_QUEUE_TRANSFER_BIT )
        std::cout << " Transfer";

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

Вот информация, выданная для моего GPU (GeForce 3060 RTX):

Queue families 3

    Count 16 Graphics Compute Transfer

    Count 2 Transfer

    Count 8 Compute Transfer

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

Есть и более гибкая стратегия - мы разделяем все свойства и возможности на обязательные и желательные. Далее для каждого устройства, обладающего всеми обязательными характеристиками, по строим по необязательным некоторый рейтинг устройства - насколько оно хорошо нам подходит. Потом мы просто выбираем из списка устройство с наибольшим рейтингом. В примере ниже мы требуем наличия хотя бы одного семейства очередей с поддержкой вычислений (compute).

int isDeviceSuitable ( VkPhysicalDevice device )
{
    uint32_t            queueFamilyCount = 0;
    int                 computeFamily    = -1;
    int                 i                = 0;

    	// get number of queue families
    vkGetPhysicalDeviceQueueFamilyProperties ( device, &queueFamilyCount, nullptr );

    	// reserve memory of info on all famiies
    std::vector<VkQueueFamilyProperties> queueFamilies ( queueFamilyCount );

    	// get actual families properties
    vkGetPhysicalDeviceQueueFamilyProperties ( device, &queueFamilyCount, queueFamilies.data () );

    	// check whether family supports compute
    for ( const auto& queueFamily : queueFamilies )
    {
        if ( computeFamily == -1 && queueFamily.queueFlags & VK_QUEUE_COMPUTE_BIT )
            return i;

        i++;
    }

    return -1;
}

// . . .

    int computeFamily  = -1;

    	// look for first device which mets our requirements (compute)
    for ( const auto& dev : physDevices )
        if ( (computeFamily = isDeviceSuitable ( dev ) ) > -1 ) 
        {
            physicalDevice = dev;
            break;
        }

    	// check whether we have found any physical device
    if ( physicalDevice == VK_NULL_HANDLE )
        fatal () << "VulkanWindow: failed to find a suitable GPU!";

Создание логического устройства

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

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

VkDeviceQueueCreateInfo	queueCreateInfo     = {};
float			queuePriorities []  = { 1.0f };

queueCreateInfo.sType            = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = computeFamily;
queueCreateInfo.queueCount       = 1;
queueCreateInfo.pQueuePriorities = &queuePriority;

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

Итак, для создания логического устройства нам надо передать рассмотренные ранее параметры в функцию vkCreateDevice, задав их в полях структуры VkDeviceCreateInfo. Для случая, когда нам нужна только очередь для счета (compute queue) мы получаем следующий код:

VkDeviceCreateInfo	devCreateInfo = {};
VkDevice		device	      = VK_NULL_HANDLE;

devCreateInfo.sType                   = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
devCreateInfo.pQueueCreateInfos       = &queueCreateInfo;
devCreateInfo.queueCreateInfoCount    = 1;
devCreateInfo.pEnabledFeatures        = &deviceFeatures;
devCreateInfo.enabledExtensionCount   = 0;
devCreateInfo.ppEnabledExtensionNames = nullptr;
devCreateInfo.enabledLayerCount       = 0;

if ( vkCreateDevice ( physicalDevice, &devCreateInfo, nullptr, &device ) != VK_SUCCESS )
    fatal () << "VulknaWindow: failed to create logical device!";

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

VkQueue computeQueue    = VK_NULL_HANDLE;

vkGetDeviceQueue ( device, computeFamily, 0, &computeQueue  );

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