CPU Cache 原理

计算机的 RAM 分为 static RAM (SRAM) 和 dynamic RAM (DRAM) 两类。SRAM 速度快但是成本高容量小,DRAM 反之。主要原因是硬件上,SRAM 要复杂许多。

因此,现代计算机仅在 CPU cache 使用 SRAM,内存则使用 DRAM。计算机可以通过将内存(慢)中的数据缓存到 CPU cache(快)中获取极大的性能提升。其原因主要是:

程序使用的指令和内存存在 空间局部性(spatial locality) 和 时间局部性(temporal locality)。具体是指:

  • spatial locality: 内存上存储位置接近
  • temporal locality: 被使用的时间接近

例子:

spatial locality temporal locality
code 循环中执行同样的语句 循环中调用函数(函数地址不接近,但是会高频率调用)
data 使用数组 使用不久前使用过的数据

3.1 CPU Caches in the Big Picture

首先,CPU 不直接与内存交互,所有读写必须经过缓存。现代计算机的缓存架构如下:

CPU cache architecture

可以看到,读内存时,数据从内存经过总线进入 CPU cache。依次存入各级缓存,供 CPU 使用。

注意,L1 Cache 分为了指令缓存 (L1i Cache) 和数据缓存 (L1d Cache)。这样设计的原因是一般情况下指令和数据是不相关的。

我们可以通过以下命令查看缓存大小:

sysctl -a | grep -e 'hw.*cache'

得到缓存信息(Apple M1 Max):

hw.cachelinesize: 128
hw.l1icachesize: 131072
hw.l1dcachesize: 65536
hw.l2cachesize: 4194304

关键信息是:

  • L1i Cache 为 128 kB
  • L1d Cache 为 64 kB
  • L2 Cache 为 4 MB

cacheline 是 CPU cache 一次性可以加载的内存大小,这里是 128 字节。

3.1.1 CPU Caches Sharing

在多核、多线程(指硬件线程,如 Intel 的超线程)的现代 CPU 架构下,cache 的共享方式为:

CPU cache sharing

即:

  • 每个核拥有独立的 L1 cache
    • 如果这个核有多个硬件线程(e.g., Intel hyper-threading),那么它们共享 L1 Cache。
  • 所有核共享 L2 及以上的 cache

3.2 Cache operation at high level

为了效率,CPU cache 一次会加载 cacheline 大小的内存。现在(2022)年的普通 CPU cacheline 一般是 64 字节或者 128 字节。cacheline 是 CPU cache 读写的最小单位

无论读还是写,CPU cache 都需要先加载操作的内存所在的 cacheline。注意,写入的时候也是覆盖一整条 cacheline,所以我们需要先加载进 CPU cache 再做修改,否则写入的时候,除了修改的内存,我们无法知道其它内存应该填写什么值。

当 CPU 更改了 cache 中的值,但是还未来得及写回内存时,这个 cacheline 被标记为 "dirty",这个 flag 会在写入内存后清除。这在单核的时候非常直观,但是多核的时,一个核如何才知道一段内存是不是 "dirty" 的,也就是正在被别的核修改呢?

对于多核 CPU,所有处理器观测到的内存应该是一致的(cache coherency)。为了实现这个功能,处理器会监听彼此的写操作,并且将写入的地址与自己的 cache 做比较。当侦测到写操作时,会将自己的这段 cacheline 标记为失效。一个最重要的缓存一致性协议 MESI 可以总结为:

  • "dirty" 的 cacheline 不会出现在除了执行写入操作的其它处理器的 cache 中。

  • "clean" 的 cacheline 可以出现在任意多个 cache 中。

3.3 CPU Cache Implementation Details

略。

3.3.3 Write Behavior

我们已经提过,CPU Cache 需要保证一致性,并且其实现对于用户是透明的。即,如果 cacheline 被更改了,在用户看来应该和没有 CPU cache 时一致,即直接反映到了内存上。

要保证这一点,有两种实现方法:

  • write-through
  • write-back

在 write-through 的实现中,如果某个 cacheline 被写入了,处理器会立即将这个 cacheline 写到内存。这样的实现简单但是比较慢,因为 CPU 每次更改数据都会进行内存写入。

write-back 实现有更佳的性能。当 cacheline 被修改后,处理器不马上将其写入内存,而是将它标记为 "dirty"。当 cacheline 不再使用时,带 "dirty" 标记的会被写回内存。这样做的好处是显而易见的,可以避免总线通信和内存写入的时间。

但是,write-back 实现在多核 CPU 上存在问题。如果一个核将某个 cacheline 标记为 dirty,另一个核尝试去读,由于最新的更改还未写入到内存,第二个核显然不能直接去读内存,否则就破坏了一致性。

3.3.4 Multi-Processor Support

