从物理核到缓存一致性: Linux CPU 缓存揭秘

文摘   2024-10-18 17:43   新加坡  

作者介绍/Author

张新谊,西安邮电大学研二在读,舒新峰教授学生。操作系统和Linux内核爱好者,热衷于探索Linux内核和eBPF技术。


博文内容为 Linux CPU 多级缓存与实践,内容涉及:物理核与逻辑核的概念,CPU多级缓存,CPU的L1/L2/L3缓存查看,缓存流程写入策略,缓存一致性策略,CPU缓存分析。

一、物理核与逻辑核

逻辑核和物理核是CPU中的两个概念,分别涉及到 CPU 的实际硬件和操作系统中呈现的处理单元。

1.物理核(Physical Core)

物理核是处理器中实际存在的硬件核心,它负责执行计算任务。

2.逻辑核(Logical Core)

逻辑核是操作系统看到的虚拟处理单元。在支持超线程技术的 CPU 上,一个物理核可以分成多个逻辑核。通常,每个物理核可以提供两个逻辑核,操作系统认为它有更多的核来分配任务,但实际上这些逻辑核共享物理核的资源。

在Linux系统下,我们可以使用如下命令来查看每个 CPU 核心的 core id

cat /proc/cpuinfo | grep "core id"

输出如下:

core id         : 0
core id         : 1

core id 只有 0 和 1 两个值,意味着只有两个物理CPU。

通过cat /proc/cpuinfo命令可以看到CPU更加详细的信息:

physical id结果显示该实机有两个物理CPU,一个id是1,一个id是0。cpu cores结果表示每个CPU有两个物理核。因为有两个物理CPU,所以该机器有4个物理核。

但是通过lscpu命令看到的是8核,这是因为intel会通过超线程技术将一个物理核虚拟成多个,所以在操作系统层面看到的要比实际的物理核多。

我们还可以看到每个process的id,这里的process即逻辑核:

processor       : 0
physical id     : 0
core id         : 0
.....
processor       : 3
physical id     : 0
core id         : 0
.....
processor       : 8
physical id     : 1
core id         : 1

processor即逻辑核的序号,可以看出这个机器总共有8个逻辑核。但是可以看到processor0和processor3的physical idcore id都是一样的,即其同处于一个物理核上,实际上就是一个核,只是通过虚拟技术虚拟出来的。

超线程里的2个逻辑核实际上是在一个物理核上运行的,模拟双核运作,共享该物理核的L1和L2缓存

二、CPU 多级缓存

1.什么是CPU cache?

为什么会有CPU缓存?首先介绍一下摩尔定律,摩尔定律是由英特尔公司联合创始人戈登·摩尔在1965年提出的一个观察与预测。它指出,在 每18到24个月内,集成电路上可容纳的晶体管数量会增加一倍,也就是说计算机处理器的性能每18到24个月翻一番,同时单位成本会降低。内存的速度当然也会不断增长,但是增长的速度远小于 CPU,于是,CPU 与内存的访问性能的差距不断拉大。

到现在,一次内存访问所需时间是 200~300 多个时钟周期,这意味着 CPU 和内存的访问速度已经相差 200~300 多倍了。为了弥补CPU和内存之间的性能差异,以便于能够真实变得把CPU的性能提升利用起来,而不是让它在那里空转,在现代CPU中引入了高速缓存。从CPU Cache被加入到现有的CPU里开始,内存中的指令、数据,会被加载到L1-L3 Cache中,而不是直接从CPU访问内存中取拿。

2.多级CPU缓存

随着热点数据的体积越来越大,单纯地增加一级缓存的大小已经不足以满足现代处理器的需求,于是引入了多级缓存体系:

由上图见,L1 缓存距离 CPU 核心最近,具有最快的访问速度但容量较小。通常,L1 缓存会分为两部分数据缓存(L1 Data)和指令缓存(L1 code)。这是因为代码和数据的更新策略不同,需要分别进行缓存管理。此外,由于CISC架构中的变长指令,指令缓存需要进行特殊的优化,以提高对变长指令的访问效率。每个物理核心都有自己独立的 L1 数据缓存和 L1 指令缓存。L1 缓存的访问速度极快,通常可以在几个 CPU 时钟周期内完成数据的读取和写入,为 CPU 核心提供最快的数据访问支持,像是 CPU 的贴身助手,随时为其提供最急需的数据。

