性能问题:L3命中率较低
CPU的最后一级缓存(一般也就是L3),如果命中率不高,对系统性能会有极坏的影响。所以对这一问题,我们呀及时准确的检测、暴露出来。
缓存的命中率,是CPU性能的一个关键性能指标。我们知道,CPU里面有好几级缓存(cache),每一级缓存都比后面以及缓存速度访问快,最后以及缓存叫做LLC(Last Level Cache),LLC的后面就是内存。
当 CPU 需要访问一块数据或者指令时,它会首先查看最靠近的一级缓存(L1);如果数据存在,那么就是缓存命中(Cache Hit),否则就是不命中(Cache Miss),需要继续查询下一级缓存。
缓存不命中的比例对CPU的性能影响很大,尤其是最后一级缓存的不命中时,对性能的损害尤其严重。这个损害主要有方面的性能影响。
- 第一个方面,会影响CPU速度。内存的访问延迟,是LLC的延迟的的很多倍(比如五倍);所以 LLC 不命中对计算速度的影响可想而知。
- 第二个方面,会影响内存带宽。
- 如果LLC没有命中,那么就只能从内存里面去取了。LLC不命中的计数,其实就是对内存访问的计数,因为CPU对内存的访问总是要经过LLC,不会跳过LLC的。所以每一次 LLC 不命中,就会导致一次内存访问;反之也是成立的:每一次内存访问都是因为 LLC 没有命中。
- 更重要的是,一个系统的内存带宽是有限制的,很有可能会成为性能瓶颈。从内存中取数据,就会占用内存带宽。因此,如果LLC命中率低的话,那么对内存带宽的使用就会很大。内存带宽使用率很高的话,内存的存取延迟就会急剧上升。
- 另外,最近几年计算机和互联网发展的趋势是,后台系统需要对越来越多的数据进行处理,因此内存带宽越来越成为性能瓶颈。
使用perf策略LLC不命中率
perf的工作原理:
- 它是在内部使用性能监视单元,也就是PMU(Performance Monitoring Units)硬件来收集各种相关CPU硬件事件的数据,并不会给系统带来太大开销
- 注意,PMU硬件是针对每种处理器特别实现的,所以支持的事件集合以及具体事件原理,在处理器之间可能不同。
PMU 尤其可以监测 LLC 相关的指标数据,比如 LLC 读写计数、LLC 不命中计数、LLC 预先提取计数等指标。具体用 Perf 来测量 LLC 各种计数的命令格式是:
perf stat -e LLC-loads,LLC-load-misses,LLC-stores,LLC-store-misses
下图显示的是一次 Perf 执行结果。
我们可以看到,在这段取样时间内,有 1951M(19.51 亿)次 LLC 的读取,大约 16% 是不命中。有 313M(3.13 亿)次 LLC 的写入,差不多 24% 是不命中。
如何降低 LLC 的不命中率?
根据具体的问题,至少有三个解决方案。而且,这三个方案也不是互相排斥的,完全可以同时使用。
缩小数据结构,让数据变得紧凑
- 对一个系统而言,所有的缓存大小,包括最后一级缓存LLC,都是固定的。
- 如果每个数据变小,各级缓存自然可以缓存更多的数据,也就可以提高缓存的命中率。
举个例子,开源的 C++ Folly 库里面有很多类,比如 F14ValueMap,就比一般的标准库实现小很多,从而占用比较少的内存;采用它的话,自然缓存的命中率就比较高。
用软件方式来预取数据
- 通过合理预测,把以后可能要读取的数据提前取出,放到缓存里面,这样可以提高缓存命中率
- 这是一种“空间换时间”的策略,因为付出的代价是占用了缓存空间。当然,这个预测的结果可能不正确
现代CPU其实一般都有硬件指令和数据预取功能
- 也就是根据程序的运行状态进程预测,并提取把指令和数据预取到缓存中
- 这种硬件预测针对连续性的内存访问非常有效。
- 但是很多情况下,程序对内存的访问模式是随机的、不规则的,也就是不连续的。
- 硬件预取器对于这种随机的访问模式,根本无法做出正确的预测,这就需要软件预取
软件预取就是一种预取到缓存中的技术,以便及时提供给CPU、减少CPU停顿,从而降低缓存的不命中率,也就提高了CPU的使用效率。
现代CPU都提供相应的预取指令
- 具体来讲,Windows 下可以使用 VC++ 提供的_mm_prefetch 函数
- Linux 下可以使用 GCC 提供的 __builtin_prefetch 函数。
- GCC提供了这样的接口,允许开发人员向编译器提供提示,从而帮助GCC为底层的编译处理器产生预取指令。这种策略在硬件预取不能正确、及时的预取数据时,极为有用。
但是软件预取也是有代价的:
- 预取的操作本身也是一种CPU指令,执行它就会占用CPU的周期。
- 预取的内存数据总是会占用缓存空间。
- 因为缓存空间很有限,这样可能会踢出其他的缓存的内容,从而造成被踢出内容的缓存不命中
- 如果预取的数据没有及时被用到,或者带来的好处不大,甚至小于带来的踢出其他缓存相对应的代价,那么软件预取就不会提升性能
实践建议:
- 软件预取最好只针对绝对必要的情况,就是会实际导致CPU停顿的数据进行预取
- 对于很长的循环(就是循环次数比较多),尽量提前预取后面的两到三个循环所需要的数据。
- 而对于短些的循环(循环次数比较少),可以试试在进入循环之前,就把数据提前预取到。
去除伪共享缓存
- 通过让每个数据结构变大,牺牲一点存储空间,来解决伪共享缓存的问题
那什么是伪共享缓存?
- 内存缓存系统中,一般是以缓存行(cache line)为单位存储的。最常见的缓存行大小是64个字节。
- 现代CPU为了保证缓存相对于内存的一致性,必须实时监测每个核对缓存相对应的内存位置的修改。如果不同核所对应的缓存,其实是对应内存的同一个位置,那么对于这些缓存位置的修改,就必须轮流有序的执行,以保证内存一致性。
- 但是,这将导致核与核之间产生竞争关系,因为一个核对内存的修改,将导致另外的核在该处内存上的缓存失效。在多线程的场景下就会导致这样的问题。当多线程修改看似互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享
你可以参考下面这张 Intel 公司提供的图,两个线程运行在不同的核上,每个核都有自己单独的缓存,并且两个线程访问同一个缓存行。
- 如果线程 0 修改了缓存行的一部分,比如一个字节,那么为了保证缓存一致性,这个核上的整个缓存行的 64 字节,都必须写回到内存;
- 这就导致其他核的对应缓存行失效。其他核的缓存就必须从内存读取最新的缓存行数据。这就造成了其他线程(比如线程 1)相对较大的停顿。
这个问题就是伪共享缓存。之所以称为“伪共享”,是因为,单单从程序代码上看,好像线程间没有冲突,可以完美共享内存,所以看不出什么问题。由于这种冲突性共享导致的问题不是程序本意,而是由于底层缓存按块存取和缓存一致性的机制导致的,所以才称为“伪共享”。
所以,我们开发程序时,不同线程的数据要尽量放到不同的缓存行,避免多线程同时频繁地修改同一个缓存行。
- 举个具体例子,假如我们要写一个多线程的程序来做分布式的统计工作,为了避免线程对于同一个变量的竞争,我们一般会定义一个数组,让每个线程修改其中一个元素。当需要总体统计信息时,再将所有元素相加得到结果。
- 但是,如果这个数组的元素是整数,因为一个整数只占用几个字节,那么一个 64 字节的缓存行会包含多个整数,就会导致几个线程共享一个缓存行,产生“伪共享”问题。
- 这个问题的解决方案,是让每个元素单独占用一个缓存行,比如 64 字节,也就是按缓存行的大小来对齐(Cache Line Alignment)。
- 具体方法怎么实现呢?其实就是插入一些无用的字节(Padding)。这样的好处,是多个线程可以修改各自的元素和对应的缓存行,不会存在缓存行竞争,也就避免了“伪共享”问题。