刷新缓存以防止基准测试波动

Flushing the cache to prevent benchmarking fluctiations

我正在 运行使用某人的 C++ 代码对数据集进行基准测试。我遇到的问题是,我经常得到第一个 运行 的时间,如果我再次 运行 相同的代码,这些数字会发生巨大变化(即 28 秒到 10 秒)。我假设这是由于 CPU 的自动缓存造成的。有没有办法刷新缓存,或者以某种方式防止这些波动?

没有一个有效 "for everything, everywhere"。大多数处理器都有刷新缓存的特殊指令,但它们通常是特权指令,因此必须从 OS 内核内部完成,而不是您的用户模式代码。当然,对于每个处理器架构,指令都是完全不同的。

所有当前的 x86 处理器都有一条 clflush 指令,可以刷新一个缓存行,但要做到这一点,您必须拥有要刷新的数据(或代码)的地址。这对于小型和简单的数据结构来说很好,如果你有一个到处都是的二叉树就不太好了。当然,一点也不便携。

在大多数环境中,读取和写入大块替代数据,例如类似于:

// Global variables.
const size_t bigger_than_cachesize = 10 * 1024 * 1024;
long *p = new long[bigger_than_cachesize];
...
// When you want to "flush" cache. 
for(int i = 0; i < bigger_than_cachesize; i++)
{
   p[i] = rand();
}

使用 rand 将比填充某些东西 constant/known 慢得多。但是编译器无法优化调用,这意味着它(几乎)保证代码会保留。

上面的代码不会刷新指令缓存 - 这很难做到,基本上,您必须 运行 一些(足够大的)其他代码才能可靠地做到这一点。但是,指令缓存往往对整体基准性能的影响较小(指令缓存对于现代处理器的性能极为重要,这不是我要说的,但从某种意义上说,基准测试的代码通常足够小,完全适合在缓存中,基准测试 运行s 在相同代码上多次,所以它只是第一次迭代较慢)

其他想法

另一种模拟 "non-cache" 行为的方法是为每个基准测试分配一个新区域 - 换句话说,直到基准测试结束或使用包含数据的数组并输出结果才释放内存, 这样每个 运行 都有自己的数据集要处理。

此外,实际测量基准测试 "hot runs" 的性能是很常见的,而不是缓存为空的第一个 "cold run"。这当然取决于您实际想要实现的目标...

这是我的基本方法:

  1. 如果您可以动态确定 LLC 大小(或者您静态知道),则分配 2 倍于 LLC 大小的内存区域,或者如果您不能,则分配平台上最大 LLC 大小的合理倍数感兴趣1.
  2. memset 内存区域到某个 non-zero 值:1 就可以了。
  3. "Sink" 某处的指针,这样编译器就无法优化上面或下面的东西(写入 volatile 全局几乎 100% 的时间都有效)。
  4. 从该区域中的随机索引读取,直到您平均触及每个缓存行 10 次左右(将读取的值累加成一个总和,您以与 (3) 类似的方式下沉)。

这里有一些说明,说明为什么这通常有效,为什么少做可能无效 - 细节以 x86 为中心,但类似的问题也适用于许多其他架构。

  • 在开始主 read-only 刷新循环之前,您绝对希望 写入分配的内存(第 2 步),否则您可能只是重复读取OS 返回的相同小 zero-mapped page 以满足您的内存分配。
  • 您想要使用比 LLC 大小大得多的区域,因为外部缓存级别通常是物理寻址的,但您只能分配和访问虚拟地址。如果你只是分配一个 LLC-sized 区域,你通常不会完全覆盖每个缓存集的所有方式:一些集将是 over-represented (因此将被完全刷新),而其他集是 under-represented ,因此并非所有现有值都可以通过访问该内存区域来刷新。 2x over-allocation 很可能几乎所有集合都具有足够的代表性。
  • 你想避免优化器做一些聪明的事情,比如注意到内存永远不会逃脱函数并消除你所有的读写。
  • 您想围绕内存区域 随机 迭代,而不是线性地跨过它:一些设计,例如最近 Intel 上的 LLC 检测何时出现 "streaming" 模式存在,并从 LRU 切换到 MRU,因为 LRU 是关于此类负载的 worst-possible 替换策略。结果是,无论您通过内存流式传输多少次,您之前的一些 "old" 行都可以保留在缓存中。随机访问内存会破坏这种行为。
  • 您想要访问的不仅仅是 LLC 内存量,原因是 (a) 与分配超过 LLC 大小的原因相同(虚拟访问与物理缓存)和 (b) 因为随机访问需要更多访问才能获得很有可能达到每个集合足够的次数 (c) 缓存通常只有 pseudo-LRU,因此您需要比 exact-LRU 下预期的访问次数更多的次数来刷新每一行。

即使这样也不是万无一失的。上面未考虑的其他硬件优化或缓存行为可能会导致此方法失败。您可能会因为 OS 提供的页面分配而感到非常不走运,并且无法到达所有页面(您可以通过使用 2MB 页面在很大程度上缓解这种情况)。我强烈建议测试您的刷新技术是否足够:一种方法是使用 CPU 性能计数器测量缓存未命中的次数,而 运行 您的基准并根据已知的 working-set尺寸2.

请注意,这会使所有级别的缓存中的行处于 E(独占)或 S(共享)状态,而不是 M(修改)状态。这意味着当这些行被基准测试中的访问替换时,它们不需要被逐出到其他缓存级别:它们可以简单地被删除。 中描述的方法将使 most/all 行处于 M 状态,因此对于您在基准测试中访问的每一行,最初都会有 1 行驱逐流量。您可以通过将第 4 步更改为写入而不是读取来实现与我上面的食谱相同的行为。

在这方面,这两种方法在本质上都不 "better":在现实世界中,缓存级别将混合修改和 not-modified 行,而这些方法离开缓存在连续体的两个极端。原则上,您可以同时使用 all-M 和 no-M 状态进行基准测试,看看它是否重要:如果重要,您可以尝试评估缓存的 real-world 状态通常会是什么复制那个。


1请记住,LLC 大小几乎每 CPU 一代都在增长(主要是因为核心数量在增加),所以如果这需要 future-proof。

2 我只是把它扔在那里就好像它是 "easy",但实际上可能非常困难,具体取决于您的具体问题。