技术博客

GPU 内存分配子系统:内存池与 staging buffer 的上传路径

第 45 / 149 篇 · 2025-05-29 · godot · 渲染Vulkan源码分析

CPU 数据进显存要过两道门:第一道是"分配在哪",VMA 池决定显存碎片和分配速度;第二道是"怎么进去",staging buffer 决定上传路径和同步成本。drivers/vulkan/rendering_device_driver_vulkan.cpp 里这两块的代码都不长,但注释很少,值得单独读一遍。

第一道门:VMA 池化分配

Godot vendor 了 Vulkan Memory Allocator(VMA),RD 创建纹理和缓冲时不直接调 vkAllocateMemory——那个调用每次都要走驱动的分配器,慢且容易产生显存碎片(GPU 显存没有虚拟内存兜底,碎片直接等于可用容量缩水)。VMA 的做法是按资源类型分池:

$$ pool_{type} = f(usage, mem\_property) $$

device-local 的纹理进一类池,host-visible 的 staging 缓冲进另一类池,池内用 buddy 式的块切分。texture_create 的耗时大头因此不在分配本身,而在首次使用的布局转换。池化带来的限制也要知道:所有池的总和就是 VkPhysicalDeviceMemoryProperties 报的设备内存上限,超了不是分配失败——VMA 会把分配转发到 host 内存兜底,帧率断崖式下跌但程序还活着,这也是"显存爆了但没报错"现象的来源。

第二道门:staging buffer 中转

CPU 写的数据不能直连 GPU 显存(device-local 内存 CPU 不可寻址),必须经一块 host-visible 的"中转仓":

CPU memcpy → staging buffer(host-visible,可写)
           → vkCmdCopyBufferToImage(transfer 命令)
           → GPU 显存里的 VkImage(device-local)

buffer_update / texture_update 系列 API 的背后都是这条路。关键在配额:staging buffer 是一块预先分配好尺寸的池(默认按帧预算凑一块,多块循环使用),不是每次上传现分配。好处是上传命令录制完就能复用块——同步点设在块回收上:一块 staging 被标记"此帧已用",等对应 fence 走完(GPU 消费完毕)才允许覆写。

两个经典故障都出在配额上。上传超过单块尺寸的大贴图:RD 会拆成多次 copy,中间没有额外同步,多块并发的场景下若某帧上传总量超池容量,会等上一帧 fence——这就是"加载瞬间卡一帧"的机制。另一个方向:块被回收前 CPU 又写同一块(引擎侧的配额 bug 或驱动慢),会出现 CPU 覆写 GPU 正在读的数据,症状是贴图花屏或 device lost,且只在特定硬件上复现。

和 transfer 队列的关系

初始化时选出的 transfer 队列(三-3 篇)在上传路径上是可选加速器:texture_update 默认走主 graphics 队列的 transfer 阶段,有独立 transfer 族时部分路径可以并行提交,贴图上传和渲染主命令互不阻塞。这是"开了 Godot 看显存占用差不多、加载速度却不同"的隐藏变量——队列结构影响的是并发度,不是带宽本身。

排查口诀

贴图相关问题先问三件事:显存池还有没有余量(VMA 统计接口能看)、staging 配额够不够帧内的上传峰值(看加载瞬间的帧时间尖峰)、数据进显存后布局转换对不对(RD 层自动管,但自定义 RDD 后端要自己保证)。这三问覆盖了 4.x 里九成"资源加载类"渲染 bug 的根因。

CPU 内存 解码后的像素数据 staging buffer host-visible 循环块池 VkImage(device-local) VMA 池化分配 块内切分 memcpy copy 命令 块回收同步 fence 完成后才允许覆写同一块 配额超限行为 等上一帧 fence = 加载帧时间尖峰 显存超上限时 VMA 转发 host 内存兜底:帧率暴跌但不报错

本文代码引自 Godot 4.2.2-stable 的 drivers/vulkan/rendering_device_driver_vulkan.cpp 及 vendored VMA,流程为示意简化。