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)"真正接近理想状态。

相关推荐
用户68545375977692 分钟前
同步成本换并行度:多线程、协程、分片、MapReduce 怎么选才不踩坑
后端
javaTodo9 分钟前
Claude Code 记忆机制详解:从 CLAUDE.md 到 Auto Memory,六层体系全拆解
后端
LSTM9730 分钟前
使用 C# 和 Spire.PDF 从 HTML 模板生成 PDF 的实用指南
后端
JaguarJack42 分钟前
为什么 PHP 闭包要加 static?
后端·php·服务端
BingoGo1 小时前
为什么 PHP 闭包要加 static?
后端
是糖糖啊1 小时前
OpenClaw 从零到一实战指南(飞书接入)
前端·人工智能·后端
百度Geek说1 小时前
基于Spark的配置化离线反作弊系统
后端
Java编程爱好者2 小时前
虚拟线程深度解析:轻量并发编程的未来趋势
后端
苏三说技术2 小时前
Spring AI 和 LangChain4j ,哪个更好?
后端