在准备我在QCon SF 2014上的演讲时,我想研究一种有关微基准不是如何有效地反映作为大型应用程序的一部分运行时软件的行为的理论。 特别是最后一级缓存中的争用(当前Intel CPU中为L3 *)。
一个例子
去年,在开发用于存储市场数据的系统时,我需要一个时间序列数据存储。 过去,我曾使用LevelDB解决类似的问题,然后再次考虑使用它。 我正在构建的系统具有相当高的吞吐量要求(160K ops / sec)。 我围绕要存储的数据建立了一个微基准测试,以验证LevelDB是否足够快。 它设法以大约400K ops / sec的速度运行,这表明这将是一个可行的解决方案。 但是,当我将代码合并到主应用程序中并在端到端性能测试环境中运行它时,它无法保持上述吞吐率。
我怀疑在微型基准内运行代码时,它具有整个L3缓存本身。 当作为大型应用程序的一部分运行时,在同时运行多个其他繁忙线程的情况下,共享的L3缓存成为争用点,这将严重影响数据存储的吞吐量。 争用L3的问题在于,每个线程将不断替换高速缓存中的值,从而推出另一个线程的高速缓存行,这意味着内存访问丢失高速缓存的可能性更大,并且需要从主线程访问数据记忆。
CPU体系结构刷新
如果您对计算性能抱有普遍兴趣,并且在过去的十年中一直没有遇到困难,那么您可能已经知道现代CPU具有多层内存层次结构; 由处理器和主内存之间的一系列逐渐变大和变慢的高速缓存组成。 下图显示了Intel Nehalem CPU的布局。
如您所见,每个核心都有专用的L1和L2缓存,但是L3缓存是共享资源。 如果2个线程在2个独立的内核上运行,并且它们的工作集完全适合L1或L2,并且每个线程使用的内存是独立的,则这2个线程可以并行运行而不会互相妨碍。 因此,问题仍然在于,如果工作集更大并且需要L3,它将如何影响各个线程的性能。
性能测试
为了了解我是否能够理解影响,我编写了一个小型性能测试 。 该测试通过保存两个整数数组来工作。 第一个保存一组随机值,第二个保存进入第一个数组的索引。 代码遍历索引数组,从第一个数组中读取值。 索引数组经过重新排序,形成了混合顺序访问和随机访问的数据访问模式。 该测试测量通过索引数组加载数据数组中的所有值的速度。
该测试在不同数量的线程(一到五个)下运行,这些线程具有一系列工作集大小,范围从16字节到128 MB。 下图显示了针对工作集大小而变化的线程数的吞吐量。
每个方案的吞吐量以相对线性的方式进行,直到工作集的大小达到L3的大小为止,在我的测试机上,L3的大小恰好为8MB。 此后,由于大多数访问停止达到L3,并且不得不从主内存中获取数据,因此性能下降。 有趣的是,所有情况下的吞吐量都在8MB左右收敛到大致相同的值。 如果我们不绘制吞吐量,而是计算每个方案在基准单线程情况下的“加速”,那么我们将通过工作集大小来查看代码的可伸缩性***。
当每个线程的工作集与L3的大小匹配时,“加速”或可伸缩性显然会急剧下降。 这就是我在帖子标题中所说的“不可思议的山谷” 。 在这一点上,代码已“完全竞争”,或者引用梅尔的故事 ,
“最悲观”。 这非常清楚地表明,在完全独立的数据集上工作的多个线程在不同的内核(但在同一套接字上)运行时可以相互竞争。 到性能下降的地步,以至于等效于在单个线程上运行代码。
从中可以得出以下几点:
- 当心相信微基准测试的具体数字,因为在繁忙的系统上运行时,同一内核上的其他线程很有可能会降低基准测试代码的整体性能。
- 如果编写并行代码,则工作集大小非常重要。 在fork / join样式的应用程序中,代码停止细分的点应考虑工作集的大小,以避免竞争L3。
还有另一件事要考虑。 上面的测试基于顺序访问和随机访问的混合。 如果我们更改测试对象,以使索引数组不被改组,以使所有内存访问都是顺序的,那么“不可思议的山谷”还会出现吗?
查看这些图表,对于2线程情况,“加速”只有8MB的小幅度移动。 但是,这可以通过运行间差异来解释,因此不是结论性的。 我认为,如果内存访问纯粹是顺序访问,那么L3缓存争用不太可能对性能和可伸缩性产生重大影响。 因此,关于它们访问内存的方式完全(或大部分是)顺序的(惊奇,惊奇)算法将遭受较少的性能影响。 但是,有许多算法因其性质而随机访问,例如,垃圾收集系统的标记阶段。 在这些情况下,请注意工作集的大小以及系统中其他线程在做什么,这对于确保代码正常运行很重要。
*一些最新的Haswell CPU具有L4高速缓存。
**特别高的延迟。
***使用其他线程可以提高吞吐量。
翻译自: https://www.javacodegeeks.com/2014/12/the-uncanny-valley-of-l3-cache-contention.html