3.1 内存类型、堆与分配策略


文档摘要

3.1 内存类型、堆与分配策略 本节摘要:Vulkan 把显存管理整个交给应用:堆是物理钱袋,内存类型是带访问属性的套餐,资源必须显式挂靠到内存上。本节教你读懂本机的「内存地理志」、写出通用的类型筛选函数、按访问模式选择三种经典放置方案,并用子分配思路应对碎片与分配数上限。 别把显存当成一块均匀的大内存——把它当成一条商业街:有便宜量大的仓库(设备本地堆)、有 CPU 能直接推门进来的门面(主机可见内存)、还有 CPU 进得去但走得慢的储藏间(无缓存的主机内存)。把纹理放进门面、把每帧要改的顶点放进仓库,都是常见的街道级错误。本节是第 3 章的地基:一切资源手续都从这里讲的「地理志」开始。

3.1 内存类型、堆与分配策略

本节摘要:Vulkan 把显存管理整个交给应用:堆是物理钱袋,内存类型是带访问属性的套餐,资源必须显式挂靠到内存上。本节教你读懂本机的「内存地理志」、写出通用的类型筛选函数、按访问模式选择三种经典放置方案,并用子分配思路应对碎片与分配数上限。

别把显存当成一块均匀的大内存——把它当成一条商业街:有便宜量大的仓库(设备本地堆)、有 CPU 能直接推门进来的门面(主机可见内存)、还有 CPU 进得去但走得慢的储藏间(无缓存的主机内存)。把纹理放进门面、把每帧要改的顶点放进仓库,都是常见的街道级错误。本节是第 3 章的地基:一切资源手续都从这里讲的「地理志」开始。

一、堆与类型:两层查询模型

内存信息由 vkGetPhysicalDeviceMemoryProperties 一次性给出,分两层。memoryHeaps 是物理钱袋:每块 GPU 至少一条设备本地堆(VIDMEM),集成显卡的本地堆常与系统内存共享;堆只有大小和 DEVICE_LOCAL 标志,访问属性不在这一层。memoryTypes 是套餐:每条类型挂在某个堆上,并带一串属性位——DEVICE_LOCAL(GPU 优先访问、带宽最高)、HOST_VISIBLE(CPU 可映射)、HOST_COHERENT(CPU 写入无需手动刷新即可见)、HOST_CACHED(CPU 读走缓存,回读快)、LAZILY_ALLOCATED(按需提交,常见于移动端 tile 显存)。

VkPhysicalDeviceMemoryProperties mem; vkGetPhysicalDeviceMemoryProperties(physDev, &mem); printf("堆数量 %u, 类型数量 %u\n", mem.memoryHeapCount, mem.memoryTypeCount); for (uint32_t i = 0; i < mem.memoryTypeCount; ++i) { const auto& t = mem.memoryTypes[i]; printf("类型 %u: 堆 %u, 属性 %s%s%s%s%s\n", i, t.heapIndex, (t.propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) ? "本地 " : "", (t.propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) ? "主机可见 " : "", (t.propertyFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) ? "一致 " : "", (t.propertyFlags & VK_MEMORY_PROPERTY_HOST_CACHED_BIT) ? "缓存 " : "", (t.propertyFlags & VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT) ? "懒分配" : ""); }

桌面独显的典型输出大约十条类型:纯本地(GPU 专用)、本地加可见(BAR 类,新一代显卡才有)、纯可见(系统内存回退)、可见加缓存加一致(上传与回读首选)等。这张表因设备而异,这正是一切索引必须查询的原因。

图 3-1:典型独显的堆与类型分布

图 3-1:典型独显的堆与类型分布

二、类型筛选函数与三步绑定

一切分配都经过同一个筛选逻辑:给定需求属性与可选的兜底属性,找到首个满足的类型下标。这个函数值得每个项目都有一份:

