2.3 逻辑设备与队列族 本节摘要:逻辑设备是把「硬件能力」翻译成「可用账户」的手续:声明队列族与数量、启用设备级扩展、打开特性开关,然后从账户里领取真正执行命令的 VkQueue。本节讲清队列族的查询方法、创建设备的完整材料清单,以及多队列架构如何为后续的异步与多线程铺路。 某个周五下午,渲染线程和资源上传线程挤在同一个队列里互相阻塞,帧率断崖式下跌——排查到底才发现,那块明明带独立传输队列的显卡,被程序当成单队列设备用了。本节的任务就是让你在创建账户那天就把柜台开够:设备选好之后(2.2),把它升级成可以递交命令的 VkDevice,并搞明白队列族这张「网点业务表」怎么读、怎么用。
本节摘要:逻辑设备是把「硬件能力」翻译成「可用账户」的手续:声明队列族与数量、启用设备级扩展、打开特性开关,然后从账户里领取真正执行命令的 VkQueue。本节讲清队列族的查询方法、创建设备的完整材料清单,以及多队列架构如何为后续的异步与多线程铺路。
某个周五下午,渲染线程和资源上传线程挤在同一个队列里互相阻塞,帧率断崖式下跌——排查到底才发现,那块明明带独立传输队列的显卡,被程序当成单队列设备用了。本节的任务就是让你在创建账户那天就把柜台开够:设备选好之后(2.2),把它升级成可以递交命令的 VkDevice,并搞明白队列族这张「网点业务表」怎么读、怎么用。
GPU 内部并非铁板一块:图形引擎、计算单元、DMA 拷贝引擎各自独立,Vulkan 用「队列族」刻画这种物理分工。每个族有四个关键属性:支持的命令位(GRAPHICS、COMPUTE、TRANSFER、SPARSE_BINDING)、该族提供的队列数量、时间戳有效位深、图像传输对齐粒度。
查询与打印一张业务表:
uint32_t famCount = 0; vkGetPhysicalDeviceQueueFamilyProperties(physDev, &famCount, NULL); std::vector<VkQueueFamilyProperties> fams(famCount); vkGetPhysicalDeviceQueueFamilyProperties(physDev, &famCount, fams.data()); for (uint32_t i = 0; i < famCount; ++i) { const auto& f = fams[i]; printf("族 %u: 队列数 %u, 能力 %s%s%s%s\n", i, f.queueCount, (f.queueFlags & VK_QUEUE_GRAPHICS_BIT) ? "图形 " : "", (f.queueFlags & VK_QUEUE_COMPUTE_BIT) ? "计算 " : "", (f.queueFlags & VK_QUEUE_TRANSFER_BIT) ? "传输 " : "", (f.queueFlags & VK_QUEUE_SPARSE_BINDING_BIT) ? "稀疏" : ""); }
桌面独显的典型输出是一条全能族(图形、计算、传输)加若干专用族;部分移动 GPU 则只有一条全能族。关键认识:能力是族的属性,不是队列的属性——同一族里每个队列能力相同;而「全能族」意味着图形队列也能做拷贝,但用图形队列拷贝会占用图形引擎的调度槽位,未必划算。
vkCreateDevice 的材料清单分三部分,每一部分都要求有查询出处。
第一份是队列声明。你要为每个想用的族递交一张 VkDeviceQueueCreateInfo:族编号、开设数量、优先级数组(浮点,同族内调度权重,必须提供但多数驱动只作提示)。族编号来自 2.2 节的查询,数量不得超过该族 queueCount。
第二份是设备级扩展。窗口应用至少要 VK_KHR_swapchain;其余按需,同样必须先查 vkEnumerateDeviceExtensionProperties。与实例级扩展的分界在 1.4 节讲过:表面是实例级的「大厅设施」,交换链是设备级的「网点柜台」。
第三份是特性开关。核心特性填 VkPhysicalDeviceFeatures,扩展特性走 pNext 链。下面是完整骨架:
float prio = 1.0f; VkDeviceQueueCreateInfo qInfos[2] = {}; qInfos[0].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; qInfos[0].queueFamilyIndex = graphicsFamily; // 来自查询 qInfos[0].queueCount = 1; qInfos[0].pQueuePriorities = &prio; // 若存在独立传输族,多开一个柜台给异步上传 uint32_t qInfoCount = 1; if (transferFamily != graphicsFamily && transferFamily != UINT32_MAX) { qInfos[1].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; qInfos[1].queueFamilyIndex = transferFamily; qInfos[1].queueCount = 1; qInfos[1].pQueuePriorities = &prio; qInfoCount = 2; } const char* devExts[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; VkPhysicalDeviceFeatures2 feats2 = {VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2}; feats2.features.samplerAnisotropy = VK_TRUE; // 例:采样器各向异性 // 扩展特性示例(需先查询支持): // VkPhysicalDeviceTimelineSemaphoreFeatures tl = {...}; feats2.pNext = &tl; VkDeviceCreateInfo ci = {VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO}; ci.pNext = &feats2; // 用 features2 时 pApplicationInfo 走实例 ci.queueCreateInfoCount = qInfoCount; ci.pQueueCreateInfos = qInfos; ci.enabledExtensionCount = 1; ci.ppEnabledExtensionNames = devExts; VkDevice device; VK_CHECK(vkCreateDevice(physDev, &ci, NULL, &device)); // 领取柜台:族编号加槽位号,一一对应声明里的内容 vkGetDeviceQueue(device, graphicsFamily, 0, &graphicsQueue); if (qInfoCount == 2) vkGetDeviceQueue(device, transferFamily, 0, &transferQueue);
账户注销也有规矩:vkDestroyDevice 要求它名下的派生对象都已销毁或至少不再被使用。「结清业务再销户」这条规则贯穿全册,第 3 章的资源、第 5 章的命令池都要照办。
单个全能队列的程序,渲染、上传、计算串成一条队;开足柜台后,三类工作可以物理并行。典型分工:
| 柜台 | 提交内容 | 收益 |
|---|---|---|
| 图形队列 | 场景渲染、后处理、呈现 | 独占图形引擎 |
| 独立传输队列 | 纹理与网格的异步上传 | 上传不挤占渲染调度 |
| 独立计算队列 | 后期计算、遮挡剔除 | 与图形管线交错执行 |
代价不是免费的:跨队列的依赖必须用信号量显式连线(第 6 章的主题),柜台越多,连线越多。经验法则是先用单一图形队列把功能跑对,确认存在并行收益后再加柜台——柜台数量是性能决策,不是架构炫技。
背景:一个场景编辑器在拖入大模型时画面冻结,提交记录显示上传拷贝占了图形队列的大块时间,渲染命令排在拷贝后面挨个等。
操作:分三步开通异步通道。先在 2.2 的评估里确认存在不含图形与计算位的独立传输族(很多独显都有一条);然后在 vkCreateDevice 里为该族追加一张队列声明(上文 qInfos[1] 的分支);最后把资源上传代码整体迁到新队列,与渲染队列之间用信号量交接完成事件。
结果:拖入模型期间帧率只轻微波动,不再冻结。上传期间渲染队列全程保持流动,交接点只发生在「渲染确实需要用到新资源」的那一帧。
解读:收益的前提是「上传与渲染在时间上本来就可以错开」——如果渲染下一帧立刻就要用刚上传的纹理,信号量会把两条队列重新串起来,收益归零。异步队列放大的是已有的并行性,不能创造它。
变式:没有独立传输族的设备(多数集显)无法做这步优化,但同族多队列仍能让拷贝命令与绘制命令在不同槽位排队,收益打折存在。判断依据始终是那张业务表,不是愿望。
其一,把「族支持图形」与「族支持呈现」混为一谈:呈现支持要用 vkGetPhysicalDeviceSurfaceSupportKHR 按族逐个确认,全能族通常支持,但不是必然。其二,queueCount 声明得慷慨、领取时偷懒:声明了两条却只领一条没问题,反过来领取超出声明数量的队列是非法的。其三,优先级数组悬空:pQueuePriorities 指向的数组必须活到调用结束,栈上临时数组最稳妥。