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

Flushing the cache to prevent benchmarking fluctiations

本文关键字:基准测试 缓存 刷新      更新时间:2023-10-16

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

不是一个"适用于所有地方"的。大多数处理器都有专门的指令来刷新缓存,但它们通常是特权指令,因此必须在操作系统内核内部执行,而不是在用户模式代码中执行。当然,对于每个处理器体系结构来说,这是完全不同的指令。

所有当前的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将比填充常量/已知值慢得多。但编译器无法优化调用,这意味着(几乎)可以保证代码会保留下来。

上面的操作不会刷新指令缓存——这要困难得多,基本上,你必须运行一些(足够大的)其他代码才能可靠地执行。然而,指令缓存往往对整体基准测试性能的影响较小(指令缓存对现代处理器的性能极其重要,我不是这么说的,但从某种意义上说,基准测试的代码通常足够小,可以全部放在缓存中,并且基准测试在同一代码上运行多次,所以第一次迭代时只会慢一些)

其他想法

模拟"非缓存"行为的另一种方法是为每次基准测试分配一个新的区域-换句话说,在基准测试结束之前不释放内存,或者使用包含数据和输出结果的数组,这样每次运行都有自己的数据集

此外,通常实际测量基准测试的"热运行"的性能,而不是第一次缓存为空的"冷运行"。这当然取决于你实际想要达到的目标。。。

以下是我的基本方法:

  1. 如果您可以动态地(或静态地)确定LLC大小,或者如果您不这样做,则分配一个LLC大小的2倍的内存区域1
  2. memset将内存区域设置为某个非零值:1会做得很好
  3. 将指针"下沉"到某个地方,这样编译器就无法优化上面或下面的内容(写入volatile全局几乎100%有效)
  4. 从区域中的随机索引中读取,直到您平均接触每个缓存行10次左右(将读取值累积为一个总和,以类似于(3)的方式下沉)

以下是一些关于为什么这通常是有效的以及为什么少做可能不起作用的注意事项——细节是以x86为中心的,但类似的问题也适用于许多其他体系结构。

  • 在开始主只读刷新循环之前,您绝对希望写入分配的内存(步骤2),因为否则您可能只是重复读取操作系统返回的同一个零映射小页面,以满足您的内存分配
  • 您希望使用比LLC大得多的区域,因为外部缓存级别通常是物理寻址的,但您只能分配和访问虚拟地址。如果你只分配一个LLC大小的区域,你通常不会完全覆盖每个缓存集的所有方式:一些集会被过度表示(因此会被完全刷新),而其他集则被过度表示,因此甚至不是所有现有值都可以通过访问该内存区域来刷新。2倍的超额分配使得几乎所有集合都有足够的表示性
  • 您希望避免优化器做一些巧妙的事情,比如注意内存从未逃离函数,以及消除所有的读写操作
  • 您希望在内存区域周围随机迭代,而不是线性地遍历它:一些设计,如最近英特尔上的LLC,会检测到何时存在"流式"模式,并从LRU切换到MRU,因为LRU是这种负载最糟糕的替换策略。其效果是,无论您在内存中流式传输多少次,在您努力之前的一些"旧"行都可以保留在缓存中。随机访问内存会破坏这种行为
  • 您想要访问的内存量不仅仅是LLC大小,原因与(a)您分配的内存量超过LLC大小的原因相同(虚拟访问与物理缓存),以及(b)因为随机访问需要更多的访问,才有可能达到足够的次数(c)缓存通常只是伪LRU,因此,您需要的访问次数超过您在精确LRU下所期望的访问次数,才能清空每一行

即使这样也不是万无一失的。上面没有考虑的其他硬件优化或缓存行为可能会导致这种方法失败。操作系统提供的页面分配可能会让你非常不走运,无法访问所有页面(使用2MB页面可以在很大程度上缓解这种情况)。我强烈建议测试刷新技术是否足够:一种方法是在运行基准测试时使用CPU性能计数器来测量缓存未命中的数量,并根据已知的工作集大小2来判断这个数字是否合理。

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

在这方面,这里的两种方法本质上都不比另一种"更好":在现实世界中,缓存级别将混合修改和未修改的行,而这些方法使缓存处于连续体的两个极端。原则上,你可以用全M状态和无M状态进行基准测试,看看它是否重要:如果重要,你可以尝试评估缓存的真实状态通常是什么样的复制。


1请记住,LLC的规模几乎每一代CPU都在增长(主要是因为核心数量在增加),所以如果这需要经得起未来考验,您需要留出一些增长空间。

2我只是把它扔在那里,好像它"很容易",但实际上可能很难,这取决于你的确切问题。