在NeuroPilot上跑长时间推理服务时,一个很典型的问题会慢慢浮出水面:服务刚启动时一切正常,跑了几小时之后,明明设备上还有空闲内存,新的张量分配却开始失败,或者分配延迟明显抖动。大多数情况下,这并不是内存真的不够,而是内存碎片把可用空间切得七零八落。本文围绕内存池化管理这一思路,详细讲讲碎片是怎么产生的,以及如何在NeuroPilot工程里设计一个实用的内存池。

一、NeuroPilot内存碎片是怎么产生的
NeuroPilot的张量生命周期和计算图绑定得比较紧。一次推理过程中,中间张量会随着算子的执行不断创建和销毁。默认情况下,每次创建张量都向底层分配器发起一次真实分配,销毁时立即归还。问题在于,分配器看到的请求尺寸五花八门:某个卷积层的输出可能是1.5MB,下一个层可能是3.2MB,再往后又出现0.8MB。
这种高频、小尺寸、尺寸不固定的分配模式,是碎片的温床。分配器为了满足请求,会从较大的空闲块中切割。切割后剩下的边角料如果太小,就几乎没有机会被再次利用。随着时间推移,内存空间被这些“渣块”逐渐填满。更麻烦的是外碎片问题:虽然所有空闲块加起来有500MB,但最大的一块连续空闲区只有10MB,此时申请一个64MB的张量就会直接失败。
另外还要注意多模型并存的场景。如果多个模型实例各自直接向系统申请内存,申请和释放的顺序交错之后,地址空间会像被洗过一副牌一样混乱。解决思路其实很明确:把分配行为收拢到一个统一的池子里,用应用层的策略去替代底层分配器的通用策略,碎片的形态就变得可控了。
二、内存池的核心设计:分桶自由链表
内存池的基本思想是“一次大申请、多次小分配”。启动时向NeuroPilot申请一大块设备内存,之后所有张量分配都从这块内存中切分。切分不能随意进行,否则池子自己也会碎片化,因此需要引入分桶机制。
分桶就是把请求尺寸向上取整到预定义的尺寸等级,比如64KB、128KB、256KB……每一级维护一条自由链表。分配时找到对应桶,从链表头取下一块即可;释放时把块挂回链表头。由于同桶内的块尺寸完全一致,永远不会产生内部碎片的不匹配问题,分配和释放都是O(1)操作。
class MemoryPool {
// 尺寸等级:64KB起,每级翻倍,共20级
enum { MIN_BLOCK = 64 * 1024, LEVELS = 20 };
struct FreeNode {
FreeNode* next;
size_t level;
};
// 每个等级一条自由链表
FreeNode* freeLists_[LEVELS] = {nullptr};
void* base_ = nullptr; // 池基地址
size_t poolSize_ = 0;
std::mutex mtx_;
// 将请求尺寸换算为等级
int LevelOf(size_t size) {
int lv = 0;
size_t s = MIN_BLOCK;
while (s < size && lv < LEVELS - 1) {
s <<= 1;
lv++;
}
return lv;
}
public:
bool Init(size_t totalBytes);
void* Alloc(size_t size) {
int lv = LevelOf(size);
std::lock_guard<std::mutex> lock(mtx_);
// 本级有空闲块直接取
if (freeLists_[lv]) {
FreeNode* node = freeLists_[lv];
freeLists_[lv] = node->next;
return node;
}
// 向上借:从更大的块中分裂出一个子块
for (int i = lv + 1; i < LEVELS; i++) {
if (freeLists_[i]) {
FreeNode* block = freeLists_[i];
freeLists_[i] = block->next;
// 逐级分裂,把多余的半块挂回各级链表
for (int j = i - 1; j >= lv; j--) {
size_t half = MIN_BLOCK << j;
FreeNode* buddy = reinterpret_cast<FreeNode*>(
reinterpret_cast<char*>(block) + half);
buddy->level = j;
buddy->next = freeLists_[j];
freeLists_[j] = buddy;
}
return block;
}
}
return nullptr; // 池耗尽,交由上层处理
}
void Free(void* ptr);
};
上面的实现借鉴了伙伴系统的思想:每个块在分裂时一分为二,两个半块互为伙伴。释放时如果伙伴也空闲,就合并回上一级。这样碎片会被自动“回收再聚合”,不会无限累积。伙伴系统的代价是内部碎片最多浪费一半空间(请求65KB会占用128KB的块),所以分桶粒度的选择要在碎片率和空间利用率之间权衡。
三、固定块池与可变块池的选择
固定块池指池内所有块尺寸一致,实现最简单,链表操作零开销,也没有外部碎片。它适合张量尺寸固定的场景,比如输入输出shape恒定的图像分类模型。做法是先做一次空跑,把模型用到的所有张量尺寸收集下来,按尺寸去重后,每种尺寸建一个专用池。
可变块池则允许任意尺寸的分配,通过上面提到的分级分裂来管理。它灵活性强,适合动态shape或多模型混跑的场景,但实现复杂度更高,需要处理对齐、合并和线程安全等细节。实际工程中常见的做法是两者混合:对小尺寸(比如256KB以下)用固定块池快速分配,大尺寸走可变路径,兼顾延迟和利用率。
| 方案 | 碎片率 | 分配延迟 | 适用场景 |
|---|---|---|---|
| 固定块池 | 极低 | 最稳定 | 静态shape模型 |
| 可变块池(伙伴系统) | 中低 | 稳定 | 动态shape、多模型 |
| 直接系统分配 | 不可控 | 抖动明显 | 不建议长期服务使用 |
四、水位线监控与池大小调优
池子建好后不是一劳永逸,池开多大、什么时候扩容,都需要数据支撑。建议在池内维护三个统计量:当前已分配字节数、历史峰值、分配失败次数。峰值是最有价值的指标,它告诉你这个工作负载真正需要多少内存。可以设置一个水位线,比如当峰值达到池容量的85%时输出告警日志,提示需要扩容。
扩容策略上有两条路。一是静态评估:部署前跑一遍典型负载,记录峰值后乘以1.3左右的安全系数,把结果作为池大小。二是动态扩容:当本级和上级链表都为空时,再向NeuroPilot申请一块新的arena挂入池中。动态扩容要小心新arena可能与旧块无法物理合并,所以新arena的尺寸建议不小于现有池容量的四分之一,避免arena本身碎片化。
还有一个容易被忽视的点:对齐。NeuroPilot的某些算子对内存对齐有硬性要求,通常需要128字节甚至256字节对齐。池的基地址和每个块的切分起点都必须满足对齐约束,否则会遇到难以定位的性能下降或运行时错误。在Alloc入口处加一个assert(((uintptr_t)p & (ALIGN-1)) == 0)可以在测试阶段就抓住这类问题。
总结一下,解决NeuroPilot内存碎片的关键在于把无序的分配请求收敛为有序的池化分配:分桶让请求规整,伙伴合并让碎片自愈,水位监控让容量可规划。这套方案不需要改动模型代码,只需要替换张量分配的入口,就能显著降低分配延迟,并让长时间运行的服务内存占用保持稳定。
NeuroPilot内存池内存碎片修改时间:2026-09-13 00:00:38