DPDK 内存管理 02 Memory Pool Library
在 DPDK 中,每个内存池通过一个名字进行标识,并通过一种称为 mempool handler 的机制来管理空闲对象。默认的 handler 是基于 环形队列(ring-based) 的实现。
内存池还提供一些可选功能,例如:
- 每核独立缓存(per-core object cache)
- 对象对齐工具(alignment helper):将对象填充对齐,使它们在所有 DRAM 或 DDR3 通道中均匀分布
调试模式中的“Cookie”(Cookies)
当开启 调试模式(debug mode) 时,DPDK 会在每个分配的对象前后加上一些特殊字段,称为 cookie。
这些字段的作用是:用来检测内存越界或覆盖错误(例如缓冲区溢出、未对齐访问)
调试模式是关闭的
如果需要开启,需在 config/rte_config.h
中设置:
#define RTE_LIBRTE_MEMPOOL_DEBUG 1
内存池统计(Stats)
如果启用了 统计模式(stats mode),DPDK 会记录关于从内存池获取/归还对象的统计数据。
这些统计信息保存在 mempool
结构体中:
- 例如:每次
rte_mempool_get()
和rte_mempool_put()
的次数 - 每个 lcore 单独维护统计数据,避免多核并发更新同一个计数器时出现锁竞争
默认情况下:
- 统计模式也是关闭的
- 如需启用,需要在
rte_config.h
中定义:
#define RTE_LIBRTE_MEMPOOL_STATS 1
x86 架构下的内存对齐约束
在 x86 架构上,根据硬件内存配置的不同,通过在对象之间添加特定的内存填充(padding),可以显著提升性能。
优化的目标是:
确保每个对象的起始地址落在不同的内存通道(channel)和 rank 上,
从而使所有内存通道被均衡地使用,避免瓶颈。
特别是在执行 L3 转发(forwarding) 或 流分类(flow classification) 的时候,性能差异明显。
原因是:
通常只访问数据包的前 64 字节(例如只看头部做决策),如果所有包的起始地址都落在同一个通道,那 CPU 就只能从一个通道拉数据,造成瓶颈。而如果起始地址均匀分布在多个通道上,多个通道可以并行工作,访问带宽显著提升。
Rank 是什么?
一个 DIMM(内存条)中的 rank 是能被单独访问的 DRAM 组,用于提供完整的数据位宽。
- 每个 rank 是一个独立的物理 DRAM 集合
- 但是多个 rank 共享同一个数据通道
- 所以 多个 rank 不能同时访问
注意:rank 的物理布局(比如你看到的内存芯片颗粒数量)不一定等同于逻辑 rank 数量。
如何启用通道/Rank 优化?
运行 DPDK 应用时,可以通过 EAL 启动参数 指定使用的 内存通道(memory channels)和 rank 数量。
./my_app --memory-channels=4
这会告诉 DPDK:
- 如何进行对象对齐(比如缓存行对齐+跨 channel 分布)
- 如何设置内存填充策略,让对象分布更合理
通俗总结:
概念 | 说明 |
---|---|
内存通道(channel) | CPU 与内存之间的“高速通道”,多通道可并行传输 |
Rank | 每个 DIMM 上可被单独访问的 DRAM 组,但它们共享通道 |
优化方式 | 给每个对象加合适的 padding,使它们分布在不同的通道/rank 上 |
目标 | 让 CPU 同时利用多个内存通道并行加载数据,提升吞吐 |
实用场景 | 包处理(L3、分类)只读前 64 字节时效果最明显 |
启用方式 | 使用 EAL 启动参数 --memory-channels=N 显式设置 |
Channel 和 Rank 是什么?
项目 | 含义与角色 |
---|---|
Channel(通道) | 是 内存控制器与 DIMM(内存条)之间的数据通路,可以视为逻辑访问通道,是真正可以并行工作的路径。多个 Channel 可以 同时传输数据。 |
Rank(秩) | 是 DIMM 中的 物理 DRAM 芯片组合,能提供一次完整数据访问(如 64 位)。同一时间只能激活一个 Rank,因为多个 Rank 共用同一数据线。 |
技术特性对比
属性 | Channel | Rank |
---|---|---|
是否并行可用 | ✅ 是 | ❌ 否(每个通道内同时只能激活一个 rank) |
是什么 | 逻辑访问通道(控制器<->DIMM) | DIMM 内的物理 DRAM 组 |
控制器视角 | 可以并发发起多个请求 | 每个请求只能打到一个 rank |
通常设置 | 主板上指定的通道数(如 dual-channel) | DIMM 内部由硬件厂商决定(1R、2R、4R) |
本地缓存
在多核系统中,如果多个核心频繁访问同一个内存池的“空闲对象环形队列(ring)”,每次访问都需要执行原子操作(比如 CAS),这会带来很高的 CPU 开销。
如何优化?
为了避免这种频繁的共享访问,DPDK 为每个核心维护了一个本地缓存(per-core cache):
- 内存池分配器不再每次都从全局 ring 获取对象
- 而是先从当前核心自己的 cache 中取/还对象
- 只有当本地 cache 空了或满了,才进行一次批量交互(bulk get/put)
本地缓存结构:
- 是一个小型数组,保存对象指针(用于后续复用)
- 用一个栈结构(top 指针)维护
- 每个核心独占,不共享
- 在创建内存池时可以选择启用或禁用
自定义外部缓存(External Cache):
除了默认的 per-lcore 缓存外,你也可以使用 用户自定义的外部缓存,通过以下 API 实现:
rte_mempool_cache_create()
rte_mempool_cache_free()
rte_mempool_cache_flush()
你可以通过这些缓存来:
- 管理自己的对象缓存(更灵活)
- 显式地传递缓存给:
rte_mempool_generic_put()
rte_mempool_generic_get()
Mempool Handler 包含两个方面:
添加自定义 handler 的代码(实现 mempool 操作集)
你需要:
- 实现自己的 mempool ops 操作结构体(比如实现
.alloc
,.put
,.get
等函数) - 用宏注册它:
RTE_MEMPOOL_REGISTER_OPS(my_mempool_ops);
这就相当于注册一个新的内存池类型,告诉 DPDK 如何分配/释放对象。
使用新的 handler 创建内存池
你需要使用如下 API:
rte_mempool_create_empty()
rte_mempool_set_ops_byname()
调用顺序是:
- 使用
rte_mempool_create_empty()
创建一个空内存池 - 使用
rte_mempool_set_ops_byname()
指定你要使用的 handler(通过名字)
这样这个内存池就会使用你实现的 ops
回调逻辑。
多个 Handler 可以同时用
你可以在同一个程序中使用多个不同的 handler,比如:
- 一个 mempool 使用默认 ring 实现
- 另一个 mempool 使用你自定义的 NUMA-aware 实现
只要分别设置 handler 就可以:
rte_mempool_set_ops_byname(pool1, "ring_mp_mc");
rte_mempool_set_ops_byname(pool2, "my_custom_ops");
⚙兼容旧接口
老版本程序一般调用的是:
rte_mempool_create()
这个会默认使用 ring-based handler。
如果你想切换到新的 handler,你需要修改老程序,改用:
rte_mempool_create_empty() + set_ops_byname()
pktmbuf 专用配置支持 handler 设置
如果你用的是 rte_pktmbuf_create()
,可以通过配置宏来指定默认使用的 mempool handler:
#define RTE_MBUF_DEFAULT_MEMPOOL_OPS "my_ops_name"
这样就不用每次都手动 set ops,配置文件统一控制即可。
注意事项:
如果你的程序使用了 DPDK 的 共享库(shared libraries):
-
你可以通过 EAL 参数
-d
指定要加载的 handler.so
动态库./app -d my_handler.so
-
如果是多进程程序,所有子进程使用的
-d
参数 必须顺序一致,否则会导致 handler 注册失败或行为错乱。
通俗总结:
内容 | 说明 |
---|---|
handler 是什么? | 指定一个内存池具体怎么分配/释放的实现方式(比如 ring、stack、GPU buffer) |
怎么用? | 注册 ops 结构体 + rte_mempool_create_empty() + set_ops_byname() |
能多个用吗? | 一个程序可以同时用多个不同 handler |
老程序能用吗? | 默认用 ring handler,要改代码才能换 handler |
动态加载注意什么? | 所有进程的 -d 参数顺序必须一致 |
关于新接口和旧接口的对比:
旧接口 rte_mempool_create()
的特点
好处:
- 简洁,一行搞定:创建 + 绑定 handler + 初始化 + 填充
- 默认使用 ring-based handler(适用于大多数通用场景)
- 对于多数 mbuf 使用者足够方便
局限:
局限点 | 说明 |
---|---|
handler 写死 | 默认使用 ring_mp_mc,不支持自由选择 |
不支持外部内存 | 比如不能直接用 hugepage file、DMA 区、设备共享内存等 |
扩展性差 | 不能参与高级定制(NUMA-aware 分配、自定义缓存结构、共享跨设备 buffer 等) |
某些 flags 不好设置 | 比如想禁用缓存、启用自定义对齐、物理地址映射控制等受限 |
新接口的优势(组合式 API)
使用流程如下:
struct rte_mempool *mp = rte_mempool_create_empty("my_pool", ...);
rte_mempool_set_ops_byname(mp, "ring_mp_mc"); // 可选任何 handler
rte_mempool_populate_default(mp); // 填充内存
新接口的优势总结如下:
优势点 | 描述 |
---|---|
高度灵活 | handler 不写死,你可以随时切换,比如使用 DPDK 自带的 stack , bucket , 或你自定义的 |
支持外部内存(external memory) | 适配 zero-copy、DMA 显存、设备共享内存等场景 |
更适合高级应用 | 比如网络设备的 buffer 共享、zero-copy、多 NUMA 节点、PMD 驱动协同管理内存等 |
分步控制,利于调试与扩展 | 每一步可插入调试信息、限制策略,构建更复杂的 mempool 创建逻辑 |
更好支持 testing 和插件机制 | 可以动态加载 handler .so 文件,插件式扩展内存管理 |
示例对比:
老接口:
struct rte_mempool *mp = rte_mempool_create("mypool", 1024,
2048, 512, sizeof(struct rte_pktmbuf_pool_private),
NULL, NULL, NULL, NULL, SOCKET_ID_ANY, 0);
新接口:
struct rte_mempool *mp = rte_mempool_create_empty("mypool", 1024,2048, 512, SOCKET_ID_ANY, 0);
rte_mempool_set_ops_byname(mp, "ring_mp_mc");
rte_mempool_populate_default(mp);
在哪些情况下使用新接口有 “显著性能收益”?
场景类型 | 是否建议用新接口 | 提升点 |
---|---|---|
NUMA 优化 | 强烈推荐 | 降低远程访问开销 |
外部内存 / DMA 显存 | 必须 | Zero-copy、设备共享 |
启动优化 | 可选 | 批量填充更快 |
多 PMD 共享 mempool | 推荐 | 内存节省、性能更稳定 |
软件测试、模拟 | 推荐 | 更轻更快更可控 |
简单 mbuf 分配 | 可继续用老接口 | 无需切换 |