NCNN Vulkan Memory Allocator 实现深度解读:设备端显存与主机端统一分发的池化架构
一、移动端 GPU 推理的内存困局:多模型切换时,显存碎片的叠加与 vkAllocateMemory 的沉重开销
在 Android 设备上通过 NCNN 部署多个推理模型(如人脸检测 + 人脸关键点 + 活体检测流水线)时,Vulkan 显存的分配和释放如果处理不当,会产生两个问题。其一,vkAllocateMemory 调用本身有数毫秒量级的开销——Adreno GPU 驱动需要在内核态划分显存区域并更新 GPU MMU 页表。其二,每次释放显存后,驱动的内部分配器并不会立即合并相邻空闲块,跨模型切换产生的碎块逐渐累积,最终导致显存分配失败。
NCNN 的 Vulkan Memory Allocator 借鉴了 AMD VMA(Vulkan Memory Allocator)的设计理念,以 4MB 为单位从驱动申请大块显存(Device Memory Block),然后在应用层用 Freelist + Best-Fit 策略将大块切割和回收,将驱动的 vkAllocateMemory 调用次数降低两个数量级。
二、统一双层池化架构:Host-Staging 缓冲区与 Device-Local 显存的协作与同步边界
graph TB
subgraph 应用层[应用层 — NCNN Net::forward]
A1[分配 Blob 张量<br/>VkBlobAllocator::alloc]
end
subgraph 分配层[分配层 — VkAllocator]
B1{缓冲区用途?}
B1 -->|权重/常量| B2[device-local pool]
B1 -->|中间特征图| B3[device-local pool]
B1 -->|Staging 上传| B4[host-visible pool]
B2 --> C1[BuddyAllocator<br/>4MB Blocks]
B3 --> C1
B4 --> C2[BuddyAllocator<br/>16MB Blocks]
end
subgraph 驱动层[驱动层 — Vulkan Driver]
C1 --> D1[vkAllocateMemory<br/>DEVICE_LOCAL]
C2 --> D2[vkAllocateMemory<br/>HOST_VISIBLE | HOST_COHERENT]
end
subgraph 物理层[物理层 — 硬件]
D1 --> E1[GPU VRAM<br/>Adreno/Mali 专用显存]
D2 --> E2[Unified Memory<br/>DDR Shared Buffer]
end
C1 -.->|超过 30s 未使用| F[空闲 Block 回收]
F -.->|vkFreeMemory| D1
style 分配层 fill:#bbf,stroke:#333,stroke-width:2px
style B1 fill:#ff9,stroke:#333,stroke-width:2px
架构上有两个独立的池:Device-Local Pool 托管 GPU 专用显存中的张量(权重、特征图),Host-Visible Pool 托管 CPU-GPU 共享的 DDR 区域(Staging Buffer 用于数据上传和下载)。两者使用相同的数据结构(BuddyAllocator),但 Block 大小不同——Device-Local 默认 4MB(显存压力大时更省),Host-Visible 默认 16MB(减少碎片)。
BuddyAllocator 是一种基于伙伴系统的二分分裂分配器。每个 Block 有一个固定阶数 order(0 到 MAX_ORDER),order=N 的 Block 大小为 BlockSize × 2^N。分配流程:请求 size → 向上取整到 2^order → 查找 order 链表 → 若无空闲,向上分裂更高 order 节点直到找到 → 分配并标记已用。释放流程:标记为闲 → 检查 buddy 是否空闲 → 若空闲则合并并递归上升。
与传统 dlmalloc 相比,BuddyAllocator 的优点在于内存连续性好、内部碎片可控(最多浪费 (2^order - request_size) / 2^order,上限接近 50%,但通过 16 字节对齐限制,实际浪费率约 5%-15%)。缺点是不支持任意大小的跨 order 合并——只能合并完全相同 order 的 buddy 块。这意味着一个空闲的 order=5(128KB)和相邻的空闲 order=4(64KB)无法合并成一个 192KB 的块——它们在 Buddy 体系中是"邻居"但不是"伙伴"。
三、从 VkAllocator 到 Blob 生命周期的管线集成:分配、回收与 fence 同步
以下简化代码展示 NCNN 风格的 Vulkan 内存分配器的核心逻辑。
/*
* buddy_vk_allocator.cpp — 基于伙伴系统的 Vulkan 内存分配器
*
* 设计要点:
* 1. 以固定尺寸 MemoryBlock 为单位,减少 vkAllocateMemory 调用
* 2. 伙伴系统保证碎片可控,最坏内部碎片率 < 50%
* 3. 空闲 Block 超时回收,确保多模型切换后释放显存
*/
#include <vulkan/vulkan.h>
#include <vector>
#include <algorithm>
#include <cstring>
#include <chrono>
#define BUDDY_MAX_ORDER 8 /* BlockSize × 2^8 = 1MB (BlockSize=4KB时) */
#define BLOCK_RECYCLE_SEC 30 /* 空闲 30 秒后回收 Block */
struct BuddyBlock {
uint32_t offset; /* Block 内偏移 */
uint32_t order; /* 阶数: 0..BUDDY_MAX_ORDER */
bool is_free;
};
struct MemoryBlock {
VkDeviceMemory memory; /* Vulkan 设备内存句柄 */
uint32_t block_size; /* 最小分配单元 (order=0) */
uint32_t total_size; /* Block 总字节数 */
uint8_t* mapped_ptr; /* HOST_VISIBLE 类型的 CPU 映射地址 */
std::vector<BuddyBlock> buddies; /* 伙伴状态数组 */
std::chrono::steady_clock::time_point last_used;
int ref_count; /* 当前活跃分配数 */
};
class BuddyVkAllocator {
private:
VkDevice device_;
uint32_t memory_type_index_;
uint32_t block_size_; /* order=0 块大小 */
std::vector<MemoryBlock*> blocks_;
std::vector<MemoryBlock*> free_blocks_; /* 完全空闲的 Block */
/*
* 从 Vulkan 驱动申请一块 MemoryBlock
* 返回 nullptr 表示显存耗尽或驱动故障
*/
MemoryBlock* allocate_new_block(uint32_t size) {
VkMemoryAllocateInfo alloc_info = {};
alloc_info.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
alloc_info.allocationSize = size;
alloc_info.memoryTypeIndex = memory_type_index_;
VkDeviceMemory memory;
VkResult result = vkAllocateMemory(device_, &alloc_info, nullptr, &memory);
if (result != VK_SUCCESS) {
/* 显存分配失败:尝试回收空闲 Block 后重试一次 */
recycle_idle_blocks();
result = vkAllocateMemory(device_, &alloc_info, nullptr, &memory);
if (result != VK_SUCCESS) return nullptr;
}
MemoryBlock* blk = new MemoryBlock();
blk->memory = memory;
blk->block_size = block_size_;
blk->total_size = size;
blk->mapped_ptr = nullptr;
blk->ref_count = 0;
blk->last_used = std::chrono::steady_clock::now();
/* 初始化伙伴数组:根节点 order=MAX, 标记为 free */
int total_buddies = (1 << (BUDDY_MAX_ORDER + 1)) - 1;
blk->buddies.resize(total_buddies);
for (auto& b : blk->buddies) {
b.is_free = false;
b.offset = 0;
b.order = 0;
}
blk->buddies[0].is_free = true;
blk->buddies[0].order = BUDDY_MAX_ORDER;
return blk;
}
/*
* 递归分裂 Buddy 节点直到 order 匹配请求大小
* idx: 当前节点在 buddies 中的索引
* target_order: 请求的阶数
* 返回分配成功的节点索引,失败返回 -1
*/
int buddy_split(int idx, int target_order) {
BuddyBlock& node = blocks_.back()->buddies[idx];
if (!node.is_free) return -1;
if (node.order == target_order) {
node.is_free = false;
return idx;
}
if (node.order <= target_order) return -1; /* 无法分裂到更小 */
/* 将当前节点分裂为左右 buddy */
int left = idx * 2 + 1;
int right = idx * 2 + 2;
int child_order = node.order - 1;
node.is_free = false;
auto& buddies = blocks_.back()->buddies;
buddies[left].is_free = true;
buddies[left].order = child_order;
buddies[left].offset = node.offset;
uint32_t child_size = block_size_ << child_order;
buddies[right].is_free = true;
buddies[right].order = child_order;
buddies[right].offset = node.offset + child_size;
/* 从左侧 buddy 继续递归 */
return buddy_split(left, target_order);
}
public:
/*
* 分配内存:遍历所有 Block 寻找匹配的 Buddy 节点
* 返回 {Block指针, 偏移量, 实际大小},失败返回 nullptr
*/
struct Allocation {
MemoryBlock* block;
uint32_t offset;
uint32_t size; /* 实际分配的 2^order 大小 */
};
Allocation* allocate(uint32_t request_size) {
/* 向上取整到 block_size 的 2^order */
uint32_t aligned = block_size_;
int order = 0;
while (aligned < request_size && order < BUDDY_MAX_ORDER) {
aligned <<= 1;
order++;
}
if (order > BUDDY_MAX_ORDER) return nullptr; /* 超过最大 order */
/* 遍历已有 Block,尝试分配 */
for (auto* blk : blocks_) {
int idx = buddy_split(0, order);
if (idx >= 0) {
blk->ref_count++;
blk->last_used = std::chrono::steady_clock::now();
auto* alloc = new Allocation{
blk,
blk->buddies[idx].offset,
aligned
};
return alloc;
}
}
/* 所有 Block 均无匹配 buddy,申请新 Block */
uint32_t new_block_size = block_size_ << BUDDY_MAX_ORDER;
if (new_block_size < (block_size_ << order)) {
new_block_size = block_size_ << (order + 2); /* 至少容纳请求 */
}
MemoryBlock* new_blk = allocate_new_block(new_block_size);
if (!new_blk) return nullptr;
blocks_.push_back(new_blk);
int idx = buddy_split(0, order);
if (idx < 0) {
delete new_blk; /* 不应发生 */
return nullptr;
}
new_blk->ref_count++;
new_blk->last_used = std::chrono::steady_clock::now();
auto* alloc = new Allocation{
new_blk,
new_blk->buddies[idx].offset,
aligned
};
return alloc;
}
/*
* 回收超过空闲阈值的 Block,调用 vkFreeMemory 归还显存
*/
void recycle_idle_blocks() {
auto now = std::chrono::steady_clock::now();
auto it = blocks_.begin();
while (it != blocks_.end()) {
MemoryBlock* blk = *it;
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - blk->last_used).count();
if (blk->ref_count == 0 && elapsed > BLOCK_RECYCLE_SEC) {
vkFreeMemory(device_, blk->memory, nullptr);
delete blk;
it = blocks_.erase(it);
} else {
++it;
}
}
}
};
四、Buddy 系统的适配边界:Unity Memory 架构下的隐形成本与碎片热点
BuddyAllocator 在分体式显存(Discrete GPU)架构上表现优异,但在统一内存架构(Unified Memory,如 Mali GPU 与 CPU 共享 DDR)下引入了一个隐蔽问题:HOST_VISIBLE | DEVICE_LOCAL 标志的 Memory Block 既是 CPU 可写也是 GPU 可读,但使用 vkMapMemory 持久映射后会占用 Mali GPU 的 MMU 槽位资源。当 Block 数量超过 32 个时,Mali 驱动内部的 MMU 条目分配会触发 VK_ERROR_OUT_OF_DEVICE_MEMORY,即使物理内存仍有富裕。
另一个热点是碎片化在特定使用模式下会加速:模型的逐层推理按严格的拓扑顺序分配和释放张量,早期层的输出张量占大量显存且生命周期短。在 Buddy 系统中,这些大块释放后形成 order=MAX-1 的空闲节点,但后继层需要的张量大小恰好是两个 order=MAX-1 可以满足的 order=MAX 块——然而这两个节点在 Buddy 树上并不相邻(它们是不同父节点的左子节点),因此永远无法合并。这种"伪碎片化"导致 Block 利用率降至 50%。
应对方案是引入跨度合并策略:不局限于 Buddy 伙伴关系,定期对整个 Block 的 free list 做 compaction。但这打破了 Buddy 系统的确定性假设,引入约 50-200μs 的 compaction 延迟,在低端 GPU 上可能影响帧率。
五、总结
NCNN 的 Vulkan Memory Allocator 以 Buddy 系统为核心,将频率高、开销大的 vkAllocateMemory/vkFreeMemory 调用转化为用户态的 Freelist 操作,显著降低了驱动调用开销。Device-Local 和 Host-Visible 两个独立池分别服务不同的内存访问模式。
工程落地的关键考量:Block Size 的选择直接影响碎片率(过小导致 Block 爆炸,过大浪费显存);Buddy 合并的严格性需要在确定性分配和空间利用率之间做折中;Mali 等统一内存架构上需警惕 vkMapMemory 持久映射导致的驱动内部资源耗尽。对于需要在移动端同时运行多个推理模型的场景,空闲 Block 的延迟回收机制是避免显存泄漏的最后一道防线。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_42431428/article/details/162868734



