缓存失效确实是计算机科学中最难的问题之一

我打算写一篇文章,作为一个练习来帮助我理解当缓存失效时发生了什么。毕竟,理解一件事的最好方法就是试着向别人解释。

但请注意,我在这里写的主题超出了我个人的专业领域,所以请注意!

问题:两个 CPU 性能波段

这是该帖子中的一张图表,它说明了这个问题。它显示集群内不同虚拟机实例(节点)的 CPU 利用率。所有节点的配置都相同,包括运行相同的应用程序逻辑和接收相同的流量。

一个低波段的 CPU 利用率约为 15-20%,另一个变化很大,大约为 25%-90%。详细解释见:https://netflixtechblog.com/seeing-through-hardware-counters-a-journey-to-threefold-performance-increase-2721924a2822

缓存和多核

计算机程序将它们需要的数据保存在主内存中。计算机的主存问题是访问速度慢。一个 CPU 指令周期大约是 400ps,访问主内存(DRAM 访问)是 50-100ns,这意味着它需要大约 125-250 个周期。为了提高性能,CPU 将一些内存保存在更快的本地缓存中。

缓存的大小和速度之间存在权衡,因此计算机架构师使用分层缓存设计,在这种设计中,他们拥有多个不同大小和速度的缓存。这是一种与最快的核心缓存(L1 缓存)的交互模式导致了此处描述的问题,因此这是我们将在本文中重点关注的缓存。

如果您是一名计算机工程师,正在设计一个多核系统,其中每个内核都有内核缓存,您的系统必须针对称为缓存一致性的问题实施解决方案。

缓存一致性

想象一个多线程程序,其中每个线程都在不同的内核上运行:

  • 线程 T1 在 CPU 1 上运行
  • 线程 T2 在 CPU 2 上运行

程序使用了一个变量,我们称之为x。

我们还假设两个线程之前都读取过x,因此与 x 关联的内存被加载到两者的缓存中。所以缓存看起来像这样:

现在假设线程 T1 修改 x,然后 T2 读取 x。

代码语言:javascript
复制
T1             T2
--             --
x = x + 1
              if(x==0) {
              // shouldn't execute this!
              }

问题是 T2 的本地缓存已经过时,因此它读取一个不再有效的值。

术语缓存一致性指的是确保多核(或更一般地,分布式)系统中的本地缓存保持同步的问题。

这个问题是通过一个叫做缓存控制器的硬件设备来解决的。缓存控制器可以检测缓存中的值何时在一个内核上被修改,以及另一个内核是否缓存了相同的数据。在这种情况下,缓存控制器会使陈旧的缓存无效。在上面的示例中,缓存控制器将使 T2 中的缓存无效。当 T2 去读取变量 x 时,它必须将数据从主内存读取到内核中。

缓存一致性确保行为是正确的,但每次缓存失效并且必须再次从主内存检索相同的内存时,它都会付出从主内存读取的性能损失。

数据以块的形式进入缓存

假设一个程序需要从主存中读取数据。例如,假设它需要读取名为x的变量。假设x被实现为 32 位(4 字节)整数。当CPU从主存读取时,存放变量x的内存会被带入缓存。

但是 CPU 不会只是将变量x读入缓存。它会将包含变量x的连续内存块读取到缓存中。在 x86 系统上,这个块的大小是 64 字节。这意味着访问编码变量x的 4 个字节实际上最终会带来 64 个字节。

这些存储在高速缓存中的内存块称为高速缓存行。

伪共享

我们现在几乎有足够的上下文来解释故障模式。这是来自 OpenJDK 存储库的 C++ 代码片段。

代码语言:javascript
复制
class Klass : public Metadata {
  ...
  // Cache of last observed secondary supertype
  Klass*      _secondary_super_cache;
  // Array of all secondary supertypes
  Array<Klass*>* _secondary_supers;

这在 Klass 类中声明了两个指针变量:_secondary_super_cache和_secondary_supers。因为这两个变量是一个接一个地声明的,所以它们会在内存中并排放置。

这两个变量在主存中是相邻的

_secondary_super_cache 本身就是一个缓存。这是一个非常小的缓存,只有一个值。它在代码路径中用于动态检查特定 Java 类是否是另一个类的子类型。此代码路径不常用,但它确实发生在运行时动态创建类的程序中。

现在想象以下场景:

有两个线程:CPU 1 上的 T1,CPU 2 上的 T2 T1 想要写入 _secondary_super_cache变量并且已经在其 L1 缓存中加载了与 _secondary_super_cache 变量关联的内存 T2 想要从_secondary_supers变量读取并且已经在其 L1 缓存中加载了与 _secondary_supers 变量关联的内存。当 T1(CPU 1)写入 _secondary_super_cache 时,如果 CPU 2 在其缓存中加载了相同的内存块,则缓存控制器将使 CPU 2 中的该缓存行无效。

但是,如果该缓存行包含 _secondary_supers 变量,则 CPU 2 将不得不从内存中重新加载该数据以进行读取,这很消耗性能。

ssc指的是_secondary_super_cache,ss指的是_secondary_supers

这种缓存控制器使核心需要访问的缓存非陈旧数据无效的现象,恰好与陈旧数据位于同一缓存行,称为伪共享。

在这种情况下伪共享的概率是多少?

在这种情况下,这两个变量都是指针。在这个特定的 CPU 架构上,指针是 64 位或 8 字节。L1 缓存行大小为 64 字节。这意味着一个缓存行可以存储 8 个指针。换个说法,一个指针可以占据高速缓存行中 8 个位置中的一个。

只有一种情况两个变量不会在同一缓存行结束:当_secondary_super_cache占据位置 8,而_secondary_supers占据位置 1。在所有其他情况下,这两个变量将占据相同的缓存行,因此将容易受到虚假共享的影响。

1 / 8 = 12.5%,这大致是在这种情况下在低频段观察到的节点数。