DPDK:关于408的盛宴--内存与Cache

1. 内存

1.1 简介

孙悟空象征躁动的"心猿",需历事以悟"空";

猪八戒象征贪欲的"水猪",需持戒以悟"能";

沙和尚居于二者之间,象征调和的"脾土",以静制动、以中正化偏,悟"净"而不染。

内存,一种优化存储效率而诞生的产物,为了缓和cpu与磁盘之间工作效率的不对等。

先来了解一些基本的东西吧。

  1. RAM: 随机访问存储器,计算机的"工作区"。 可以在任意地址进行读写操作,速度快,但断电后数据丢失。 是所有程序运行的基础内存。
  2. DRAM: 动态随机存储器,系统主内存的核心。通过电容存储信息,需要定期刷新来维持数据。 容量大、成本低,但速度较慢。
  3. SRAM:静态随机存储器,通过晶体管保持数据,不需刷新。 速度极快,但成本高、容量小。常用于 CPU Cache(L1/L2/L3)
  4. DDR:DDR 是 DRAM 的改进型,在时钟信号的上升沿和下降沿都能传输数据。 带宽翻倍、功耗更低,是现代主内存的主流技术(DDR4、DDR5 等)。

1.2 内存的基本原理

设想这样一个场景:

我们有一个很大的数组,用来存放大量数据。最直觉的做法,当然是------从头到尾,顺序地写入。

但问题来了。

如果系统中有多个进程,每个进程都希望拥有自己独立的数组空间 ,那这块连续的内存就必须被切分成若干小块,分配给不同的进程使用。

然而, "切块"远没有看上去那么简单。

切多大?------要不要分页、分段,还是页段结合?

怎么分?------是按先进先出(FIFO)回收,还是更灵活的算法?

顺序如何?------哪些块可以共享,哪些必须独立?

如何避免相互干扰?又如何让每一块都被充分利用?

切块与数据存放之间的对应关系,又该如何维持?

看似只是把一个大数组"分块",

但背后,正是操作系统在做的事------从物理内存到虚拟内存,从分配到回收,一切都围绕着"如何让每个进程都各得其所"展开。

本文的重点不在于此,所以不一一学习了,先来学学内存的物理地址与虚拟地址吧。

在 32 位系统中(本文也基于 32 位架构进行说明),

一个虚拟地址通常被划分为两部分:

  • 高 12 位(页号) :表示页表中的偏移量,可以理解为"页表数组"的下标;
  • 低 20 位(页内偏移) :表示数据在对应物理页中的具体位置。

因为页表在内存中是连续存放的,所以页号就像数组索引,通过它可以直接定位到对应的页表条目。

而 CPU 内部有一个特殊的寄存器------CR3 ,它保存了当前进程页表的起始地址。

当程序访问一个虚拟地址时,CPU 会做以下几步:

  1. 从 CR3 取得页表基地址;
  2. 结合虚拟地址的页号,定位到页表中的对应条目;
  3. 从该条目中读取出物理页的起始地址
  4. 最后加上页内偏移量,得到数据的真实物理地址

1.3 TLB

如前所述,CPU 访问一个虚拟地址 VA 时,通常需要 三次访存

  1. 第一次访存:读取页目录项(PDE)
  2. 第二次访存:读取页表项(PTE)
  3. 第三次访存:访问实际的数据页

三次访存显然成本不低,那么有没有办法再快一些?

答案是------在 CPU 与内存之间再加一层缓存:TLB(Translation Lookaside Buffer,快表)

TLB 是 MMU(内存管理单元)中的高速缓存 ,用来加速虚拟地址到物理地址的转换。

它保存了最近使用过的页表项(PTE),这样 CPU 不必每次都访问内存中的页表,就能快速完成地址映射,从而显著降低访存延迟。

当 CPU 发出虚拟地址时:

  • TLB 命中(hit) ,即可直接获得对应的物理地址(只需一次访存);
  • TLB 未命中(miss) ,则需要访问内存中的页表完成转换,并将结果写回 TLB,以便下次快速命中(仍是三次访存)。