L2 缓存的容量比 L1 缓存大,但访问速度稍慢。它充当了中间缓冲区的作用,当 L1 缓存未命中时,CPU 会尝试从 L2 缓存中获取数据。可以将 L2 缓存比作一个小型的数据仓库,存储着更多可能被 CPU 核心频繁使用的数据。每个物理核心拥有独立的 L2 缓存,确保其在访问数据时可以减少对其他核心的依赖,提高整体访问效率。

L3 缓存通常具有更大的容量,但访问速度相对较慢。它在多核心处理器中扮演着重要的角色,多个核心共享 L3 缓存中的数据。可以将 L3 缓存比作一个大型的公共数据存储区,为多个核心之间的协同工作提供支持。如果 L1 和 L2 缓存都未命中,CPU 核心会尝试从 L3 缓存中获取数据,以减少直接访问主内存的高延迟。

3.CPU的L1/L2/L3缓存查看

以上介绍的都是比较笼统的概念,但其实每个CPU的缓存都是不一样的,我们可以使用下图的方式来查看各级 CPU Cache 的大小,比如我自己的这台服务器,离 CPU 核心最近的 data L1和code cache都是 32KB,其次是 L2 Cache 是 256KB,最大的 L3 Cache 则是 8192KB。

其中,L1 Cache 通常会分为数据缓存和指令缓存,这意味着数据和指令在 L1 Cache 这一层是分开缓存的,上图中的 index0 也就是数据缓存,而 index1 则是指令缓存,它两的大小是一样的。

程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后进入到最快的 L1 Cache,之后才会被 CPU 读取。它们之间的层级关系,如下图:

越靠近 CPU 核心的缓存其访问速度越快,CPU 访问 L1 Cache 只需要 2~4 个时钟周期,访问 L2 Cache 大约 10~20 个时钟周期,访问 L3 Cache 大约 20~60 个时钟周期,而访问内存速度大概在 200~300 个 时钟周期之间。

4.什么是缓存行?

除了缓存,还有一个重要的概念是Cache line,即缓存行,由于内存、L2、L3等缓存访问都是有成本的,所以本级缓存向下一级取数据时的基本单位并不是字节,而是Cache line 。CPU Cache是由很多个Cache Line组成的,CPU Line是CPU从内存读取的基本单位。

我们可以使用下图的方式来查看CPU的Cache line大小,都是64字节。

每次CPU从内存获取数据,或者L2从L3获取数据,都是以64字节为单位来进行的。哪怕只要取一位(bit),CPU也是取一个Cache Line,然后放到各级缓存里存起来。这也是我们在开发程序的时候要重视内存对齐的底层原因。假设你有一个64字节大小的对象,如果地址对齐过,那一次内存I0就可以完成访问。但如果未曾对齐,那就要两次内存10才行。

缓存行的组成

缓存行作为缓存与主内存之间数据传输的基本单位,它由标志位、标记和数据区域组成。

  • 标志(Flag):标志位用于指示缓存行的状态,例如是否有效、是否被修改等。标记则用于唯一标识缓存行中的数据在主内存中的位置。当 CPU 需要访问某个内存地址的数据时,首先会根据地址计算出对应的缓存行标记,然后检查缓存中是否有匹配的缓存行。如果有,并且标志位显示该缓存行有效,那么就可以直接从缓存行的数据区域中获取数据。

  • 标记(Tag):用于标识该缓存行所对应的内存地址,以便判断该缓存行中的数据是否是处理器正在请求的。

  • 数据(Data):实际存储的数据,通常是64字节。

数据一致性方面,缓存行的存在使得多个处理器在访问共享数据时需要考虑缓存一致性问题。当一个处理器修改了某个缓存行中的数据,其他处理器需要通过一定的机制(如总线嗅探等)来保证自己缓存中的副本数据也得到更新,以维持数据的一致性。

三、缓存流程写入策略

Cache写机制分为写直达写回两种。

1.写直达

