原文链接点击 DPDK内存碎片优化,性能最高提升30+倍!
背景
DPDK(Data Plane Development Kit)是一个用于高性能数据包处理的开源项目,常用于构建各类高性能网络应用。基于 DPDK 的设计思想及其提供的线程/内存模型,SPDK(Storage Performance Development Kit)被开发出来,广泛用于构建各类高性能存储应用。
为了提升应用程序的性能,DPDK 基于大页内存实现了一套自己的内存管理接口,这些内存接口被广泛应用于SPDK/DPDK 的各类应用中,而且不少应用会在 IO 路径中进行内存的分配与释放。DPDK 常用的内存接口有:
void *rte_malloc(const char *type, size_t size, unsigned align);
void *rte_realloc(void *ptr, size_t size, unsigned int align);
void rte_free(void *ptr);
在实际使用过程中,我们发现 DPDK 原生的内存分配接口在一些场景下有着较大的性能问题。本文将结合实际遇到的问题,为大家介绍如何优化 DPDK 内存碎片问题。
问题现象
当接到 DPDK 内存碎片化的问题反馈后,经过多方排查,最终将怀疑点锁定到大页内存的分配上,并将实际使用时内存分配的逻辑与参数进行抽象后,编写了如下的测试 case:
uint64_t entry_time, time;
size_t size = 4096;
unsigned align = 4096;
for (int j = 0; j < 10; j++) {
entry_time = rte_get_timer_cycles();
for (int i = 0; i < 2000; i++) {
rte_malloc(NULL, size, align);
}
time = (rte_get_timer_cycles()-entry_time) * 1000000 /
rte_get_timer_hz();
printf("total open time %lu us, avg time %lu us\n", time, time/2000);
}
单线程执行内存分配,总共分配 1 万个 4k 大小的对象, 每 2000 次分配后计算平均耗时,中间不释放内存,各阶段单次平均耗时如下表:
| malloc次数 |
平均耗时 |
| 0-2000 |
15us |
| 2000-4000 |
44us |
| 4000-6000 |
77us |
| 6000-8000 |
168us |
| 8000-10000 |
253us |
从上表可以看到,随着分配次数的增加,后续每分配 4K内存的耗时在线性增加,按照我们之前的理解,单线程下多次分配内存耗时不会过高,每次分配4K内存的耗时应该差不多,但是当前结果与预期不符,针对该场景我们进行了深入分析。
分析
首先结合代码分析了 DPDK 分配的逻辑,然后结合火焰图对怀疑点加了些调试手段以进一步 debug,最终得到了结论,并对结论进行了的验证。
代码分析
DPDK 通过 heap 管理每个 NUMA 节点上的大页内存,对于一块连续内存,首先转换成一个 struct malloc_elem 对象 elem,再将 elem 插入链表中,并保证链表中每项的地址按大小排序。每个 heap 都维护了 13 个链表,每个链表中的 elem 大小在相同范围内,比如大小 0-256 的 elem 在同一个链表,256-1024 的 elem 在同一个链表,1024-4096 的 elem 在同一个链表...。
DPDK内存分配的主要逻辑如下图所示:
![image.png]()
- 根据参数和当前线程所在 CPU 决定从哪个 heap 上分配内存。
- 根据要分配的大小,找到对应的链表(对应代码中的 free_head[N]),遍历该链表中所有的 elem,找到合适(满足大小、对齐等要求)的 elem。
- 将 elelm 从链表上摘除,根据大小进行切分并返回。
抓火焰图
![]()
可以看到 find_subitable_element 函数耗时非常高,这个函数的作用就是遍历链表上的 elem,找到满足要求的 elelm。以分配 4K 内存为例,首先遍历第三个链表即 free_head[2] 上的所有 elem,逐个检查是否满足要求,如果找到合适的 elem,就从链表上摘除,根据大小进行切分并返回,如果 free_head[2] 上找不到,会依次在free_head[3]、free_head[4] 上查找,直到找到为止。
从代码分析单次判断 elem 是否满足要求耗时应该很短,所以怀疑是链表上元素过多,导致整个遍历耗时过久。
那么我们可以尝试添加些 log,看看各个链表上 elem 的个数是否有变化
添加调试手段
经分析 DPDK 有些内存 dump 的 rpc,其中用到的 malloc_heap_get_stats 函数会遍历 heap 上所有链表上的 elem,我们可以简单修改,查看每个链表上的 elem 个数。
系统刚启动完成时,可以看到 elem 个数不多,大小也比较集中。
![]()
分配 2000 个 4K 内存后,看到 free_head[2] 上 elem 数量明显增加:
![]()
- 分配 4000 个 4K 内存后:
![]()
可以看到 free_head[2] 上 elem 个数随着分配次数的增加线性增加,这会导致每次分配 4K 所需遍历的 elem 个数也会增加,那么为什么会这样呢?
原因分析
添加 log 发现,free_head[2] 上所有 elem 的大小都小于 4096 字节:
![]()
为什么会出现这么多小的 elem 呢?经分析我们发现是在分配时切分 elem 而生成,切分 elem 示意图如下:
![image.png]()
以上图为例:
- 当调用 find_suitable_element 函数后,找到了一个满足要求的 elem,该 elem 大小一般远大于 4096 字节,所以在 malloc_elem_alloc 中会切分。
- malloc_elem_alloc 中会从 elem 末尾开始,根据大小和对齐算出 new_elem 的地址,由于分配内存要求 4K 对齐,所以 end-start 很可能大于 4096 ,而我们只要 4096,那么 new_elem 后面的 tailer 这段空间为避免浪费,是可以被重新利用的,由于其大小是 2432,也会被插入 free_head[2] 中。这也就导致了 free_head[2] 上 elem 个数在线性增加。
验证
根据上述分析,如果 align 为 64 字节,elem 的尾部空间应该小于 64,不会再插入链表,就不会有这个问题,将测试代码中的 align 参数改为 64 后,测试结果如下:
平均耗时 0.7us ~ 0.8us,不再有线性增加的现象。
![]()
那么是不是所有的 align 都有这个问题?我们又测了分配 64K 大小,align 为 64K 的情况,也出现了相同的情况。
![]()
结论
DPDK在管理内存时会将相邻大小的 elem 放在一个链表,分配时根据大小 4K 找到对应的链表 free_head[2],遍历该链表上的 elem 来查找满足要求的 elem,当分配时指定 align 过大,会使找到的 elem 尾部空间较大,为避免空间浪费会将尾部空间重新插入 free_head[2] 链表中,导致 free_head[2] 链表中 elem 数量增多,下次分配时又需要先遍历 free_head[2],使每次分配耗时呈线性增加。
解决方案
方案一:
struct malloc_heap {
rte_spinlock_t lock;
LIST_HEAD(, malloc_elem) free_head[RTE_HEAP_NUM_FREELISTS];
size_t free_head_max_size[RTE_HEAP_NUM_FREELISTS];
.....
}
在 heap 中新增 free_head_max_size 数组,记录每个 freelist 中 elem 的最大 size,遍历时如果要求的 size 大于该 freelist 上最大的size,可以直接跳过,但缺点是每次分配/释放时都要维护该数组。
![]()
可以看到单次分配耗时没有再呈线性增加,平均耗时稳定在0.5us左右,对比之前性能提升30 倍+:
![]()
方案二:
在原有的逻辑中,大小为 4K 的 elem 在 free_head[2] 上,4K 刚好处于边界,如果修改链表选择的规则,改到在 free_head[3] 进行查找,也可以解决问题。以分配 4K 为例,在 4K-16K 的链表上更容易找到对应的 elem,16K 也是在 16K-64K 的链表上能查找的概率更高。
![]()
也即每个链表上包含的元素大小范围如下:
heap->free_head[ 0 ] - ( 0 , 2 ^ 8 ) 0 < x < 256 heap->free_head[ 1 ] - [ 2 ^ 8 , 2 ^ 10 ) 256 <= x < 1024 heap->free_head[ 2 ] - [ 2 ^ 10 , 2 ^ 12 ) 1024 <= x < 4096 heap->free_head[ 3 ] - [ 2 ^ 12 , 2 ^ 14 ) 4 k <= x < 16 k
heap->free_head[ 4 ] - [ 2 ^ 14 , 2 ^ 16 ) 16 k <= x < 64 k ...
对比测试效果与方案一无明显差异,优点是对 16K/64K 等处于边界点的分配效率也有提升,缺点是可能会对极端情况有影响,需要更多测试。
![]()
测试验证
单线程
bulk:连续分配 1000 个对象,memset 后,1000 个对象一起释放,统计单次平均分配/释放耗时
no-bulk:分配 1 个对象,memset, delay 5us, 释放,重复 1 万次,统计平均分配和释放的总耗时
realloc:分配 1 个对象,memset, realloc 成原本 2 倍大小,memset, realloc 成原本 4 倍大小,memset,释放,重复 1000 次,统计平均总耗时
| 单次分配大小 |
测试项 |
修改前align=64 |
修改后align=64 |
修改前align=4096 |
修改后align=4096 |
| 64 |
bulk |
0.309/0.083 |
0.287/0.079 |
/ |
/ |
| no-bluk |
5.275 |
5.273 |
/ |
/ |
|
| realloc |
0.538 |
0.512 |
/ |
/ |
|
| 128 |
bulk |
0.410/0.074 |
0.399/0.085 |
/ |
/ |
| no-bluk |
5.270 |
5.264 |
/ |
/ |
|
| realloc |
0.584 |
0.616 |
/ |
/ |
|
| 512 |
bulk |
0.380/0.115 |
0.382/0.086 |
/ |
/ |
| no-bluk |
5.291 |
5.292 |
/ |
/ |
|
| realloc |
1.033 |
0.963 |
/ |
/ |
|
| 1024 |
bulk |
0.636/0.117 |
0.36/0.146 |
/ |
/ |
| no-bluk |
5.291 |
5.289 |
/ |
/ |
|
| realloc |
1.56 |
1.31us |
/ |
/ |
|
| 3072 |
bulk |
0.801/0.386 |
0.812/0.343 |
0.875/0.217 |
0.883/0.225 |
| no-bluk |
5.32 |
5.26 |
5.77 |
5.81 |
|
| realloc |
2.15 |
2.09 |
2.774 |
2.769 |
|
| 4096 |
bulk |
1.099/0.486 |
0.771/0.474 |
0.90/0.324 |
0.762/0.362 |
| no-bluk |
5.62 |
5.329 |
5.65 |
5.394 |
|
| realloc |
2.89 |
2.59 |
3.36 |
2.81 |
|
| 8192 |
bulk |
0.967/0.563 |
1.011/0.589 |
0.886/0.516 |
0.883/0.476 |
| no-bluk |
5.37 |
5.37 |
5.43 |
5.43 |
|
| realloc |
4.68 |
4.65 |
4.87 |
4.75 |
|
| 16384 |
bulk |
1.33/0.87 |
1.21/0.881 |
1.379/0.938 |
1.33/0.95 |
| no-bluk |
5.49 |
5.465 |
5.57 |
5.525 |
|
| realloc |
9.59 |
9.528 |
9.76 |
9.705 |
|
| 32768 |
bulk |
2.22/2.01 |
2.276/2.06 |
2.41/2.06 |
2.44/2.068 |
| no-bluk |
5.65 |
5.646 |
5.72 |
5.71us |
|
| realloc |
20.5 |
20.49 |
20.8 |
20.74 |
|
| 131072 |
bulk |
10.432/10.526 |
10.397/10.531 |
10.68/10.79 |
10.676/10.818 |
| no-bluk |
9.87 |
9.866 |
9.9 |
9.9 |
|
| realloc |
82.0 |
82.15 |
82.8 |
82.56 |
|
| 524288 |
bulk |
42.8/42.59 |
43.058/42.689 |
43.6/43.2 |
43.609/43.357 |
| no-bluk |
23.72 |
23.74 |
23.8 |
23.8us |
|
| realloc |
406 |
406 |
407 |
408.75 |
|
| 1048576 |
bulk |
125.2/125.7 |
124.9/125 |
88.2/86.8 |
87.3/86.9 |
| no-bluk |
42.38 |
42.25 |
42.46 |
42.46 |
|
| realloc |
869 |
873 |
890 |
888 |
|
多线程
bulk:连续分配 1000 个对象,memset 后,1000 个对象一起释放,统计单次平均分配/释放耗时
no-bulk:分配 1 个对象,memset, delay 5us, 释放,重复 1 万次,统计平均分配和释放的总耗时
realloc:分配 1 个对象,memset, realloc 成原本 2 倍大小,memset, realloc 成原本 4 倍大小,memset,释放,重复 1000 次,统计平均总耗时
| 单次分配大小 |
测试项 |
修改前align=64 |
修改后align=64 |
修改前align=4096 |
修改后align=4096 |
| 64 |
bulk |
15.0/6.5 |
15.1/6.5 |
/ |
/ |
| no-bluk |
22.6 |
22.6 |
/ |
/ |
|
| realloc |
73.5 |
73.8 |
/ |
/ |
|
| 128 |
bulk |
17.4/6.7 |
17.2/6.7 |
/ |
/ |
| no-bluk |
23.5 |
23.5 |
/ |
/ |
|
| realloc |
73.9 |
73.3 |
/ |
/ |
|
| 512 |
bulk |
16.8/6.2 |
16.4/6.2 |
/ |
/ |
| no-bluk |
22.3 |
22.4 |
/ |
/ |
|
| realloc |
82.1 |
79.5 |
/ |
/ |
|
| 1024 |
bulk |
18.5/6.8 |
15.9/6.7 |
/ |
/ |
| no-bluk |
25.1 |
24.0 |
/ |
/ |
|
| realloc |
92.1 |
84.7 |
/ |
/ |
|
| 3072 |
bulk |
18.2/7.8 |
18.2/7.9 |
29.1/6.7 |
29.3/6.5 |
| no-bluk |
23.9 |
25 |
36.8 |
36.3 |
|
| realloc |
91.8 |
91.3 |
117.7 |
116.2 |
|
| 4096 |
bulk |
21.3/8.5 |
16.7/8.4 |
30.3/14.2 |
23.0/7.5 |
| no-bluk |
28.9 |
24.6 |
39.2 |
30.0 |
|
| realloc |
101.8 |
95.1 |
125.6 |
108 |
|
| 8192 |
bulk |
18.4/12.0 |
18.4/12 |
25.0/9.8 |
24.1/11.5 |
| no-bluk |
26.0 |
25.9 |
31.2 |
30.9 |
|
| realloc |
110.7 |
108.4 |
122.8 |
121 |
|
| 16384 |
bulk |
22.9/19.4 |
19.4/19.0 |
28.5/16.5 |
27.2/15.6 |
| no-bluk |
27.8 |
26.5 |
33.5 |
31.5 |
|
| realloc |
125.6 |
122.5 |
140.9 |
136.2 |
|
| 32768 |
bulk |
27.4/33.2 |
27.4/33.2 |
32.8/30.3 |
34.1/27.5 |
| no-bluk |
28.9 |
28.8 |
34 |
34 |
|
| realloc |
171 |
168.2 |
184.5 |
180.8 |
|
| 131072 |
bulk |
44.8/170 |
44.6/171 |
44.7/169.4 |
44.6/167 |
| no-bluk |
58.5 |
58.1 |
63.2 |
62.9 |
|
| realloc |
463.6 |
458.3 |
472.7 |
470 |
|
| 524288 |
bulk |
160/655 |
160/660 |
160.8/653.7 |
160.1/652.5 |
| no-bluk |
162.8 |
163 |
168.8 |
168.7 |
|
| realloc |
1812 |
1826 |
1824 |
1820 |
|
16 个线程,每个线程单独绑核后做如下测试:
连续分配 N 个 4K 大小对象,4K 对齐,每次分配之间 delay 5us, 统计耗时,然后连续释放 N 个 4K 对象,不delay,统计单次耗时
| 单线程分配次数 |
优化前 |
优化后 |
| 64 |
109us |
16us |
| 128 |
214us |
17.8us |
| 256 |
408us |
17.5us |
| 512 |
962us |
18.2us |
| 1024 |
3.1ms |
15.3us |
| 2048 |
8.6ms |
17.6us |
| 4096 |
20.5ms |
17.4us |
DPDK malloc perf test
malloc 测试,修改前:
![]()
malloc 测试,修改后,可以看到没有性能衰退,4K/64K/1M 等大小分配有 15%-50% 的性能提升:
![]()
memzone 测试,修改前:
![]()
memzone测试,修改后没有性能衰退,64K/1M 大小的分配分别有 7% 和 52% 的性能提升:
![]()
总结
通过以上分析,确定了 DPDK 内存碎片化的诱因是在分配时要求对齐的大小比较大,导致 elem 切分时产生了很多碎片,并影响了后续内存分配的效率。经过完整的测试,最终选用了方案二,改动简单,效果也更好,不仅解决碎片场景下的问题,使得性能提升 30+ 倍,也在非碎片情况下,使得 4K/64K/1M 等大小的内存分配性能提升 15-50% ,同时也能缓解多进程并发分配时竞争锁的压力。目前字节跳动 STE 团队已将该补丁提交至 DPDK 社区,并于 2023 年 2 月 合入 DPDK 主线,链接如下:https://inbox.dpdk.org/dev/CAJFAV8x5k55jH0A_BHN9jxA1m-3o08tKr7RbCesvVL7o4MKgGQ@mail.gmail.com/
DPDK/SPDK 作为开源项目,凭借其出色的性能表现赢得了众多开发者的青睐,我们也希望在使用开源项目获得收益的同时,能为项目与社区带来更多想法,做出更多贡献,与开发者们一起推进社区的建设。