uint32_t findMemoryType(uint32_t typeBits, VkMemoryPropertyFlags want) { VkPhysicalDeviceMemoryProperties mem; vkGetPhysicalDeviceMemoryProperties(physDev, &mem); for (uint32_t i = 0; i < mem.memoryTypeCount; ++i) { // typeBits 的第 i 位为 1 表示该类型可用于此资源 bool usable = (typeBits & (1u << i)) != 0; bool matched = (mem.memoryTypes[i].propertyFlags & want) == want; if (usable && matched) return i; } return UINT32_MAX; // 没有套餐满足需求:要么需求写错,要么设备不支持 }

资源落地是固定的三步:创建资源领「资产证」(此时只登记尺寸与用途,不占显存)、按资产证给出的 typeBits 分配内存、绑定两者。以缓冲为例:

VkBufferCreateInfo bi = {VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; bi.size = sizeof(Vertex) * vertexCount; bi.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT; VkBuffer buffer; vkCreateBuffer(device, &bi, NULL, &buffer); // 第一步:领证 VkMemoryRequirements req; vkGetBufferMemoryRequirements(device, &buffer, &req); // 对齐与类型掩码在此 VkMemoryAllocateInfo ai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO}; ai.allocationSize = req.size; ai.memoryTypeIndex = findMemoryType(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); // 第二步:选型分配 VkDeviceMemory memory; vkAllocateMemory(device, &ai, NULL, &memory); vkBindBufferMemory(device, buffer, memory, 0); // 第三步:挂靠

第三步之前资源「不存在于任何地方」——这解释了为什么三步不能合并:类型掩码与对齐要求是创建资源之后才确定的,先分配后创建的写法没有可靠依据。

三、三种经典放置方案

按访问模式选型,业界沉淀了三种套路。方案 A,本地加暂存:正式数据放设备本地堆,上传时先写一条主机可见一致类型的暂存缓冲,再用拷贝命令搬到本地。传输成本最高但运行时带宽最优,适合纹理、静态网格等「一次上传、万次读取」。方案 B,可见直写:每帧要改的数据(动态 uniform、粒子参数)直接放主机可见一致类型,CPU 映射后每帧覆写,GPU 直读。免拷贝但 GPU 访问带宽低,适合小数据。方案 C,可见缓存回读:查询与统计结果(遮挡查询、时间戳)放主机可见加缓存类型,GPU 写、CPU 读,缓存让回读不拖累总线。

映射与刷新的细节各归其位:映射用 vkMapMemory 拿指针;一致类型写完即可,非一致类型要 vkFlushMappedMemoryRanges 才对设备可见;CPU 读方向对称地用 INVALIDATE。这三类操作的坑在第 8 章「常见陷阱」集中演练。

四、案例:分配数上限逼出的子分配器

背景:一个粒子编辑器为每个粒子系统单独 vkAllocateMemory,演示场景加载到后期报 VK_ERROR_TOO_MANY_OBJECTS。查询发现该设备 maxMemoryAllocationCount 为 4096——这是规范允许驱动设置的常见上限,编辑器的材质加粒子组合远超此数。

操作:改造分两层。底层引入子分配器(Vulkan Memory Allocator 库的思路):大块向驱动申请(每块数百 MB),块内按对齐切小块发给资源;上层把「每资源一分配」改成「按用途分池」——纹理池、网格池、动态数据池各持若干大块。销毁路径同步改造:资源销毁只归还小块,大块的回收按引用计数。

结果:同一演示场景的分配次数从近万降到两位数,加载时间因减少驱动调用也变短。之后两年内没有再触碰分配上限。

解读:vkAllocateMemory 是昂贵调用(驱动要向操作系统要显存、建立页表映射),它的成本与「每次都办新证」的模式天然冲突。子分配的本质是把驱动的昂贵手续摊薄——这也解释了为什么几乎所有 Vulkan 引擎都有自己的内存管理模块。

变式:专用传输堆(resizeBAR)支持的新显卡允许 CPU 直接访问本地堆,方案 A 的暂存搬运对纹理可以省略;但该特性需要设备与驱动双支持,探测不到就退回暂存路线——又是「查询先行」。

五、策略小结

给一个务实起点:接手任何项目,先把 VMA 这类成熟分配器接进来,再谈自研;自研分配器的正当理由是特殊访问模式(超大资源流式加载、多 GPU 差异化放置),而不是「想省个依赖」。无论用谁的分配器,本节的地理志、三步绑定与三种放置方案都是理解它行为的底账。

本节要点回顾

  • 两层模型:堆是物理容量、类型是属性套餐,资源可用的类型掩码在创建资源后才确定。
  • 三步绑定:领证、按掩码分配、挂靠,顺序不可调换,合并的尝试没有可靠依据。
  • 三种放置:本地加暂存跑大流量、可见直写养动态数据、可见缓存接回读,按访问模式对号入座。
  • 分配昂贵:分配数上限(常见 4096)与单次分配成本共同决定必须子分配,成熟分配器是第一选择。
  • 刷新与失效:非一致内存的双向可见都要手动手续,一致类型免手续但只覆盖主机可见场景。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U