写直达是保持内存与 Cache 一致性最简单的方式,就是把数据同时写入内存和 Cache 中

在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:

  • 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
  • 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。

写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

2.写回

在写直达中,由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回的方法

在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Line里,只有当修改过的 Cache Line被替换时才需要写到内存中,这减少了数据写回内存的频率,这样便可以提高系统的性能。

其具体实现流程如下:

  • 如果数据已经存在于 CPU Cache中,则直接将数据更新到该缓存行中,并将该缓存行标记为脏。脏标记表示此时缓存行中的数据与主内存中的数据不一致,在这种情况下,不需要立即将数据写回内存,而是等到合适的时机(例如缓存行被替换时)再进行写回。

  • 如果数据不在 CPU Cache 中,即缓存未命中,这时需要找到合适的缓存行来存放新的数据。

  • 检查缓存行的状态:如果目标缓存行中存放的是其他内存地址的数据,需要判断它是否被标记为脏:

    • 直接从内存中读取当前需要的数据到缓存行中。
    • 然后,将要写入的数据更新到缓存行中,并将该缓存行标记为脏
    • 首先,将缓存行中的脏数据写回内存,以确保内存中的数据是最新的。
    • 接着,从内存中读取当前需要的数据,加载到这个缓存行中(注意,这一步是为了把新数据加载到缓存中,便于后续的写入操作)。
    • 最后,将要写入的数据更新到该缓存行中,并将其标记为脏
    • 如果缓存行是脏的:
    • 如果缓存行不是脏的:

写回方式的优势在于,减少了直接写回内存的次数,提高了系统性能,尤其在大量写操作能够命中缓存的情况下,CPU 不必频繁地与内存交互,从而显著提升数据访问效率。

3.缓存一致性问题

在上述介绍的写回策略中,数据的修改并不会立即写回主内存,而是延迟到合适的时机才进行写回。这种延迟写回的机制在多核处理器系统中可能会引发数据一致性问题。因为在数据被修改后、尚未写回主内存之前,若其他核心也需要访问该数据,可能导致不同核心之间的数据版本不一致。

例如,在一个多处理器系统中,假设核心 A 和核心 B 共享主内存中的一个变量 x,但是各自拥有独立的缓存:

  • 核心 A从主内存中读取了变量 x 并存储在自己的缓存中,并将 x 增加了 1,但此时核心 A 还没有将修改后的值写回主内存,而是将缓存行标记为脏。

  • 与此同时,核心 B也从主内存中读取了变量 x,并执行了同样的操作——将 x 增加 1,但核心 B 也只是在自己的缓存中进行了修改,没有立即写回主内存。

  • 由于核心 A 和核心 B 彼此之间的缓存是独立的,它们对变量 x 的修改都仅存在于各自的缓存中,无法被对方感知。

  • 最终,如果核心 A 和核心 B 都将 x 的值写回主内存,主内存中的 x 只会增加 1,而不是预期的增加 2,因为每个核心都只增加了一次 x,但并不知道对方也做了修改。

这种情况就会导致数据不一致的问题,即主内存中的数据版本落后于缓存中的最新值,未能正确反映所有核心的修改。这种现象称为缓存一致性问题,在多核系统中非常常见,尤其是在多个核心对共享数据进行并发修改时。

为了解决这个缓存不一致的问题,我们就需要有一种机制,满足以下2点:

写传播:在一个 CPU 核心里, Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。

事务的串行化:事务串行化是说,我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。

①总线监听

每个 CPU 核心都通过总线监听其他核心对内存的访问。当一个核心修改了共享数据并将其写入自己的缓存时,会在总线上发出一个信号。其他核心通过监听总线,检测到这个信号后,会检查自己的缓存中是否有该数据的副本。如果有,就将其标记为无效或者更新为新的数据。

②MESI

MESI 协议类似于读写锁的方式,使得针对同一地址的读内存操作是并发的,而针对同一地址的写内存操作是独占的。其缓存状态如下:

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。缓存行必须时刻监听所有试图读该缓存行相对应主存的操作,这种读操作必须在该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid)该Cache line无效。

每个CPU对缓存状态的修改会通过通知->应答的机制通知各CPU。