1.4 大页

从前面的逻辑地址到物理地址的转换可以看出,如果使用常规 4KB 页,并假设 TLB 总能命中,那么至少需要在 TLB 中存放两个表项。

在这种情况下,只要访问的内容都在同一页内,就无需额外查表。

然而,当程序规模扩大时,问题就来了。

例如,一个占用 2MB 空间的程序需要 512 个页(2MB / 4KB = 512),也就需要 512 个页表项才能保证完全命中。

由于 TLB 的容量有限,随着程序使用的内存增加,TLB 很容易被占满,从而导致频繁的 TLB Miss。

此时,大页(Huge Page)的优势就体现出来了。

如果以 2MB 作为分页单位,那么只需 一个 TLB 表项 就能覆盖相同的 2MB 地址空间;

对于使用 GB 级内存 的大型程序,更可采用 1GB 大页,进一步减少 TLB Miss,提高地址转换效率。

1.5 NUMA

在早期的 SMP 系统中:

  • 所有 CPU 通过 同一条总线 访问内存和外设;
  • CPU 之间平等,无主从之分;
  • 所有硬件资源共享,每个 CPU 都能访问任意内存、外设等;
  • 内存结构统一,寻址一致(UMA,Uniform Memory Architecture)。

然而,随着 CPU 核心数量不断增加:

  • 所有处理器共享同一条系统总线,总线成为瓶颈;
  • CPU 与内存之间的访问延迟增大,整体性能提升受限。

为了解决这一问题,NUMA 架构应运而生:

  • CPU 被划分为多个 NUMA Node
  • 每个 Node 拥有独立的内存和 PCIe 总线系统;
  • 节点之间通过 QPI 总线 或类似互连进行通信。

在 NUMA 系统中:

  • 访问本地节点内存速度最快;
  • 访问远端节点内存速度较慢;
  • 访问延迟与 CPU 节点之间的距离(Node Distance)相关,因此称为 非一致性内存访问

虽然 NUMA 大幅缓解了 SMP 架构下多核 CPU 扩展带来的瓶颈,但它也带来一些新的挑战:

  • 当本地内存不足时,必须跨节点访问远端内存,访问延迟较高;
  • 应用程序若频繁跨节点操作,会导致性能下降。

因此,在 NUMA 系统下开发应用时,应注意:

  1. 尽量让任务固定在 本地 CPU 节点,减少远程访问;
  2. 降低不同 CPU 模块之间的交互;
  3. 合理规划内存分配,提升整体性能。

2. Cache

2.1 简介

内存的速度,仍然赶不上 CPU。

虽然 DRAM 的访问时间只有几十纳秒,但对 GHz 级的 CPU 来说,这仍然太慢了。

于是,为了让 CPU 不再"干等",人们又在内存和 CPU 之间,插入了一层更小、更快的存储层------Cache(高速缓存)

Cache 的核心思想,仍然是那句古老的真理:

"程序的局部性原理(Locality Principle)。"

即:

  • 时间局部性(Temporal Locality) :如果某个数据被访问过,它很可能马上还会被访问。
  • 空间局部性(Spatial Locality) :如果访问了某个地址,邻近地址也很可能被访问。

因此,CPU 不必每次都从内存中取数据,而是提前把可能要用的数据 放进 Cache 中。

下次用到时,直接命中,省去了高延迟的内存访问。

层级 名称 典型大小 延迟 特点
L1 Cache 一级缓存 32KB~64KB 几个周期 每个核心独有,速度最快
L2 Cache 二级缓存 256KB~1MB 十几周期 每个核心独有或半共享
L3 Cache 三级缓存 2MB~64MB 数十周期 多核心共享,用于跨核心数据交换
Memory 主内存 GB 级 上百周期 容量大,速度慢

2.2 Cache地址映射和变换