为了解决 write-back 实现在多核 CPU 上的问题,显然,让一个核去直接访问另一个核的缓存是不行的。那么,只能将被改动的 cacheline 拷贝到其它核的缓存中。我们不能简单在每次写入后都执行这个拷贝,这样太慢了。于是,MESI(Modified, Exclusive, Shared, Invalid) 缓存一致性协议问世了。

  • 一开始,所有 cacheline 都是空的,属于 Invalid 状态
  • 因为 写数据 加载的 cacheline 标记为 Modified
  • 因为 读数据 加载的 cacheline 有两种情况:
    • 如果 其它处理器没有加载 这个 cacheline,则标记为 Exclusive
    • 如果 其它处理器加载了 这个 cacheline,则标记为 Shared

这里的每个状态都是对当前处理器而言的。例如,Modified 是指被当前处理器更改过,如果再被其它处理器修改(remote write),则变为 Invalid 状态。

当处理器尝试写入状态为 Shared 或者 Invalid 的 cacheline 时,它必须 通知所有其它的处理器 将这个 cacheline 的状态设置为 Invalid。这个操作被称为 Request For Ownership(RFO)。由于它非常昂贵,我们需要极力避免引发 RFO 的操作。

其状态转移如下图所示:

MESI state transition

一共有四种操作,总结其影响如下:

  • local read,不可能引起 RFO
  • local write,如果写入 Shared 的 cacheline,引起 RFO
  • remote read by another processor,不可能引起 RFO,但是导致 Exclusive 变为 Shared
  • remote write by another processor,必然引起 RFO

因此,CPU 总是希望尽可能多的 cacheline 处在 Exclusive 状态。因为在该状态下既可以利用缓存的优势,又对 local write 最为友好。

Example

现在用一个例子说明 cacheline 的影响。

在这个例子中,我们用多个核并行更改数组中元素的值。由于保证了被更改的元素不是同一个,不存在 data sharing 也不存在 race condition。但是,由于缓存的影响(被修改的元素可能存在同一个 cacheline,也就是经常说的 false sharing),速度实质上也根据选择的元素不同而不同。

#include <chrono>
#include <iostream>
#include <thread>
#include <vector>

// a toy function to measure cache performance
void func(std::vector<int> *counter, size_t index) {
  for (int i = 0; i < 1000000; ++i) {
    (*counter)[index] += 1;
  }
}

auto run_in_parallel(size_t thread_num, size_t offset) {
  auto start = std::chrono::steady_clock::now();
  std::vector<int> counter(1024, 0);

  std::vector<std::thread> threads;
  for (size_t i = 0; i < thread_num; ++i) {
    threads.emplace_back(func, &counter, i * offset);
  }
  for (auto &t : threads) {
    t.join();
  }

  auto end = std::chrono::steady_clock::now();

  return std::chrono::duration_cast<std::chrono::microseconds>(end - start);
}

int main(int argc, char const *argv[]) {
  std::cout
    << "hardware_destructive_interference_size == "
    << std::hardware_destructive_interference_size << '\n'
    << "hardware_constructive_interference_size == "
    << std::hardware_constructive_interference_size << "\n";

  size_t thread_num = 8;

  auto offset1 = std::hardware_constructive_interference_size / sizeof(int);
  for (size_t offset = 1; offset < offset1; offset *= 2) {
    auto elapsed_us = run_in_parallel(thread_num, offset).count();
    std::cout << "Time elapsed: " << elapsed_us << "us for offset "<< offset <<"\n";
  }

  auto offset2 = std::hardware_destructive_interference_size / sizeof(int);
  for (size_t offset = offset1; offset <= offset2 + offset1; offset += offset1) {
    auto elapsed_us = run_in_parallel(thread_num, offset).count();
    std::cout << "Time elapsed: " << elapsed_us << "us for offset "<< offset <<"\n";
  }
  return 0;
}

这个例子的运行结果是:

hardware_destructive_interference_size == 256
hardware_constructive_interference_size == 64
Time elapsed: 53215us for offset 1
Time elapsed: 33159us for offset 2
Time elapsed: 16543us for offset 4
Time elapsed: 11989us for offset 8
Time elapsed: 7003us for offset 16
Time elapsed: 6842us for offset 32
Time elapsed: 6059us for offset 48
Time elapsed: 6184us for offset 64
Time elapsed: 6113us for offset 80

可以看到,当并行修改的数据间隔在 64 字节以下时,速度明显慢了不少。结合之前的 MESI 协议,这是由于修改处于 Shared 状态的 cacheline 导致频繁 RFO 的影响。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,137评论 6 511
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,824评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,465评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,131评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,140评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,895评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,535评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,435评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,952评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,081评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,210评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,896评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,552评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,089评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,198评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,531评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,209评论 2 357

推荐阅读更多精彩内容