举个例子:

  • 只有cpu1读取了数据到cache1,此时cache1状态为E状态。
  • cpu2读取cache1,cpu2通知各cpu后,cpu1将cache1置为S状态并应答,同时cpu2的状态置为S状态。
  • cpu1修改了数据,将消息通知cpu2,cpu2将其cache1置位 I (无效)状态;cpu1将cache1置位为M状态。
  • 此时如果cpu2再次访问数据,由于cache1的I状态,那么cpu2发消息到cpu1,通知cpu1将数据写会内存,cpu1将状态置E并将数据写会内存,此时cpu2将数据重新读取到cache1中;同理如果cpu3读取cache1,由于cpu1的状态是M的话,cpu1页会将cache1写回内存。

四、CPU 缓存分析

我们可以通过对 CPU 的三级缓存进行分析,进而优化代码,提高执行速度,提起CPU 吞吐。

1.使用 Valgrind 分析 CPU 缓存

valgrind 实用程序集提供了一组用于内存调试和剖析的工具。其内的 cachegrind工具允许用户进行缓存仿真,并用缓存缺失的数量对源代码进行注释。

工具安装:

sudo snap install valgrind

使用该工具来分析hostnamectl 命令的缓存命中情况:

输出分析

  1. 指令引用 (I refs)
  • I refs: 3,282,457
    • 这是指令引用的总数,表示 hostnamectl 程序总共执行了 3,282,457 条指令。
  • I1 misses: 4,095
    • 一级指令缓存(I1 Cache)未命中数4,095。这些未命中会导致程序不得不从更慢的二级或三级缓存中获取指令。
  • LLi misses: 3,184
    • 最后一级缓存(LL Cache)未命中数3,184,即 L1 缓存未命中后,访问二级缓存或 L3(最后一级缓存)时也未命中。
  • I1 miss rate: 0.12%LLi miss rate: 0.10%
    • 指令缓存的未命中率相对较低,0.12%0.10% 分别表示指令在 L1 和 LL 缓存的未命中概率。这些较低的未命中率说明 hostnamectl 的指令访问在缓存中的命中率较高,整体的缓存行为是比较好的。
  1. 数据引用 (D refs)
  • D refs: 1,208,197 (852,198 rd + 355,999 wr)
    • 数据引用总数1,208,199,其中 852,200 次是读取数据(rd),355,999 次是写入数据(wr)。
  • D1 misses: 43,750 (34,723 rd + 9,027 wr)
    • 一级数据缓存(D1 Cache)未命中数43,750,其中读取数据未命中 34,723 次,写入未命中 9,027 次。相对于总数据引用数,这些未命中的数目是比较显著的。
  • LLd misses: 24,970 (16,248 rd + 8,722 wr)
    • 最后一级数据缓存(LL Cache)未命中数24,970,其中读取数据未命中 16,248 次,写入未命中 8,722 次。这表示在数据访问中有一部分未能在较低层级的缓存中找到,最终需要访问内存。
  • D1 miss rate: 3.6% (4.1% + 2.5%)
    • 一级数据缓存的未命中率为 3.6%,其中数据读取的未命中率为 4.1%,写入的未命中率为 2.5%。这意味着在 L1 数据缓存中,有一部分的数据读取没有找到,因此需要从更低层的缓存中获取。
  • LLd miss rate: 2.1% (1.9% + 2.5%)
    • 最后一级数据缓存的未命中率为 2.1%,表示在数据读取过程中,从所有缓存(包括 L3)中也无法命中的比例。这些访问最终可能需要从主内存获取数据。
  1. 最后一级缓存引用与未命中 (LL refsLL misses)
  • LL refs: 47,845 (38,818 rd + 9,027 wr)
    • 最后一级缓存引用数47,845,其中读取操作占 38,818 次,写入操作占 9,027 次。
  • LL misses: 28,154 (19,432 rd + 8,722 wr)
    • 最后一级缓存未命中数28,154,其中读取未命中 19,432 次,写入未命中 8,722 次。
  • LL miss rate: 0.6% (0.5% + 2.5%)
    • 最后一级缓存的整体未命中率为 0.6%。其中读取未命中率为 0.5%,写入未命中率为 2.5%。这意味着从最后一级缓存(如 L3)访问数据时,有一小部分最终需要从主内存获取。

