CPU

存储器的层次结构

  • 寄存器;
  • CPU Cache;
    1. L1-Cache;
    2. L2-Cache;
    3. L3-Cahce;
  • 内存;
  • SSD/HDD 硬盘

每个 CPU 核心都有一块属于自己的 L1 高速缓存,指令和数据在 L1 是分开存放的,所以 L1 高速缓存通常分成指令缓存数据缓存

L2 高速缓存同样每个 CPU 核心都有,但是 L2 高速缓存位置比 L1 高速缓存距离 CPU 核心 更远,它大小比 L1 高速缓存更大,CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 10~20 个时钟周期。

L3 高速缓存通常是多个 CPU 核心共用的,位置比 L2 高速缓存距离 CPU 核心 更远,大小也会更大些,通常大小在几 MB 到几十 MB 不等,具体值根据 CPU 型号而定。

L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的。

存储器的层次关系

CPU 并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。

比如,CPU Cache 的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache 不会直接把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到 CPU Cache 中。

访问速度

L1 Cache 的访问延时是 1 纳秒,而内存已经是 100 纳秒了,相比 L1 Cache 速度慢了 100 倍。另外,机械硬盘的访问延时更是高达 10 毫秒,相比 L1 Cache 速度慢了 10000000 倍,差了好几个数量级别。

CPU Cache 的数据结构和读取过程

CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)

如何写出让 CPU 跑得更快的代码?

访问的数据在 CPU Cache 中的话,意味着缓存命中,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快

  1. 对于数据缓存,我们在遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时,性能能得到有效的提升;
  2. 对于指令缓存,有规律的条件分支语句能够让 CPU 的分支预测器发挥作用,进一步提高执行的效率
  3. 对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心

CPU缓存一致性

两种针对写入数据的方法:

  • 写直达(Write Through
  • 写回(Write Back

写直达

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(*Write Through*)

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

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

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

写回

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

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

缓存一致性问题

要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:

  • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(*Write Propagation*)
  • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串行化(*Transaction Serialization*)

要实现事务串行化,要做到 2 点:

  • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
  • 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

写传播和事务串行化具体是用什么技术

总线嗅探

总线嗅探方法很简单, CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。

MESI 协议

基于总线嗅探机制实现了事务串行化,也用状态机机制降低了总线带宽压力

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。

[独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。

「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

解决伪共享

缓存行大小对齐

缓存行对齐是指在设计数据结构时,确保它们的起始地址与缓存行的大小对齐。这意味着数据结构的起始地址是缓存行大小(例如,64字节)的整数倍。

目的:通过这种方式,可以确保数据结构不会跨越多个缓存行。这有助于减少缓存行的加载和替换操作,因为整个数据结构可以通过单次缓存行加载操作被完整地加载到缓存中,提高了访问效率。

实现:在多数编程语言中,可以通过特定的关键字或属性(如C/C++中的__attribute__((aligned(N))))来指定变量或数据结构的对齐。

字节填充(Padding)

字节填充是在数据结构中故意添加额外的字节,以改变其大小,使其更好地与缓存行边界对齐。

目的:这种方法主要用于避免“伪共享”(False Sharing)。伪共享发生在多线程程序中,当不同线程在同一缓存行上操作不同的变量时,即使这些变量不直接相关,它们的更新也可能导致不必要的缓存同步操作。通过添加填充,可以确保频繁修改的变量不会位于同一缓存行,减少缓存无效化的次数,提高程序性能。

假设有一个结构体,包含几个整型变量,而且我们知道缓存行大小是64字节:

1
2
3
4
5
6
struct Example {
int a;
// 填充以确保a单独占用一个缓存行
char padding[60];
int b;
};

在这个例子中,通过在ab之间添加60个字节的填充,确保ab分别位于不同的缓存行中。这样,在多线程环境下,如果不同线程分别修改ab,它们的操作就不会因为伪共享而互相影响性能。


CPU
http://example.com/2024/03/31/操作系统/CPU/
作者
Mrxiad
发布于
2024年3月31日
许可协议