Cache 本质上是一张"小表"------

具体来说,就是把存放在内存中的内容按照某种规则装入到Cache中,并建立内存地址与Cache地址之间的对应关系。

Cache Line

Cache 不会按字节或页存取,而是以固定长度的块为单位,称为 Cache Line (通常为 64 字节)。

这也是 CPU 每次从内存中读取的最小单元。

例如:

当程序访问地址 0x1000 时,CPU 实际会把 0x1000~0x103F 的整块数据加载到 Cache 中。

为了解决"物理地址如何放进有限的 Cache"这个问题,CPU 通常采用以下三种映射方式:

  1. 直接映射(Direct Mapped)

    • 每个物理地址只能映射到 Cache 的唯一一行;
    • 优点:实现简单;
    • 缺点:容易冲突(冲突失效)。
  2. 全相联映射(Fully Associative)

    • 任意物理块可放入 Cache 的任意位置;
    • 优点:灵活;
    • 缺点:查找复杂(需并行比较标签)。
  3. 组相联映射(Set Associative)

    • 折中方案:Cache 被分为若干"组",每组包含若干行(称为 n 路组相联);
    • 地址中的一部分用于选择组号,一部分用于匹配标签。

例如,一个 4 路组相联的 32KB Cache,每行 64B:

  • 共 32KB / 64B = 512 行;
  • 512 / 4 = 128 组;
  • 地址低 6 位是行内偏移;
  • 接下来的 7 位是组号;
  • 剩余高位为标签(Tag)。

这样,Cache 查找过程为:

scss 复制代码
(1) 取地址的组号部分 -> 找到对应组;
(2) 比较组内每行的 Tag;
(3) 匹配成功则命中,否则 Miss。

2.3 Cache的预取

即使有多级 Cache,访问内存仍可能花费上百个 CPU 周期。 而现代处理器的流水线是高度并行的:

当某个指令等待数据从内存取回时,整个流水线都可能被阻塞,这就叫做 Cache Miss Stall

Cache 预取(Prefetching) 主要是让 CPU 尽量提前把未来可能访问的数据 从内存取到 Cache 中,

从而在真正访问时无需等待,从"取用分离"变成"用即所得"。

预取的原理在于局部性原理,例如:

c++ 复制代码
for (i = 0; i < N; i++)
    sum += a[i];

CPU 观察到 a[i] 的地址在每次访问后都线性递增, 它就可以推断出:接下来很可能访问 a[i+1]a[i+2] ......

于是它在后台提前把后续几个 Cache Line 从内存加载进来。 当真正访问时,这些数据已经"静静地等在那里"。

2.3.1 硬件预取

由 CPU 内部电路自动完成,不需要程序参与。 CPU 中有一个专门的"预取引擎"(Prefetch Engine), 它通过实时监控内存访问模式,自动判断是否值得提前加载。

硬件预取的常见算法:

名称 原理 特点
Sequential Prefetch 检测线性访问模式(如数组遍历),提前加载下一个 Cache Line 最常见、最稳定
Stride Prefetch 检测固定步长访问模式(如 a[i+4]、a[i+8]) 对规则性访问效果极好
Stream Prefetch 维护多个独立流的访问预测(例如多数组交替访问) 适合多线程、流水线场景
Adjacent Line Prefetch 加载当前 Cache Line 的同时也加载相邻行 简单粗暴但命中率高

硬件预取的优势在于自动化、零侵入 ,但缺点是无法理解程序逻辑,容易"过度积极"。

例如程序访问一次性随机跳转的数据,会导致 CPU 白白加载一堆无用行------这叫 Cache 污染(Cache Pollution)

2.3.2 软件预取

软件预取是由程序员显式发出的指令 ,告诉 CPU:

"我稍后要用这个地址,你先帮我取一下。"

常见形式如:

arduino 复制代码
#include <xmmintrin.h>
_mm_prefetch((const char*)&a[i+16], _MM_HINT_T0);

