DPDK 内存管理 - 01 逻辑核变量

DPDK 官方文档阅读 - Lcore Variables

1、Lcore variable 是什么?

逻辑核变量是DPDK框架为每个核心分配的变量,这个变量代表着框架为一个逻辑核保存的可以自定义的变量,可以使用这个变量来访问这个变量所代表的核心的内容,访问这个变量需要逻辑核变量具柄,他是一个指向变量类型的指针,但是是黑盒子指针,只能通过特定的宏访问,不能解引用。

单看官方给的定义不是很了解它到底是个什么东西,其实后面的部分有讲,他是一个可以自定义的变量,可以定义为基础类型,也可以定义为结构体,一般用于做核心上的一些简答但是重要的功能,比如收包发统计、核心状态等;

核心变量是以链表连接多个整块内存的方式来保存的。

这个变量在框架初始化的时候回给每个核心分配一个本地内存,这块内存用来保存每个核心的所有核心变量,核心变量是可以定义多个的,如果内存不够了会分配心新的内存,以头插法的方式插入链表。

2、分配Lcore变量

通过 RTE_LCORE_VAR_HANDLE 定义一个句柄。

使用 RTE_LCORE_VAR_ALLOCRTE_LCORE_VAR_INIT 分配内存并初始化句柄。

Lcore变量分配通常在模块初始化的时候做,但是实际上在任何时候都可以分配,而且它的生命周期不取决于分配的线程,而是整个框架的周期,lcore也不能被释放。

3、在DPDK中,线程可以与逻辑核变量绑定,但是任意线程都可以访问任意的逻辑和变量,应该避免非逻辑核变量持有者线程频繁访问逻辑核变量,这会带来竞争,有可能需要上锁,会带来性能开销。

RTE_LCORE_VAR_LCORE:用于访问某个lcore id对应的值;

RTE_LCORE_VAR:用于访问当前线程自己的Lcore变量;

RTE_LCORE_VAR_FOREACH:用于便利所有LCORE的Lcore变量值;

4、Lcore变量的存储

Lcore变量可以是基本类型,但是更推荐使用struct来组织更多字段;

每个lcore变量都会额外消耗sizeof(void *) 字节的内存,如果你把一个模块所有 per-core 的变量打包成一个结构体(再作为一个 lcore 变量),能更节省空间。

应用程序可以定义句柄但不立即分配变量。

每个变量值的大小不得超过 RTE_MAX_LCORE_VAR。这个大小是指“一个值”的大小,不是所有副本加起来的总大小。

一般不建议给 lcore 变量加 __rte_cache_alignedRTE_CACHE_GUARD,因为 DPDK 的布局设计已经很好地避免了 false sharing。加这些字段反而会增加缓存压力,降低性能。

lcore 变量默认初始化为零。

Lcore变量是用来做什么的:

比如在一个模块中,需要统计这个核心的收包数量、发包数量、标记这个核心是否活跃,就可以使用lcore变量来保存:

//可以使用三个变量的方式
int rx_count;
int tx_count;
bool is_active;
//可以使用结构体的方式
struct my_core_data {
    int rx_count;
    int tx_count;
    bool is_active;
};
RTE_LCORE_VAR_HANDLE(struct my_core_data, handle);
//初始化
memset(RTE_LCORE_VAR(handle), 0, sizeof(struct my_core_data));

lcore变量声明后就会为每个核心分配一个自己本地的Lcore变量,用于自己访问,不管启用了多啊少核心,DPDK编译的时候都会分配RTE_MAX_LCORE这个大数组的lcore变量。

关于lcore变量的访问:

步骤 说明
定义句柄 RTE_LCORE_VAR_HANDLE(type, name) 就是 type *name 的语法糖
分配变量 RTE_LCORE_VAR_ALLOC(name) 为所有 lcore 分配副本
访问当前线程值 RTE_LCORE_VAR(name) 等价于 &name[lcore_id()]
访问指定 lcore RTE_LCORE_VAR_LCORE(name, id) 访问其他 lcore 的值
遍历所有 lcore 值 RTE_LCORE_VAR_FOREACH(name, id) 用来做统计或调试
#include <rte_lcore.h>
#include <rte_lcore_var.h>
RTE_LCORE_VAR_HANDLE(uint32_t, packet_counter);	//声明handle
void init_var(void) {
    RTE_LCORE_VAR_ALLOC(packet_counter);
}
void process_packet(void) {
    // 当前线程的 lcore 变量值加一
    (*RTE_LCORE_VAR(packet_counter))++;
}
void print_all(void) {
    uint16_t lcore;
    RTE_LCORE_VAR_FOREACH(packet_counter, lcore) {
        printf("lcore %u handled %u packets\n",
               lcore,
               *RTE_LCORE_VAR_LCORE(packet_counter, lcore));
    }
}

Lcore 变量的存储

struct lcore_var_buffer {
    char data[RTE_MAX_LCORE_VAR * RTE_MAX_LCORE];
    struct lcore_var_buffer *prev;
};

假设RTE_MAX_LCORE = 4  → 系统最多支持 4 个逻辑核

RTE_MAX_LCORE_VAR = 128 字节 → 每个 lcore id 拥有的变量“空间块”大小

那么:char data[128 * 4] = char data[512] 字节,内存分分布如下:

+----------------------+  ← data[0] 开始
|  lcore 0 变量空间      |  ← 128 字节
|----------------------|
|  lcore 1 变量空间      |  ← 128 字节
|----------------------|
|  lcore 2 变量空间      |  ← 128 字节
|----------------------|
|  lcore 3 变量空间      |  ← 128 字节
+----------------------+  ← 共 512 字节结束

如果后续新增了lcore变量,在128字节空间内放不下了,那么会新分配内存,将增加的部分,以前插入的方式,插入到链表,使用头插法的原因是方便从最开始的地方进行清理。

所以最终我们可以总结为:

  1. 整个 data 是一个大数组(其实就是一个二维:lcore × 每核空间)
  2. 每个 lcore 的数据是连续的,访问快
  3. 每个 lcore id 的“空间切片”是一样大的(128 字节)
  4. 多个变量在每个切片中顺序排列,分配 offset 自动推进
  5. 不够用了就分配新的 buffer 接着用(形成链表)
  6. 最终在 rte_eal_cleanup() 时一起释放

变量具柄(handle)

lcore var handle 的实际值:它指向的是当前 lcore_var_buffer 中的数据区域,从 offset 处开始,表示该变量所有 lcore 实例的“起始位置”

buffer->data + offset

类型安全怎么保证?

虽然你可以自己调用 rte_lcore_var_lcore() 来拿地址,但 DPDK 更推荐使用这些宏

  • RTE_LCORE_VAR(handle):当前线程自己的值
  • RTE_LCORE_VAR_LCORE(handle, lcore_id):指定 lcore 的值

这些宏的好处是:

  • 自动返回强类型指针(指向你原始声明的类型)
  • 比直接用 void 指针更安全
  • 编译器能做类型检查,避免错误访问

lcore 变量句柄其实就是一个“起始地址”,而每个逻辑核的数据块就是这片内存的一段偏移。

想访问某个核的变量副本?直接从句柄加上 (核编号 × 步长) 就到了。

但为了避免你自己算偏移出错,DPDK 提供了宏来自动帮你完成这步,并且保证返回的指针类型正确。

使用lcore 变量带来的性能提升

lcore 变量的一个设计目标就是 提升性能

这种提升的方式主要体现在以下几点:

  • 将频繁访问的数据尽可能紧密地排列在更少的 cache line 中
  • 减少 CPU 缓存中的碎片化
  • 提高实际有效的缓存利用率和命中率(cache hit rate)

应用层是否能受益,取决于以下几个因素:

  1. 你把多少数据存到了 lcore 变量里?
  2. 你访问这些数据有多频繁?
  3. 你的程序本身对 CPU 缓存的压力有多大?
    • 比如你访问了很多其他随机内存,会冲刷掉 lcore 数据所在的 cache 行,那收益就不明显

DPDK 提供了一个性能测试工具:

lcore_var_perf_autotest

这个测试用于对比 lcore 变量和传统 lcore-id 索引数组在性能上的优劣。

但要注意:这只是一个微基准测试(micro benchmark),只反映在某种极端条件下的差异,不能完全代表实际应用的表现。

另一个重要的好处:规避硬件预取导致的 false sharing 问题

有时,即使两个核访问的数据本来不在同一个 cache line 上,也可能由于CPU 硬件预取(prefetch)行为,而间接导致 false sharing 问题。

这种预取行为:

  • CPU 自动干的,不在我们程序控制范围内
  • 不同厂商(Intel、AMD)、不同 CPU 代数、不同 BIOS 配置下行为都不同
  • 通常不会出问题,但在某些场景下,会带来严重性能抖动

使用 lcore variables 后,每个核的数据完全隔离在独立的区域中,可以有效避开这种预取带来的“误伤”。

总结:

内容
性能优化手段 把每核私有数据集中,减少 cache line 数量,提高缓存命中率
实际收益 一般是小幅提升(具体看程序访问模式和缓存压力)
传统方式的问题 即使不共享数据,CPU 的硬件预取机制也可能导致“伪共享”
lcore var 的优势 内存隔离彻底,天然防止 false sharing,规避不确定性风险

替代方案(Alternatives)

  1. Lcore Id 索引静态数组(Lcore Id Indexed Static Arrays)

这会导致什么问题?

为了避免不同 lcore 的数据在同一 cache line 中导致 false sharing,你就必须:

  1. __rte_cache_aligned 把每个元素对齐到 cache line
  2. 使用 RTE_CACHE_GUARD 加上“守卫空间”做隔离
  3. 确保内存分配时本身也按 cache line 对齐

即使你都做到了,也不能完全避免因为以下原因带来的 false sharing 风险:

  • CPU 硬件的 预取机制(prefetching)
  • 推测执行(speculative execution) 带来的意外内存访问

有时这些机制会访问到下一条 cache line,即使两个线程本来没访问同一块数据,也可能发生冲突

lcore variables 的优势:

lcore variables 的内存布局方式(每个 lcore 一整块独立空间)正好契合了 CPU 的预期行为

  • 每个 lcore 的所有数据集中在一起
  • 不再需要复杂的 padding、对齐处理
  • 避免 CPU 硬件预取引发的 false sharing
  • 整体 内存局部性更好
项目 静态数组方案 lcore variable 方案
内存布局 所有核的数据都放一起(模块为单位) 每个核的数据独立(lcore 为单位)
避免 false sharing 的手段 需要额外对齐、padding 天然避免,无需对齐
对 CPU 缓存友好程度
是否会被 CPU 预取机制误伤 可能 不太可能
  1. 线程局部存储(Thread Local Storage)

另一个替代 rte_lcore_var.h 的方案是使用 rte_per_lcore.h 提供的 TLS(线程局部存储)API,底层基于:

  • __thread(GCC 扩展)
  • _Thread_local(C11 标准)

TLS 和 lcore variable 有哪些区别?

特性 TLS(__thread/_Thread_local) lcore variable(rte_lcore_var)
生命周期 绑定线程:线程退出后变量消失 独立于线程,谁创建的都可以访问
初始化时机 线程启动后才初始化(lazy) 分配后立即可用
是否作用于所有线程 是,所有线程(包括非 EAL)都有副本 只有带 lcore id 的线程才有副本
线程频繁创建销毁 会频繁触发 TLS 初始化,可能开销大 无影响,变量独立于线程生命周期
是否能跨线程共享指针 有风险,C11 标准下不推荐 安全,所有线程都能访问
内存布局 类似,但每线程分散 按 lcore 聚合,更集中

总结:

场景 推荐方案
线程模型复杂、动态 使用 TLS
按核调度、线程稳定 使用 lcore variable
性能关键、需避免 false sharing 使用 lcore variable
小型模块、临时存储 TLS 更简单
posted @ 2025-03-29 09:20  Tohomson  阅读(98)  评论(0)    收藏  举报