Vulkan 内存管理的三个核心难题:
- 资源与内存分离:
VkBuffer/VkImage和VkDeviceMemory是独立对象,必须手动绑定 - 分配数量上限:
VkPhysicalDeviceLimits::maxMemoryAllocationCount低至 4096,不能每个资源独占一个VkDeviceMemory - 内存类型复杂:不同 GPU 厂商、不同内存堆(VRAM/RAM/BAR)特性不同,需要手动选型
VMA 的本质是一个 sub-allocator:申请大块 VkDeviceMemory,然后在内部切分给上层使用。
Application
│
▼
VmaAllocator ─── 全局唯一入口,per-VkDevice
│
├── Default Memory Pools (按 memoryTypeIndex 分组)
│ └── VmaBlockVector
│ └── VmaDeviceMemoryBlock[]
│ └── VmaBlockMetadata (算法实现)
│
├── Custom Pools (用户自定义)
│ └── 同上结构,但固定 memoryTypeIndex
│
└── Dedicated Allocations (独占整块 VkDeviceMemory)
核心对象:
| 对象 | 职责 |
|---|---|
VmaAllocator |
全局管理器,线程安全入口 |
VmaBlockVector |
管理同一 memoryType 下的所有 Block |
VmaDeviceMemoryBlock |
封装一个 VkDeviceMemory,含元数据 |
VmaBlockMetadata |
追踪 Block 内部的已用/空闲区间 |
VmaAllocation |
返回给用户的句柄,表示一次分配 |
当调用 vmaCreateBuffer() 时,内部走以下流程:
1. 在现有 Block 中找合适的空闲区间
↓ 失败
2. 申请新的 VkDeviceMemory Block(preferred size)
↓ 失败
3. 尝试 size/2, size/4, size/8 的 Block
↓ 仍失败
4. 回退到 Dedicated Allocation(独占整块 VkDeviceMemory)
↓ 仍失败
5. 换一种兼容的 memoryType,重新从步骤 1 开始
↓ 全部失败
6. 返回 VK_ERROR_OUT_OF_DEVICE_MEMORY
Two-Level Segregated Fit,来自实时系统领域学术论文。 目标:O(1) 分配/释放 + 接近 best-fit 的碎片率。
FL (First Level)
0 1 2 3 4 5 ... (按 2^n 划分)
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
SL: [0][1][2][3] [0][1][2][3] ... (每级再细分 2^SL 个桶)
│
▼
Free Block 双向链表
- FL(First Level):
floor(log2(size)),即 size 最高有效位位置 - SL(Second Level):把 FL 区间再等分为
2^SL_BITS个子桶(VMA 中 SL_BITS=5,即 32 个子桶)
SL 推导:FL=f 的区间为 [2^f, 2^(f+1)),宽度 2^f,32 等分后每个子桶宽度:
sub_width = 2^f / 32 = 2^(f - 5)
sl = (size - 2^f) / sub_width
= (size >> (f - 5)) - 32 ← 纯移位,无除法
例:size=400,SL_BITS=5
fl = 8 (256 ≤ 400 < 512)
sub_width = 2^(8-5) = 8
sl = (400 >> 3) - 32 = 50 - 32 = 18
桶 [8][18] 覆盖范围:[400, 408)
size 的二进制中,FL 位之后的 5 位就是 sl,直接用移位掩码提取,不需要除法:
size = 400 = 0b 1_10010_000
↑ └5位┘
FL=8 SL=18(10010)
为什么 SL=32(SL_BITS=5)最合理:
| SL_BITS | 子桶数 | 最大内碎片 | 位图类型 | 查找指令 |
|---|---|---|---|---|
| 4 | 16 | size/16 ≈ 6.3% | uint16_t | 浪费寄存器 |
| 5 | 32 | size/32 ≈ 3.1% | uint32_t | 单条指令 |
| 6 | 64 | size/64 ≈ 1.6% | uint64_t | 稍重 |
32 个子桶正好填满一个 uint32_t,BSF/CLZ/__builtin_ctz 是单条 CPU 指令。SL=32 是碎片率与硬件效率的最佳平衡点。
位图加速:
uint32_t m_IsFreeBitmap; // bit[fl] = 1 表示该 FL 有空闲块
uint32_t m_SubIsFreeBitmap[MAX_FL]; // bit[sl] = 1 表示桶[fl][sl]有空闲块用 find first set (FFS) 指令(__builtin_ctz / _BitScanForward)实现 O(1) 查找。
┌─────────────────────────────────┐
│ size (最低位:0=空闲, 1=已用) │
│ prev_phys_block (物理紧邻前块) │
├─────────────────────────────────┤ ← 以下仅空闲块有
│ next_free (同 free list 下一块) │
│ prev_free (同 free list 上一块) │
├─────────────────────────────────┤
│ 用户数据区 │
└─────────────────────────────────┘
- 物理链(
prev_phys_block):按地址顺序连接,用于合并(coalescing) - 逻辑链(
prev_free/next_free):同一桶内的双向链表
1. size 向上取整到对应桶的最小值(round up),保证找到的块一定够用
2. 计算 (fl, sl)
3. 查位图找第一个"≥ (fl,sl)"的非空桶(2 次位操作)
4. 从桶链表头取出块
5. 若块比请求大,split 剩余部分插入对应桶
6. 返回块
round up 的必要性:不取整可能在桶中找到小于请求 size 的块(桶是范围,不是精确值)。
1. 检查物理前邻是否空闲 → 空闲则从 free list 摘除,合并
2. 检查物理后邻是否空闲 → 空闲则从 free list 摘除,合并
3. 将合并后的大块插入对应 (fl, sl) 的 free list 头部
4. 更新位图
初始:256KB Block,全空(NullBlock)
分配 A=48KB → [0,48K)
分配 B=16KB → [48K,64K)
分配 C=32KB → [64K,96K)
释放 B → [48K,64K) 进入 free list,前邻A/后邻C均已用,不合并
分配 D=12KB → 从 B 的 16KB 块分裂:D=[48K,60K),剩余4KB回 free list
释放 A → [0,48K) 进入 free list,后邻D已用,不合并
释放 D → 前邻A空闲,合并 → [0,60K) 插入新桶,位图更新
| 算法 | 时间复杂度 | 碎片情况 | 适用场景 |
|---|---|---|---|
| First-fit | O(n) | 中等 | 简单实现 |
| Best-fit | O(n log n) | 低 | 追求低碎片 |
| Buddy | O(log n) | 内碎片大 | 内核/简单场景 |
| TLSF | O(1) | 接近 best-fit | VMA、实时系统 |
VmaSuballocation* m_NullBlock; // Block 末尾的特殊空闲块末尾始终保留 Null Block 代表剩余空间,优先从普通 free list 分配,不足时才从 Null Block 切割。
Vulkan 规定 buffer 和 image 相邻时需满足 bufferImageGranularity 对齐,VMA 在分配时检查相邻块类型,必要时在 offset 上加 padding:
[Buffer alloc][padding][Image alloc]
← padding 保证跨越 granularity 页边界 →
VmaAllocation 对象来自 VMA 内部 pool allocator(类似 slab),高频分配路径完全无 CPU heap 压力,平均 0 次动态 malloc。
开启:VMA_POOL_CREATE_LINEAR_ALGORITHM_BIT
适合生命周期相似的资源,支持四种模式:
| 模式 | 描述 |
|---|---|
| Free-at-once | 始终在末尾追加,全部释放后从头开始 |
| Stack (LIFO) | LIFO 释放可复用尾部空间 |
| Double Stack | 两端各一个栈,相向增长 |
| Ring Buffer | 游标循环,适合流式上传 |
Double Stack 示意:
低地址 高地址
[────────上栈▶ ......... ◀下栈────]
默认分配从左边长 upper address 从右边长
增量式、协作式,VMA 只负责内存语义,数据迁移由用户负责:
vmaBeginDefragmentation(allocator, &info, &context);
while (vmaBeginDefragmentationPass(allocator, context, &passInfo)) {
// 用户:vkCmdCopyBuffer / vkCmdCopyImage 迁移数据
// 可设 pMoves[i].operation = IGNORE 跳过某块
vmaEndDefragmentationPass(allocator, context, &passInfo);
}
vmaEndDefragmentation(allocator, context, &stats);约束:
- 不跨 memoryType 移动
- 目标内存大小与原始一致(保留对齐)
- VMA 不录制 GPU 命令
TLSF 算法可脱离 GPU 独立使用,管理任意内存范围(如大 VkBuffer 内的 sub-range):
VmaVirtualBlock virtualBlock;
vmaCreateVirtualBlock(&info, &virtualBlock);
VmaVirtualAllocation alloc;
VkDeviceSize offset;
vmaVirtualAllocate(virtualBlock, &allocInfo, &alloc, &offset);| 维度 | 选择 |
|---|---|
| 接口语言 | C(与 Vulkan API 风格一致) |
| 实现语言 | C++14,单头文件 vk_mem_alloc.h |
| 依赖 | 仅标准库,无 STL 容器/RTTI/exception |
| 核心算法 | TLSF(默认)+ Linear(可选) |
| 错误处理 | 返回 VkResult,与 Vulkan 统一 |
| 职责边界 | 不上传数据、不录制命令,只管内存 |
- bufferImageGranularity 对齐处理细节
- TLSF 与 VmaBlockVector 扩容策略的配合
- Linear 分配器与 TLSF 的选择依据
- Defragmentation pass 设计细节
- VMA vs D3D12 Memory Allocator 对比