这条指令不会立刻使用数据,也不会阻塞流水线,

只是悄悄发出一个"预加载请求",让数据在未来的某一时刻出现在 Cache 里。

  • _MM_HINT_T0 表示加载到 L1 Cache;
  • _MM_HINT_T1/T2 可指定加载到更高层 Cache(如 L2/L3);
  • _MM_HINT_NTA 表示"非时间局部性"(no temporal locality),用于一次性数据。

软件预取的优势在于更精准 ,可以结合业务逻辑和数据结构调度。

但需要程序员对硬件特性非常熟悉,否则容易适得其反。

2.4 Cache的写策略

Cache 不只是读数据,还要写数据。

但写的地方可能在 CPU,也可能在主内存,于是出现了几种不同的策略:

策略 特点 优缺点
Write Through 同步写入内存(每次写 Cache 时同时写内存) 数据一致性好,但速度慢
Write Back 仅写入 Cache,延迟写回内存(脏行标记) 性能高,但需额外一致性控制
Write Allocate 写 Miss 时先加载到 Cache,再修改 适合 Write Back 策略
No Write Allocate 写 Miss 时直接写内存 适合 Write Through 策略

通常采用 Write Back + Write Allocate 的组合方案,以平衡速度与一致性。

2.5 Cache一致性

在多核系统中,每个核心都有自己的 Cache。

那么,当多个核心同时访问同一块内存时,如何保证数据一致?

这就是 Cache 一致性协议(Cache Coherency Protocol) 的职责。

最经典的方案是 MESI 协议

状态 全称 含义
M Modified 当前 Cache 拥有最新数据(脏行)
E Exclusive 数据仅在本 Cache 中,未修改
S Shared 数据可能存在于多个 Cache 中
I Invalid 数据无效(已被修改或替换)

当一个核心修改了某行数据,其他核心的相同地址就会被标记为 Invalid

从而强制它们重新从内存或其他核心拉取最新数据。

2.6 DDIO

在传统的 I/O 模型中:

外设(如网卡)通过 DMA 将数据写入内存,

然后 CPU 再从内存中读入 Cache,参与处理。

这种"中转站式"流程会导致额外的内存带宽占用和延迟。

为此,Intel 在 Xeon 处理器中引入了 DDIO(Data Direct I/O) 技术。

DDIO 的核心思想:

让外设直接与 CPU 的 L3 Cache 交互。

当开启 DDIO 时,网卡的 DMA 不再写入主内存,而是直接写到 L3 Cache 的特定区域。

这样:

  • CPU 处理网络数据时,可直接从 Cache 读取;
  • 减少一次主内存访问;
  • 显著降低延迟、提升吞吐。

DDIO 在高性能网络系统(如 DPDK、RDMA、TGW 等)中发挥着重要作用,

它让 Cache 与 I/O 协同 成为可能,也让"零拷贝(Zero-Copy)"真正接近理想状态。

相关推荐
yunxi_056 小时前
Redis 写时复制:一个老兵的防坑指南
redis·后端
起名不要6 小时前
数据转换
后端
桜吹雪6 小时前
15 个可替代流行 npm 包的 Node.js 新特性
javascript·后端
笃行3506 小时前
KingbaseES SQL Server模式扩展属性管理:三大存储过程实战指南
后端
火锅小王子6 小时前
目标筑基:从0到1学习GoLang (入门 Go语言+GoFrame开发服务端+ langchain接入)
前端·后端·openai
SimonKing6 小时前
继老乡鸡菜谱之后,真正的AI菜谱来了,告别今天吃什么的烦恼...
java·后端·程序员
Java技术小馆7 小时前
AI模型统一接口桥接工具
后端·程序员
华仔啊7 小时前
Docker入门全攻略:轻松上手,提升你的项目效率
后端·docker·容器
IT_陈寒8 小时前
Redis性能翻倍秘籍:10个99%开发者不知道的冷门配置优化技巧
前端·人工智能·后端