gitbook/深入浅出计算机组成原理/docs/107477.md
2022-09-03 22:05:03 +08:00

128 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 37 | 高速缓存“4毫秒”究竟值多少钱
在这一节内容开始之前我们先来看一个3行的小程序。你可以猜一猜这个程序里的循环1和循环2运行所花费的时间会差多少你可以先思考几分钟然后再看我下面的解释。
```
int[] arr = new int[64 * 1024 * 1024];
// 循环1
for (int i = 0; i < arr.length; i++) arr[i] *= 3;
// 循环2
for (int i = 0; i < arr.length; i += 16) arr[i] *= 3
```
在这段Java程序中我们首先构造了一个64×1024×1024大小的整型数组。在循环1里我们遍历整个数组将数组中每一项的值变成了原来的3倍在循环2里我们每隔16个索引访问一个数组元素将这一项的值变成了原来的3倍。
按道理来说循环2只访问循环1中1/16的数组元素只进行了循环1中1/16的乘法计算那循环2花费的时间应该是循环1的1/16左右。但是实际上循环1在我的电脑上运行需要50毫秒循环2只需要46毫秒。这两个循环花费时间之差在15%之内。
为什么会有这15%的差异呢这和我们今天要讲的CPU Cache有关。之前我们看到了内存和硬盘之间存在的巨大性能差异。在CPU眼里内存也慢得不行。于是聪明的工程师们就在CPU里面嵌入了CPU Cache高速缓存来解决这一问题。
## 我们为什么需要高速缓存?
按照[摩尔定律](https://zh.wikipedia.org/wiki/%E6%91%A9%E5%B0%94%E5%AE%9A%E5%BE%8B)CPU的访问速度每18个月便会翻一番相当于每年增长60%。内存的访问速度虽然也在不断增长却远没有这么快每年只增长7%左右。而这两个增长速度的差异使得CPU性能和内存访问性能的差距不断拉大。到今天来看一次内存的访问大约需要120个CPU Cycle这也意味着在今天CPU和内存的访问速度已经有了120倍的差距。
如果拿我们现实生活来打个比方的话CPU的速度好比风驰电掣的高铁每小时350公里然而它却只能等着旁边腿脚不太灵便的老太太也就是内存以每小时3公里的速度缓慢步行。因为CPU需要执行的指令、需要访问的数据都在这个速度不到自己1%的内存里。
![](https://static001.geekbang.org/resource/image/4f/4b/4fc459f42a67d3949402865a998bf34b.png)
随着时间变迁CPU和内存之间的性能差距越来越大
为了弥补两者之间的性能差异我们能真实地把CPU的性能提升用起来而不是让它在那儿空转我们在现代CPU中引入了高速缓存。
从CPU Cache被加入到现有的CPU里开始内存中的指令、数据会被加载到L1-L3 Cache中而不是直接由CPU访问内存去拿。在95%的情况下CPU都只需要访问L1-L3 Cache从里面读取指令和数据而无需访问内存。要注意的是这里我们说的CPU Cache或者L1/L3 Cache不是一个单纯的、概念上的缓存比如之前我们说的拿内存作为硬盘的缓存而是指特定的由SRAM组成的物理芯片。
这里是一张Intel CPU的放大照片。这里面大片的长方形芯片就是这个CPU使用的20MB的L3 Cache。
![](https://static001.geekbang.org/resource/image/c1/15/c1dc0e3453f469fc4607557dab9d5215.jpg)
现代CPU中大量的空间已经被SRAM占据图中用红色框出的部分就是CPU的L3 Cache芯片
在这一讲一开始的程序里运行程序的时间主要花在了将对应的数据从内存中读取出来加载到CPU Cache里。CPU从内存中读取数据到CPU Cache的过程中是一小块一小块来读取数据的而不是按照单个数组元素来读取数据的。这样一小块一小块的数据在CPU Cache里面我们把它叫作Cache Line缓存块
在我们日常使用的Intel服务器或者PC里Cache Line的大小通常是64字节。而在上面的循环2里面我们每隔16个整型数计算一次16个整型数正好是64个字节。于是循环1和循环2需要把同样数量的Cache Line数据从内存中读取到CPU Cache中最终两个程序花费的时间就差别不大了。
知道了为什么需要CPU Cache接下来我们就来看一看CPU究竟是如何访问CPU Cache的以及CPU Cache是如何组织数据使得CPU可以找到自己想要访问的数据的。因为Cache作为“缓存”的意思在很多别的存储设备里面都会用到。为了避免你混淆在表示抽象的“缓存“概念时用中文的“缓存”如果是CPU Cache我会用“高速缓存“或者英文的“Cache”来表示。
## Cache的数据结构和读取过程是什么样的
现代CPU进行数据读取的时候无论数据是否已经存储在Cache中CPU始终会首先访问Cache。只有当CPU在Cache中找不到数据的时候才会去访问内存并将读取到的数据写入Cache之中。当时间局部性原理起作用后这个最近刚刚被访问的数据会很快再次被访问。而Cache的访问速度远远快于内存这样CPU花在等待内存访问上的时间就大大变短了。
![](https://static001.geekbang.org/resource/image/3a/cc/3a6fcfd1155e03f4f2781dbb6ddaf6cc.png)
这样的访问机制和我们自己在开发应用系统的时候“使用内存作为硬盘的缓存”的逻辑是一样的。在各类基准测试Benchmark和实际应用场景中CPU Cache的命中率通常能达到95%以上。
问题来了CPU如何知道要访问的内存数据存储在Cache的哪个位置呢接下来我就从最基本的**直接映射Cache**Direct Mapped Cache说起带你来看整个Cache的数据结构和访问逻辑。
在开头的3行小程序里我说过CPU访问内存数据是一小块一小块数据来读取的。对于读取内存中的数据我们首先拿到的是数据所在的**内存块**Block的地址。而直接映射Cache采用的策略就是确保任何一个内存块的地址始终映射到一个固定的CPU Cache地址Cache Line。而这个映射关系通常用mod运算求余运算来实现。下面我举个例子帮你理解一下。
比如说我们的主内存被分成031号这样32个块。我们一共有8个缓存块。用户想要访问第21号内存块。如果21号内存块内容在缓存块中的话它一定在5号缓存块21 mod 8 = 5中。
![](https://static001.geekbang.org/resource/image/52/22/522eade51bbfad19fd25eb4f3ce80f22.png)
Cache采用mod的方式把内存块映射到对应的CPU Cache中
实际计算中有一个小小的技巧通常我们会把缓存块的数量设置成2的N次方。这样在计算取模的时候可以直接取地址的低N位也就是二进制里面的后几位。比如这里的8个缓存块就是2的3次方。那么在对21取模的时候可以对21的2进制表示10101取地址的低三位也就是101对应的5就是对应的缓存块地址。
![](https://static001.geekbang.org/resource/image/ca/80/caadd2728b5cfcd2bd704103570f3a80.png)
取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中定位对应字的位置偏移量。**
![](https://static001.geekbang.org/resource/image/13/d4/1313fe1e4eb3b5c949284c8b215af8d4.png)
内存地址到Cache Line的关系
而内存地址对应到Cache里的数据结构则多了一个有效位和对应的数据由“**索引 + 有效位** **\+ 组标记 + 数据**”组成。如果内存中的数据已经在CPU Cache里了那一个内存地址的访问就会经历这样4个步骤
1. 根据内存地址的低位计算在Cache中的索引
2. 判断有效位确认Cache中的数据是有效的
3. 对比内存访问地址的高位和Cache中的组标记确认Cache中的数据就是我们要访问的内存数据从Cache Line中读取到对应的数据块Data Block
4. 根据内存地址的Offset位从Data Block中读取希望读取到的字。
如果在2、3这两个步骤中CPU发现Cache中的数据并不是要访问的内存地址的数据那CPU就会访问内存并把对应的Block Data更新到Cache Line中同时更新对应的有效位和组标记的数据。
好了讲到这里相信你明白现代CPU是如何通过直接映射Cache来定位一个内存访问地址在Cache中的位置了。其实除了直接映射Cache之外我们常见的缓存放置策略还有全相连CacheFully Associative Cache、组相连CacheSet Associative Cache。这几种策略的数据结构都是相似的理解了最简单的直接映射Cache其他的策略你很容易就能理解了。
## 减少4毫秒公司挣了多少钱?
刚才我花了很多篇幅讲了CPU和内存之间的性能差异以及我们如何通过CPU Cache来尽可能解决这两者之间的性能鸿沟。你可能要问了这样做的意义和价值究竟是什么毕竟一次内存的访问只不过需要100纳秒而已。1秒钟时间内足有1000万个100纳秒。别着急我们先来看一个故事。
2008年一家叫作Spread Networks的通信公司花费3亿美元做了一个光缆建设项目。目标是建设一条从芝加哥到新泽西总长1331公里的光缆线路。建设这条线路的目的其实是为了将两地之间原有的网络访问延时从17毫秒降低到13毫秒。
你可能会说仅仅缩短了4毫秒时间啊却花费3个亿真的值吗为这4毫秒时间买单的其实是一批高频交易公司。它们以5年1400万美元的价格使用这条线路。利用这短短的4毫秒的时间优势这些公司通过高性能的计算机程序在芝加哥和新泽西两地的交易所进行高频套利以获得每年以10亿美元计的利润。现在你还觉得这个不值得吗
其实只要350微秒的差异就足够高频交易公司用来进行无风险套利了。而350微秒如果用来进行100纳秒一次的内存访问大约只够进行3500次。而引入CPU Cache之后我们可以进行的数据访问次数提升了数十倍使得各种交易策略成为可能。
## 总结延伸
很多时候程序的性能瓶颈来自使用DRAM芯片的内存访问速度。
根据摩尔定律自上世纪80年代以来CPU和内存的性能鸿沟越拉越大。于是现代CPU的设计者们直接在CPU中嵌入了使用更高性能的SRAM芯片的Cache来弥补这一性能差异。通过巧妙地将内存地址拆分成“索引+组标记+偏移量”的方式使得我们可以将很大的内存地址映射到很小的CPU Cache地址里。而CPU Cache带来的毫秒乃至微秒级别的性能差异又能带来巨大的商业利益十多年前的高频交易行业就是最好的例子。
在搞清楚从内存加载数据到Cache以及从Cache里读取到想要的数据之后我们又要面临一个新的挑战了。CPU不仅要读数据还需要写数据我们不能只把数据写入到Cache里面就结束了。下一讲我们就来仔细讲讲CPU要写入数据的时候怎么既不牺牲性能又能保证数据的一致性。
## 推荐阅读
如果你学有余力,这里有两篇文章推荐给你阅读。
如果想深入了解CPU和内存之间的访问性能你可以阅读[What Every Programmer Should Know About Memory](https://people.freebsd.org/~lstewart/articles/cpumemory.pdf)。
现代CPU已经很少使用直接映射Cache了通常用的是组相连Cacheset associative cache想要了解组相连Cache你可以阅读《计算机组成与设计硬件/软件接口》的5.4.1小节。
## 课后思考
对于二维数组的访问,按行迭代和按列迭代的访问性能是一样的吗?你可以写一个程序测试一下,并思考一下原因。
欢迎把你思考的结果写在留言区。如果觉得有收获,你也可以把这篇文章分享给你的朋友,和他一起讨论和学习。