赞
踩
目录
按照摩尔定律,CPU 的访问速度每 18 个月便会翻一番,相当于每年增长 60%。内存的访问速度虽然也在不断增长,却远没有这么快,每年只增长 7% 左右。而这两个增长速度的差异,使得 CPU 性能和内存访问性能的差距不断拉大。到今天来看,一次内存的访问,大约需要 120 个 CPU Cycle,这也意味着,在今天,CPU 和内存的访问速度已经有了 120 倍的差距。
如果拿我们现实生活来打个比方的话,CPU 的速度好比风驰电掣的高铁,每小时 350 公里,然而,它却只能等着旁边腿脚不太灵便的老太太,也就是内存,以每小时 3 公里的速度缓慢步行。因为 CPU 需要执行的指令、需要访问的数据,都在这个速度不到自己 1% 的内存里。
为了弥补两者之间的性能差异,我们能真实地把 CPU 的性能提升用起来,而不是让它在那儿空转,我们在现代 CPU 中引入了高速缓存。
从 CPU Cache 被加入到现有的 CPU 里开始,内存中的指令、数据,会被加载到 L1-L3 Cache 中,而不是直接由 CPU 访问内存去拿。在 95% 的情况下,CPU 都只需要访问 L1-L3 Cache,从里面读取指令和数据,而无需访问内存。要注意的是,这里我们说的 CPU Cache 或者 L1/L3 Cache,不是一个单纯的、概念上的缓存(比如之前我们说的拿内存作为硬盘的缓存),而是指特定的由 SRAM 组成的物理芯片。
运行程序的时间主要花在了将对应的数据从内存中读取出来,加载到 CPU Cache 里。CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。
知道了为什么需要 CPU Cache,接下来,我们就来看一看,CPU 究竟是如何访问 CPU Cache 的,以及 CPU Cache 是如何组织数据,使得 CPU 可以找到自己想要访问的数据的。因为 Cache 作为“缓存”的意思,在很多别的存储设备里面都会用到。为了避免你混淆,在表示抽象的“缓存“概念时,用中文的“缓存”;如果是 CPU Cache,我会用“高速缓存“或者英文的“Cache”,来表示。
现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的数据写入 Cache 之中。当时间局部性原理起作用后,这个最近刚刚被访问的数据,会很快再次被访问。而 Cache 的访问速度远远快于内存,这样,CPU 花在等待内存访问上的时间就大大变短了。
这样的访问机制,和我们自己在开发应用系统的时候,“使用内存作为硬盘的缓存”的逻辑是一样的。在各类基准测试(Benchmark)和实际应用场景中,CPU Cache 的命中率通常能达到 95% 以上。
问题来了,CPU 如何知道要访问的内存数据,存储在 Cache 的哪个位置呢?接下来,我就从最基本的直接映射 Cache(Direct Mapped Cache)说起,带你来看整个 Cache 的数据结构和访问逻辑。
CPU 访问内存数据,是一小块一小块数据来读取的。对于读取内存中的数据,我们首先拿到的是数据所在的内存块(Block)的地址。而直接映射 Cache 采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的 CPU Cache 地址(Cache Line)。而这个映射关系,通常用 mod 运算(求余运算)来实现。下面我举个例子帮你理解一下。
比如说,我们的主内存被分成 0~31 号这样 32 个块。我们一共有 8 个缓存块。用户想要访问第 21 号内存块。如果 21 号内存块内容在缓存块中的话,它一定在 5 号缓存块(21 mod 8 = 5)中。
Cache 采用 mod 的方式,把内存块映射到对应的 CPU Cache 中
实际计算中,有一个小小的技巧,通常我们会把缓存块的数量设置成 2 的 N 次方。这样在计算取模的时候,可以直接取地址的低 N 位,也就是二进制里面的后几位。比如这里的 8 个缓存块,就是 2 的 3 次方。那么,在对 21 取模的时候,可以对 21 的 2 进制表示 10101 取地址的低三位,也就是 101,对应的 5,就是对应的缓存块地址。
取 Block 地址的低位,就能得到对应的 Cache Line 地址,除了 21 号内存块外,13 号、5 号等很多内存块的数据,都对应着 5 号缓存块中。既然如此,假如现在 CPU 想要读取 21 号内存块,在读取到 5 号缓存块的时候,我们怎么知道里面的数据,究竟是不是 21 号对应的数据呢?同样,建议你借助现有知识,先自己思考一下,然后再看我下面的分析,这样会印象比较深刻。
这个时候,在对应的缓存块中,我们会存储一个组标记(Tag)。这个组标记会记录,当前缓存块内存储的数据对应的内存块,而缓存块本身的地址表示访问地址的低 N 位。就像上面的例子,21 的低 3 位 101,缓存块本身的地址已经涵盖了对应的信息、对应的组标记,我们只需要记录 21 剩余的高 2 位的信息,也就是 10 就可以了。
除了组标记信息之外,缓存块中还有两个数据。一个自然是从主内存中加载来的实际存放的数据,另一个是有效位(valid bit)。啥是有效位呢?它其实就是用来标记,对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是 0,无论其中的组标记和 Cache Line 里的数据内容是什么,CPU 都不会管这些数据,而要直接访问内存,重新加载数据。
CPU 在读取数据的时候,并不是要读取一整个 Block,而是读取一个他需要的整数。这样的数据,我们叫作 CPU 里的一个字(Word)。具体是哪个字,就用这个字在整个 Block 里面的位置来决定。这个位置,我们叫作偏移量(Offset)。
总结一下,一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的 Data Block 中定位对应字的位置偏移量。
而内存地址对应到 Cache 里的数据结构,则多了一个有效位和对应的数据,由“索引 + 有效位 + 组标记 + 数据”组成。如果内存中的数据已经在 CPU Cache 里了,那一个内存地址的访问,就会经历这样 4 个步骤:
如果在 2、3 这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。
好了,讲到这里,相信你明白现代 CPU,是如何通过直接映射 Cache,来定位一个内存访问地址在 Cache 中的位置了。其实,除了直接映射 Cache 之外,我们常见的缓存放置策略还有全相连 Cache(Fully Associative Cache)、组相连 Cache(Set Associative Cache)。这几种策略的数据结构都是相似的,理解了最简单的直接映射 Cache,其他的策略你很容易就能理解了。
我们现在用的 Intel CPU,通常都是多核的的。每一个 CPU 核里面,都有独立属于自己的 L1、L2 的 Cache,然后再有多个 CPU 核共用的 L3 的 Cache、主内存。
因为 CPU Cache 的访问速度要比主内存快很多,而在 CPU Cache 里面,L1/L2 的 Cache 也要比 L3 的 Cache 快。所以,上一讲我们可以看到,CPU 始终都是尽可能地从 CPU Cache 中去获取数据,而不是每一次都要从主内存里面去读取数据。
这个层级结构,就好像我们在 Java 内存模型里面,每一个线程都有属于自己的线程栈。线程在读取 COUNTER 的数据的时候,其实是从本地的线程栈的 Cache 副本里面读取数据,而不是从主内存里面读取数据。如果我们对于数据仅仅只是读,问题还不大。我们在上一讲里,已经看到 Cache Line 的组成,以及如何从内存里面把对应的数据加载到 Cache 里。
但是,对于数据,我们不光要读,还要去写入修改。这个时候,有两个问题来了。
第一个问题是,写入 Cache 的性能也比写入主内存要快,那我们写入的数据,到底应该写到 Cache 里还是主内存呢?如果我们直接写入到主内存里,Cache 里的数据是否会失效呢?为了解决这些疑问,下面我要给你介绍两种写入策略。
//未完待续......
CPU 的性能也取决于它的内部结构设计。很多程序员对 CPU 的内部机构不是完全清楚,尤其是对相关的术语之间的区别和联系一知半解,比如多处理器和多核、逻辑 CPU 和硬件线程、超线程,以及 L1/L2/L3 三级缓存等。
之所以对这些结构不甚了解,主要原因是现代处理器变得复杂,普遍采用多处理器,多核以及内部的各种优化处理来提高 CPU 性能。我们今天就从外到内,从宏观到微观地介绍一下。
现在的 CPU 普遍采用多处理器(Socket)来提高 CPU 性能,每个处理器都有自己可以直接访问的本地内存(Local Memory)。一般来讲,这里面每个处理器的性能和内存大小都是一样的。每个处理器也都可以访问其他处理器的内存,这些内存就相当于是外地 / 远程内存(Remote Memory)。
当 CPU 处理器访问本地内存时,会有较短的响应时间(称为本地访问 Local Access)。而如果需要访问外地 / 远程内存时候,就需要通过互联通道访问,响应时间就相比本地内存变慢了(称为远端访问 Remote Access)。所以 NUMA(Non-Uniform MemoryAccess)就此得名。
下图展示了两个处理器的 NUMA 架构。
如果处理器 A 访问内存 A,就是本地访问。如果它访问内存 B,就是远端访问,内存的访问延迟大大增加。
采用多处理器和 NUMA 架构的主要原因,是提高整个 CPU 的并行处理性能。每一台服务器可以同时运行很多程序和进程。对每一个进程和线程而言,当它运行在某一个处理器上时,它所对应的内存使用默认的分配方案是——优先尝试在请求线程当前所处的处理器的本地内存上分配。如果本地内存不足,才会分配到外地 / 远程内存上去。
我们再看看每个处理器内部的结构。我们刚刚讲到的处理器,内部一般都是多核(Core)架构。随着多核处理器的发展,CPU 的缓存通常分成了三个级别:L1、L2 和 L3。
级别越小就越接近 CPU,速度更快,同时容量也越小。L1 和 L2 一般在核的内部,我们下一讲还会详细讲。L3 缓存是三级缓存中最大的一级,同时也是最慢的一级;在同一个处理器内部的核会共享同一个 L3 缓存。
除了多个核以及 L3 缓存外,处理器上一般还有非核心处理器(Uncore),里面含有和指令运行不直接相关的组件,包括 QPI 控制器和存储器一致性监测组件,如下图所示。
一个核还可以进一步分成几个逻辑核,来执行多个控制流程,这样可以进一步提高并行程度,这一技术就叫超线程,有时叫做 simultaneous multi-threading(SMT)。
超线程技术主要的出发点是,当处理器在运行一个线程,执行指令代码时,很多时候处理器并不会使用到全部的计算能力,部分计算能力就会处于空闲状态。而超线程技术就是通过多线程来进一步“压榨”处理器。
举个例子,如果一个线程运行过程中,必须要等到一些数据加载到缓存中以后才能继续执行,此时 CPU 就可以切换到另一个线程,去执行其他指令,而不用去处于空闲状态,等待当前线程的数据加载完毕。
通常,一个传统的处理器在线程之间切换,可能需要几万个时钟周期。而一个具有 HT 超线程技术的处理器只需要 1 个时钟周期。因此就大大减小了线程之间切换的成本,从而最大限度地让处理器满负荷运转。
一个核分成几个超线程呢?
这个数字会根据 CPU 架构有所变化;Intel 一般是把一个核分成 2 个。
“这台计算机有多少 CPU?”
我们经常会问这个问题,结合我们刚刚讲的知识,就很容易回答了。
比如,如果一台计算机有两个处理器,每个处理器有 12 个核,而且采用了 HT 超线程,那么总的 CPU 数目就是 48,就是 2×12×2。这个数字 48,就是我们平时用监控软件和命令看到的 CPU 的数量。比如,Linux 的 top 或者 vmstat 命令,显示的 CPU 个数就是这样算出来的。
我们继续探讨 CPU 的性能指标和常见性能问题,这方面很多资料都有涉及,我们提纲挈领地总结一下。
最表层的 CPU 性能指标,就是 CPU 的负载情况和使用率。CPU 使用率又进一步分成系统CPU、用户 CPU、IO 等待 CPU 等几个指标。你执行一下 top 命令就会看到。
需要注意的是,因为 CPU 架构的复杂性,以及和其他部件的交互,CPU 的使用率和负载的关系往往不是线性的。
也就是说,如果 10% 的 CPU 使用率可以每秒处理 1 千个请求,那么 80% 的 CPU 使用率能够处理多少请求呢?不太可能处理每秒 8 千个请求,往往会远远小于这个数字。
衡量一个应用程序对 CPU 使用效率时,往往会考察 CPI(Cycles Per Instruction,每指令的周期数)和 IPC(Instructions Per Cycle,每周期的指令数)。这两个指标有助于识别运行效率高或低的应用程序。而一台计算机的 CPU 性能一般用 MIPS(Millions of Instructions Per Second)来衡量,表示每秒能运行多少个百万指令,MIPS 越高,性能越高。MIPS 的计算很简单,就是时钟频率×IPC。
继续往深处分析,CPU 常见的各种中断包括软中断和硬中断。除此之外,还有一种特殊的中断:上下文切换。这些指标需要和每个核挂钩,理想情况下是各个核上的中断能够均衡。如果数量不均衡,往往会造成严重的性能问题——有的核会超载而导致系统响应缓慢,但是其他的核反而空闲。
和 CPU 相关的性能问题,基本上就是表现为 CPU 超载或者空闲。
如果是 CPU 超载,那么就要分析为什么超载。多数情况下都不一定是合理的超载,比如说多核之间的负载没有平衡好,或者 CPU 干了很多没用的活,或者应用程序本身的设计需要优化等等。反之,如果是 CPU 空闲,那就需要了解为什么空闲,或许是指令里面太多内存数据操作,从而造成 CPU 停顿,也或许是太多的分支预测错误等,这就需要具体分析和对症下药的优化。
CPU 对多线程的执行顺序是谁定的呢?
是由内核的进程调度来决定的。内核进程调度负责管理和分配 CPU 资源,合理决定哪个进程该使用 CPU,哪个进程该等待。进程调度给不同的线程和任务分配了不同的优先级,优先级最高的是硬件中断,其次是内核(系统)进程,最后是用户进程。每个逻辑 CPU 都维护着一个可运行队列,用来存放可运行的线程来调度。
我们最后讲一下 CPU 性能监测方面的工具。和 CPU 监测相关的工具挺多的,而且往往每个工具都包含很多不同的有用的信息。
比如在 Linux 上,最常用的 Top 系统进程监控命令。Top 是一个万金油的工具,可以显示出 CPU 的使用、内存的使用、交换内存和缓存大小、缓冲区大小、各个进程信息等。
如果想要查看过去的 CPU 负载情况,可以用 uptime。也可以用 mpstat 和 pidstat,来分别查看每个核还有每个进程的情况。另一个常用的 vmstat 命令可以用于显示虚拟内存、内核线程、磁盘、系统进程、I/O 模块、中断等信息。
对有经验的性能工程师来讲,有一个类似于“瑞士军刀”一样的好工具:Perf。
Perf 是 Linux 上的性能剖析(profiling)工具,极为有用。它是基于事件采样原理,以性能事件为基础,利用内核中的计数器来进行性能统计。它不但可以分析指定应用程序的性能问题,也可以用来分析内核的性能问题。
一切问题的解决都要从性能问题开始入手,我们首先来看看最后一级缓存不命中率太高这个性能问题本身。
缓存的命中率,是 CPU 性能的一个关键性能指标。我们知道,CPU 里面有好几级缓(Cache),每一级缓存都比后面一级缓存访问速度快。最后一级缓存叫 LLC(Last Level Cache);LLC 的后面就是内存。
当 CPU 需要访问一块数据或者指令时,它会首先查看最靠近的一级缓存(L1);如果数据存在,那么就是缓存命中(Cache Hit),否则就是不命中(Cache Miss),需要继续查询下一级缓存。
缓存不命中的比例对 CPU 的性能影响很大,尤其是最后一级缓存的不命中时,对性能的损害尤其严重。这个损害主要有两方面的性能影响:
第二个方面的影响就没有那么直白了,这方面是关于内存带宽。我们知道,如果 LLC 没有命中,那么就只能从内存里面去取了。LLC 不命中的计数,其实就是对内存访问的计数,因为 CPU 对内存的访问总是要经过 LLC,不会跳过 LLC 的。所以每一次 LLC 不命中,就会导致一次内存访问;反之也是成立的:每一次内存访问都是因为 LLC 没有命中。
更重要的是,我们知道,一个系统的内存带宽是有限制的,很有可能会成为性能瓶颈。从内存里取数据,就会占用内存带宽。因此,如果 LLC 不命中很高,那么对内存带宽的使用就会很大。内存带宽使用率很高的情况下,内存的存取延迟会急剧上升。更严重的是,最近几年计算机和互联网发展的趋势是,后台系统需要对越来越多的数据进行处理,因此内存带宽越来越成为性能瓶颈。
针对 LLC 不命中率高的问题,我们需要衡量一下问题的严重程度。在 Linux 系统里,可以用 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 的周期。更重要的是,预取的内存数据总是会占用缓存空间。因为缓存空间很有限,这样可能会踢出其他的缓存的内容,从而造成被踢出内容的缓存不命中。如果预取的数据没有及时被用到,或者带来的好处不大,甚至小于带来的踢出其他缓存相对应的代价,那么软件预取就不会提升性能。
我自己在这方面的实践经验,有这么几条:
1. 软件预取最好只针对绝对必要的情况,就是对会实际严重导致 CPU 停顿的数据进行预取。
2. 对于很长的循环(就是循环次数比较多),尽量提前预取后面的两到三个循环所需要的数据。
3. 而对于短些的循环(循环次数比较少),可以试试在进入循环之前,就把数据提前预取到。
好了,我们接着来讨论第三个方案:去除伪共享缓存。
什么是伪共享缓存呢?
我们都知道,内存缓存系统中,一般是以缓存行(Cache Line)为单位存储的。最常见的缓存行大小是 64 个字节。现代 CPU 为了保证缓存相对于内存的一致性,必须实时监测每个核对缓存相对应的内存位置的修改。如果不同核所对应的缓存,其实是对应内存的同一个位置,那么对于这些缓存位置的修改,就必须轮流有序地执行,以保证内存一致性。
但是,这将导致核与核之间产生竞争关系,因为一个核对内存的修改,将导致另外的核在该处内存上的缓存失效。在多线程的场景下就会导致这样的问题。当多线程修改看似互相独立的变量时,如果这些变量共享同一个缓存行,就会在无意中影响彼此的性能,这就是伪共享。
你可以参考下面这张 Intel 公司提供的图,两个线程运行在不同的核上,每个核都有自己单独的缓存,并且两个线程访问同一个缓存行。
如果线程 0 修改了缓存行的一部分,比如一个字节,那么为了保证缓存一致性,这个核上的整个缓存行的 64 字节,都必须写回到内存;这就导致其他核的对应缓存行失效。其他核的缓存就必须从内存读取最新的缓存行数据。这就造成了其他线程(比如线程 1)相对较大的停顿。
这个问题就是伪共享缓存。之所以称为“伪共享”,是因为,单单从程序代码上看,好像线程间没有冲突,可以完美共享内存,所以看不出什么问题。由于这种冲突性共享导致的问题不是程序本意,而是由于底层缓存按块存取和缓存一致性的机制导致的,所以才称为“伪共享”。
我工作中也观察到好多次这样的伪共享缓存问题。经常会有产品组来找我们,说他们的产品吞吐量上不去,后来发现就是这方面的问题。所以,我们开发程序时,不同线程的数据要尽量放到不同的缓存行,避免多线程同时频繁地修改同一个缓存行。
举个具体例子,假如我们要写一个多线程的程序来做分布式的统计工作,为了避免线程对于同一个变量的竞争,我们一般会定义一个数组,让每个线程修改其中一个元素。当需要总体统计信息时,再将所有元素相加得到结果。
但是,如果这个数组的元素是整数,因为一个整数只占用几个字节,那么一个 64 字节的缓存行会包含多个整数,就会导致几个线程共享一个缓存行,产生“伪共享”问题。
这个问题的解决方案,是让每个元素单独占用一个缓存行,比如 64 字节,也就是按缓存行的大小来对齐(Cache Line Alignment)。具体方法怎么实现呢?其实就是插入一些无用的字节(Padding)。这样的好处,是多个线程可以修改各自的元素和对应的缓存行,不会存在缓存行竞争,也就避免了“伪共享”问题。
小结:我们介绍了 CPU 方面的优化案例,重点讨论了如何降低 LLC 的缓存不命中率。我们提出了三个方案,分别是紧凑化数据、软件指令预取和去除伪共享缓存。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。