gitbook/性能工程高手课/docs/188047.md

131 lines
12 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 21 | CPU案例如何提高LLC最后一级缓存的命中率
你好,我是庄振运。
前面两讲中,我介绍了性能优化的六大原则和十大策略。从今天开始,我们来通过具体案例的解决方案讲解,了解这些原则和策略是如何应用的。
首先我们要来探讨的是一个CPU相关的性能优化案例。
这个性能案例是关于CPU的最后一级缓存的。你应该知道最后一级缓存一般也就是L3如果命中率不高的话对系统性能会有极坏的影响相关基础知识建议回顾[第15讲](https://time.geekbang.org/column/article/183357))。所以对这一问题,我们要及时准确地监测、暴露出来。
至于具体解决方案,我这里建议采取三种性能优化策略,来提高最后一级缓存的命中率。分别是:**紧凑化数据结构**、**软件预取数据**和**去除伪共享缓存**。它们分别适用于不同的情况。
## 性能问题最后一级缓存LLC不命中率太高
一切问题的解决都要从性能问题开始入手,我们首先来看看**最后一级缓存不命中率太高**这个性能问题本身。
缓存的命中率是CPU性能的一个关键性能指标。我们知道CPU里面有好几级缓存Cache每一级缓存都比后面一级缓存访问速度快。最后一级缓存叫LLCLast Level CacheLLC的后面就是内存。
当CPU需要访问一块数据或者指令时它会首先查看最靠近的一级缓存L1如果数据存在那么就是缓存命中Cache Hit否则就是不命中Cache Miss需要继续查询下一级缓存。
缓存不命中的比例对CPU的性能影响很大尤其是最后一级缓存的不命中时对性能的损害尤其严重。这个损害主要有两方面的性能影响
第一个方面的影响很直白,就是**CPU的速度**受影响。我们前面讲过内存的访问延迟是LLC的延迟的很多倍比如五倍所以LLC不命中对计算速度的影响可想而知。
第二个方面的影响就没有那么直白了,这方面是关于**内存带宽**。我们知道如果LLC没有命中那么就只能从内存里面去取了。LLC不命中的计数其实就是对内存访问的计数因为CPU对内存的访问总是要经过LLC不会跳过LLC的。所以每一次LLC不命中就会导致一次内存访问反之也是成立的每一次内存访问都是因为LLC没有命中。
更重要的是我们知道一个系统的内存带宽是有限制的很有可能会成为性能瓶颈。从内存里取数据就会占用内存带宽。因此如果LLC不命中很高那么对内存带宽的使用就会很大。内存带宽使用率很高的情况下内存的存取延迟会急剧上升。更严重的是最近几年计算机和互联网发展的趋势是后台系统需要对越来越多的数据进行处理因此**内存带宽越来越成为性能瓶颈**。
## LLC不命中率的测量
针对LLC不命中率高的问题我们需要衡量一下问题的严重程度。在Linux系统里可以用Perf这个工具来测量LLC的不命中率在[第15讲](https://time.geekbang.org/column/article/183357)中提到过)。
那么Perf工具是怎么工作的呢
它是在内部使用性能监视单元也就是PMUPerformance 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执行结果。
![](https://static001.geekbang.org/resource/image/15/1e/15211eb7e1e6da1d46b66e7cebdf1e1e.png)
我们可以看到在这段取样时间内有1951M19.51亿次LLC的读取大约16%是不命中。有313M3.13亿次LLC的写入差不多24%是不命中。
## 如何降低LLC的不命中率
那么如何降低LLC的不命中率也就是提高它的命中率呢根据具体的问题至少有三个解决方案。而且这三个方案也不是互相排斥的完全可以同时使用。
第一个方案,也是最直白的方案,就是**缩小数据结构**,让数据变得紧凑。
这样做的道理很简单对一个系统而言所有的缓存大小包括最后一级缓存LLC都是固定的。如果每个数据变小各级缓存自然就可以缓存更多条数据也就可以提高缓存的命中率。这个方案很容易理解。
举个例子开源的C++ [Folly库](https://github.com/facebook/folly/tree/master/folly)里面有很多类比如F14ValueMap就比一般的标准库实现小很多从而占用比较少的内存采用它的话自然缓存的命中率就比较高。
第二个方案,是**用软件方式来预取数据**。
这个方案也就是通过合理预测,把以后可能要读取的数据提前取出,放到缓存里面,这样就可以减少缓存不命中率。“用软件方式来预取数据”理论上也算是一种“**用空间来换时间**”的策略(参见[第20讲](https://time.geekbang.org/column/article/187540)),因为付出的代价是占用了缓存空间。当然,这个预测的结果可能会不正确。
第三个方案,是具体为了解决一种特殊问题:就是伪共享缓存。伪共享缓存这个问题,我会在后面详细讲到。这个方案也算是一种“**空间换时间**”的策略,是通过让每个数据结构变大,牺牲一点存储空间,来解决伪共享缓存的问题。
除了最直白的缩小数据结构,另外两个解决方案(用软件方式来预取数据、去除伪共享缓存)都需要着重探讨。
### 软件提前预取指令
我们先展开讨论一下第二种方案,也就是用软件提前预取指令。
现代CPU其实一般都有**硬件指令**和**数据预取**功能,也就是根据程序的运行状态进行预测,并提前把指令和数据预取到缓存中。这种硬件预测针对连续性的内存访问特别有效。
但是在相当多的情况下,程序对内存的访问模式是随机、不规则的,也就是不连续的。硬件预取器对于这种随机的访问模式,根本无法做出正确的预测,这就需要使用**软件预取**。
软件预取就是这样一种预取到缓存中的技术以便及时提供给CPU减少CPU停顿从而降低缓存的不命中率也就提高了CPU的使用效率。
现代CPU都提供相应的预取指令具体来讲Windows下可以使用VC++提供的\_mm\_prefetch函数Linux下可以使用GCC提供的\_\_builtin\_prefetch函数。GCC提供了这样的接口允许开发人员向编译器提供提示从而帮助GCC为底层的编译处理器产生预取指令。这种策略在硬件预取不能正确、及时地预取数据时极为有用。
但是软件预取也是有代价的。
一是预取的操作本身也是一种CPU指令执行它就会占用CPU的周期。更重要的是预取的内存数据总是会占用缓存空间。因为缓存空间很有限这样可能会踢出其他的缓存的内容从而造成被踢出内容的缓存不命中。如果预取的数据没有及时被用到或者带来的好处不大甚至小于带来的踢出其他缓存相对应的代价那么软件预取就不会提升性能。
我自己在这方面的实践经验,有这么几条:
1. 软件预取最好只针对绝对必要的情况就是对会实际严重导致CPU停顿的数据进行预取。
2. 对于很长的循环(就是循环次数比较多),尽量提前预取后面的两到三个循环所需要的数据。
3. 而对于短些的循环(循环次数比较少),可以试试在进入循环之前,就把数据提前预取到。
### 去除伪共享缓存
好了,我们接着来讨论第三个方案:去除伪共享缓存。
什么是伪共享缓存呢?
我们都知道内存缓存系统中一般是以缓存行Cache Line为单位存储的。最常见的缓存行大小是64个字节。现代CPU为了保证缓存相对于内存的一致性必须实时监测每个核对缓存相对应的内存位置的修改。如果不同核所对应的缓存其实是对应内存的同一个位置那么对于这些缓存位置的修改就必须轮流有序地执行以保证内存一致性。
但是,这将导致核与核之间产生竞争关系,因为一个核对内存的修改,将导致另外的核在该处内存上的缓存失效。在多线程的场景下就会导致这样的问题。当多线程修改看似互相独立的变量时,如果这些变量共享同一个缓存行,就会在无意中影响彼此的性能,这就是**伪共享**。
你可以参考下面这张Intel公司提供的图两个线程运行在不同的核上每个核都有自己单独的缓存并且两个线程访问同一个缓存行。
![](https://static001.geekbang.org/resource/image/42/c9/42be053ba1c46fece881b97b1f328ac9.png)
如果线程0修改了缓存行的一部分比如一个字节那么为了保证缓存一致性这个核上的整个缓存行的64字节都必须写回到内存这就导致其他核的对应缓存行失效。其他核的缓存就必须从内存读取最新的缓存行数据。这就造成了其他线程比如线程1相对较大的停顿。
这个问题就是**伪共享缓存**。之所以称为“伪共享”,是因为,单单从程序代码上看,好像线程间没有冲突,可以完美共享内存,所以看不出什么问题。由于这种冲突性共享导致的问题不是程序本意,而是由于底层缓存按块存取和缓存一致性的机制导致的,所以才称为“伪共享”。
我工作中也观察到好多次这样的伪共享缓存问题。经常会有产品组来找我们,说他们的产品吞吐量上不去,后来发现就是这方面的问题。所以,我们开发程序时,不同线程的数据要尽量放到不同的缓存行,避免多线程同时频繁地修改同一个缓存行。
举个具体例子,假如我们要写一个多线程的程序来做分布式的统计工作,为了避免线程对于同一个变量的竞争,我们一般会定义一个数组,让每个线程修改其中一个元素。当需要总体统计信息时,再将所有元素相加得到结果。
但是如果这个数组的元素是整数因为一个整数只占用几个字节那么一个64字节的缓存行会包含多个整数就会导致几个线程共享一个缓存行产生“伪共享”问题。
这个问题的解决方案,是**让每个元素单独占用一个缓存行**比如64字节也就是按缓存行的大小来对齐Cache Line Alignment。具体方法怎么实现呢其实就是插入一些无用的字节Padding。这样的好处是多个线程可以修改各自的元素和对应的缓存行不会存在缓存行竞争也就避免了“伪共享”问题。
## 总结
这一讲我们介绍了CPU方面的优化案例重点讨论了**如何降低LLC的缓存不命中率**。我们提出了三个方案,分别是紧凑化数据、软件指令预取和去除伪共享缓存。
![](https://static001.geekbang.org/resource/image/fc/ba/fc86555b197673e40096980571af44ba.png)
尤其是第三个方案解决的伪共享缓存问题,对大多数程序员和运维人员而言,不太容易理解。为什么难理解?是因为它牵扯了软件(比如多线程)和硬件(比如缓存一致性和缓存行的大小)的交互。
当多线程共用同一个缓存行,并且各自频繁访问时,会导致严重的称为“伪共享”的性能问题。这种问题,恰如清代词人朱彝尊的两句词,“共眠一舸听秋雨,小簟轻衾各自寒”。所以需要我们狠狠心,把它们强行分开;“棒打鸳鸯”,让它们“大难临头各自飞”,其实呢,是为了它们都好。
## 思考题
硬件指令预取的基本原理是什么?为什么有时候非常有效,但有时候会有害呢?分别试举出一个具体的模块开发中的例子,来说明为什么有效和有害。
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。