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()

调用顺序是:

  1. 使用 rte_mempool_create_empty() 创建一个空内存池
  2. 使用 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 分配 可继续用老接口 无需切换
posted @ 2025-03-30 10:15  Tohomson  阅读(131)  评论(0)    收藏  举报