【问题标题】:Flushing the cache to prevent benchmarking fluctiations刷新缓存以防止基准测试波动
【发布时间】:2015-12-25 07:00:34
【问题描述】:

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

【问题讨论】:

  • 你说的是 CPU 缓存,还是操作系统的磁盘缓存?额外的 18 秒似乎太长了,不能仅仅从 RAM 中填充 CPU 缓存、+页面错误和 TLB 未命中。即使是分散的随机读取,也应该能够在 well 不到 1 秒的时间内填满大型 Xeon 的 L3 缓存的所有缓存行。
  • @PeterCordes 根据我的计算,如果您进行分散读取,这些读取是串行相关的并且每个读取需要 100 ns,即使忽略 TLB 和其他成本,您在18 秒,看来在可行范围内。

标签: c++ caching benchmarking timing


【解决方案1】:

不是一个“适用于所有地方”的产品。大多数处理器都有刷新缓存的特殊指令,但它们通常是特权指令,因此必须从操作系统内核内部完成,而不是从您的用户模式代码中完成。当然,每种处理器架构的指令完全不同。

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

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

其他想法

另一种模拟“非缓存”行为的方法是为每个基准测试通道分配一个新区域 - 换句话说,在基准测试结束之前不释放内存或使用包含数据和输出结果的数组,例如每次运行都有自己的一组数据可供处理。

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

【讨论】:

  • 此解决方案是否适用于多核系统?如果 LLC 在核心集群(例如 NUMA 节点)之间共享会怎样?这不应该在每个 LLC 的基础上完成吗?
【解决方案2】:

这是我的基本方法:

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

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

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

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

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

在这方面,这两种方法本质上都不比另一种“更好”:在现实世界中,缓存级别将混合修改和未修改的行,而这些方法将缓存置于两个极端连续体。原则上,您可以同时使用全 M 和非 M 状态进行基准测试,看看它是否很重要:如果确实如此,您可以尝试评估缓存的实际状态通常是什么副本。


1请记住,LLC 的大小几乎在每一代 CPU 中都在增长(主要是因为核心数量在增加),因此如果这需要面向未来,您需要留出一些增长空间。

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-08-29
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多