运行上述指令还会生成一个cachegrind.out文件,现在我们通过cg_annotate工具对cachegrind.out.文件进行注解,它提供了详细的缓存分析

缓存配置
  • I1 cache(一级指令缓存):大小 32,768 字节,行大小 64 字节,8 路组相联。
  • D1 cache(一级数据缓存):大小 32,768 字节,行大小 64 字节,8 路组相联。
  • LL cache(最后一级缓存,L3 缓存):大小 8,388,608 字节(8MB),行大小 64 字节,16 路组相联。

下面的Ir  I1mr  ILmr  Dr  D1mr  DLmr  Dw  D1mw  DLmw是事件显示:

统计数据解释
  • Ir(指令引用): 总共执行了 3,282,457 条指令。
  • I1mr(一级指令缓存未命中): 4,095 次未命中,占比 0.12%。这些未命中的指令访问了更高层级的缓存。
  • ILmr(最后一级指令缓存未命中): 3,184 次未命中,占比 0.10%。这些指令最终不得不访问内存。
  • Dr(数据读取引用): 总共执行了 852,198 次数据读取。
  • D1mr(一级数据缓存未命中): 34,723 次未命中,占比 4.07%。这说明有 4.07% 的读取没有在 L1 数据缓存中找到。
  • DLmr(最后一级数据缓存未命中): 16,248 次未命中,占比 1.91%。这部分数据最终需要从主内存中获取。
  • Dw(数据写入引用): 总共执行了 355,999 次数据写入。
  • D1mw(一级数据缓存写入未命中): 9,027 次写入未命中,占比 2.54%。
  • DLmw(最后一级数据缓存写入未命中): 8,722 次写入未命中,占比 2.45%。
具体函数分析

输出的下半部分列出了各个函数的缓存使用情况,包括命中和未命中情况。以下是几个关键函数的分析:

1../elf/./elf/dl-lookup.c:do_lookup_x

  • 指令引用(Ir):760,995 次,占总数的 23.18%。
  • 一级指令缓存未命中(I1mr):37 次。
  • 数据读取(Dr):262,542 次,占总数据读取的 30.81%,有 11,481 次未命中 L1 数据缓存。

2../elf/../sysdeps/x86_64/dl-machine.h:_dl_relocate_object

  • 指令引用(Ir):370,433 次,占总数的 11.29%。
  • 一级数据缓存未命中(D1mr):7,840 次,占该函数总数据读取的 22.58%。
  • 该函数的数据访问在 L1 缓存中的未命中率较高,导致访问更多的高层次缓存,影响了性能。

总结

从输出中可以看出,程序的大部分指令访问和数据读取可以很好地被缓存命中,但在特定函数中,仍有较高的缓存未命中率。

2.使用 Perf 分析 CPU 缓存

另外可以通过Perf分析CPU缓存:

 sudo perf stat -e l1d.replacement,l1d_pend_miss.pending_cycles,l2_lines_in.all,l2_lines_out.non_silent hostnamectl
 Static hostname: zzxy-virtual-machine
       Icon name: computer-vm
         Chassis: vm
      Machine ID: f274407f7ae3467eac9258e2fc1ef3d5
         Boot ID: 9ec6dc57aade4998a0142f612d9cecc8
  Virtualization: vmware
Operating System: Ubuntu 22.04.4 LTS              
          Kernel: Linux 6.8.0-45-generic
    Architecture: x86-64
 Hardware Vendor: VMware, Inc.
  Hardware Model: VMware Virtual Platform

 Performance counter stats for 'hostnamectl':

                 0      l1d.replacement                                                       
                 0      l1d_pend_miss.pending_cycles                                          
                 0      l2_lines_in.all                                                       
                 0      l2_lines_out.non_silent                                               

       0.191005644 seconds time elapsed

       0.002107000 seconds user
       0.006323000 seconds sys

参考资料:《深入理解linux进程与内存》

公众号: Linux 性能优化之CPU 多级缓存认知


Linux内核之旅
Linux内核之旅
 最新文章