3 · 内存分配策略
静态分配与动态分配、内存池管理、伙伴系统在NPU中的适配
做NPU系统这些年,我越来越觉得内存分配是门“戴着镣铐跳舞”的艺术。你想想看,AI芯片里SRAM就那么几兆,HBM带宽再高也架不住算子疯狂申请。今天咱们就聊聊三种核心策略——静态分配、动态分配、内存池,以及伙伴系统在NPU里怎么落地。
3.1 静态分配:编译期就把账算清楚
静态分配,说白了就是“先计划,再执行”。编译器在编译模型时,就把每个张量、每个权重、每个中间结果的内存地址定死了。我个人习惯把这种策略叫做“铁饭碗”——一旦分配好,运行期间绝不改变。
但静态分配也有“死穴”。我记得有一次做多分支模型,不同分支的激活张量大小差异很大,如果全用静态分配,就得按最大尺寸预留,内存利用率直接打七折。所以静态分配更适合权重、常量、指令缓冲区这类生命周期固定、大小不变的对象。
// 伪代码:静态分配表 (编译期生成)
static_alloc_t alloc_table[] = {
{ .name = "conv1_weight", .addr = 0x1000, .size = 256*1024 },
{ .name = "bn1_scale", .addr = 0x5000, .size = 1024 },
{ .name = "relu_scratch", .addr = 0x6000, .size = 64*1024 },
};
3.2 动态分配:灵活但需要“管家”
动态分配正好相反——运行时才申请,用完就释放。NPU里的动态分配和CPU上的malloc/free不太一样。芯片上没操作系统帮你管,得自己维护一个堆。我见过不少团队直接拿伙伴系统改一改就塞进NPU驱动里,结果碎片率高得吓人。
动态分配最适合中间激活、动态shape的输出、临时scratch空间。但要注意,频繁分配释放会导致性能抖动。所以很多NPU运行时会在驱动层做一个“快速路径”——小对象走slab,大对象走伙伴系统。
// 动态分配接口示例 (NPU runtime)
npu_mem_handle_t handle;
npu_dynamic_alloc(&handle, 4096, NPU_MEM_ALIGN_256);
// ... 使用内存 ...
npu_dynamic_free(&handle);
3.3 内存池管理:预分配 + 复用
内存池是静态和动态的“混血儿”。启动时一次性从HBM或SRAM里划一大块,然后自己切成小块管理。分配和释放都在池内完成,不涉及底层硬件重映射,速度极快。
我参与过一个NPU项目,每个cluster都配了一个本地内存池。算子申请内存时,直接从池里拿一个固定大小的块,用完归还。因为池内块大小是预设的(比如1KB、4KB、16KB),所以几乎不会产生外部碎片。
| 策略 | 碎片风险 | 分配延迟 | 适用场景 |
|---|---|---|---|
| 静态分配 | 无 | 零 (编译期) | 权重、常量、固定buffer |
| 动态分配 | 中高 | 几微秒~几十微秒 | 动态shape、临时张量 |
| 内存池 | 低 | 亚微秒 | 激活复用、多核共享区 |
3.4 伙伴系统在NPU中的适配
伙伴系统(Buddy System)是内存池里一种经典的分配算法。它把内存按2的幂次分成块,分配时找最小满足的块,释放时合并相邻空闲块。在CPU上这很成熟,但NPU里有些坑要填。
第一个坑:NPU的SRAM通常有bank冲突,如果伙伴系统分配的块跨了bank边界,访问延迟会飙升。我习惯在伙伴系统的底层加一个bank对齐约束——分配时自动对齐到bank大小(比如4KB)。
第二个坑:NPU多核场景下,伙伴系统的锁竞争。我曾经在16核NPU上测试,一个全局伙伴锁导致分配耗时增加了5倍。后来改成per-core本地伙伴池,只有跨核分配才走全局池,性能才正常。
• 最小块大小 = 硬件cacheline或bank大小 (通常64B~256B)
• 最大块大小 = 本地SRAM容量 (例如2MB)
• 增加对齐属性:分配时返回对齐地址
• 多核场景使用本地伙伴池 + 全局溢出池
// 伙伴系统适配NPU的简化初始化
#define MIN_BLOCK_SHIFT 8 // 256B
#define MAX_BLOCK_SHIFT 21 // 2MB
npu_buddy_pool_t pool;
npu_buddy_init(&pool, SRAM_BASE, SRAM_SIZE,
MIN_BLOCK_SHIFT, MAX_BLOCK_SHIFT);
// 分配时指定对齐
npu_buddy_alloc_aligned(&pool, 4096, 4096); // 4KB对齐
嗯,这里要注意:伙伴系统虽然经典,但并不是万能的。如果你的NPU workload里内存块大小集中在几个固定值(比如1KB、8KB),用slab分配器可能比伙伴系统更高效。我一般在同一个池里混用——小对象走slab,大对象走伙伴。