CPU缓存冷门知识讲解
由于CPU是核心硬件,相信我们在选择CPU的时候都会去关心CPU参数方面,而在CPU核心参数中,我们经常会看到缓存(Cache)这个参数,那么CPU缓存有什么用?下面就让小编带你去看看CPU缓存冷门知识讲解,希望能帮助到大家!
计算机组成原理 - CPU 高速缓存
按道理来说,循环1 花费的时间应该是循环2 的 16 倍左右。但实际上,循环1 在我的电脑上运行需要 50 毫秒,循环2 只需要 46 毫秒。相差在 15% 之内,1 倍都没有。
这就是 CPU Cache (高速缓存)带来的效果。
程序执行时,CPU 将对应的数据从内存中读取出来,加载到 CPU Cache 里。这里注意,CPU 是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。
这一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。
日常用的 Intel 服务器或者 PC 中,Cache Line 的大小通常是 64 字节。
上面的循环2 里面,每隔 16 个整型数计算一次,16 个整型数正好是 64 个字节。所以,循环1 和循环2,都需要把同样数量的 Cache Line 数据从内存中读取到 CPU Cache 中,导致两个程序花费的时间就差别不大了。
CPU Cache 一般有三层,L1/L2 单核私有,L3 多核共享缓存(这里的 L1-L3 指特定的由 SRAM 组成的物理芯片,不是概念上的缓存)。有了 CPU Cache,内存中的指令、数据,会被加载到 L1-L3 Cache 中,95% 的情况下,CPU 都只需要访问 L1-L3 Cache,而无需访问内存。
Cpu Cache 读取数据
现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的数据写入 Cache 之中。
那么,Cache 如何根据内存地址定位到数据呢?
根据内存地址的低位,计算在 Cache 中的索引;
判断有效位,确认 Cache 中的数据是有效的;
对比内存地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访问的内存数据,从 Cache Line 中读取到对应的数据块(Data Block);
根据内存地址的Offset位,从Data Block中,读取希望读取到的字
如果在2、3这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。
总结一下,一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的Data Block中定位对应字的位置偏移量。
Cpu Cache 写入数据
当 Cpu 要写入数据时,到底是写 Cache 还是写主存呢,如何保证一致性呢?
这里介绍两种写入策略。
1. 写直达(Write-Through)
写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。
这个策略很直观,但是问题也很明显,那就是这个策略很慢。无论数据是不是在 Cache 里面,我们都需要把数据写到主内存里面。这个方式就有点儿像 Java 里 volatile 关键字,始终都要把数据同步到主内存里面。
2. 写回(Write-Back)
如果要写入的数据,就在 CPU Cache 里面,那么就只更新 CPU Cache 里面的数据。同时标记 CPU Cache 里的这个 Block 是脏(Dirty)的。就是这个时候,CPU Cache 里的这个 Block 的数据,和主内存是不一致的。
如果要写入的数据所对应的 Cache Block 里,放的是别的内存地址的数据,那么就要看一看,Cache Block 里的数据有没有被标记成脏的。如果是脏的,要先把这个 Cache Block 里面的数据,写入到主内存里面。然后,再把当前要写入的数据,写入到 Cache 里,同时把 Cache Block 标记成脏的。如果 Block 里面的数据没有被标记成脏的,那么我们直接把数据写入到 Cache 里面,然后再把 Cache Block 标记成脏的就好了。
然而,无论是写回还是写直达,都没有解决多线程,或者是多个 CPU 核的缓存一致性的问题。
这也就是我们在写入修改缓存后,需要解决的第二个问题。
要解决这个问题,我们需要引入一个新的方法,叫作 MESI 协议。这是一个维护缓存一致性协议。这个协议不仅可以用在 CPU Cache 之间,也可以广泛用于各种需要使用缓存,同时缓存之间需要同步的场景下。
多核 CPU Cache 缓存一致性
因为多核 CPU Cache 在 L3 缓存是共享的,所以一致性问题,只会出现在 L1/L2 级这种单核私有缓存的场景中。
我们需要有一种机制,来同步两个不同核心里面的缓存数据。这样的机制需要满足两点:
第一点叫写传播(Write Propagation)。写传播是说,在一个CPU核心里,我们的Cache数据更新,必须能够传播到其他的对应节点的Cache Line里。
第二点叫事务的串行化(Transaction Serialization),事务串行化是说,我们在一个CPU核心里面的读取和写入,在其他的节点看起来,顺序是一样的。
CPU Cache 里做到事务串行化,需要做到两点,第一点是一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。第二点是,如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。接下来,我们就看看实现了这两个机制的 MESI 协议。
MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。
MESI协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:
M:代表已修改(Modified) E:代表独占(E__clusive) S:代表共享(Shared) I:代表已失效(Invalidated)
我们先来看看“已修改”和“已失效”,这两个状态比较容易理解。所谓的“已修改”,就是我们上一讲所说的“脏”的 Cache Block。Cache Block 里面的内容我们已经更新过了,但是还没有写回到主内存里面。而所谓的“已失效“,自然是这个 Cache Block 里面的数据已经失效了,我们不可以相信这个 Cache Block 里面的数据。
然后,我们再来看“独占”和“共享”这两个状态。这就是 MESI 协议的精华所在了。无论是独占状态还是共享状态,缓存里面的数据都是“干净”的。这个“干净”,自然对应的是前面所说的“脏”的,也就是说,这个时候,Cache Block 里面的数据和主内存里面的数据是一致的。
那么“独占”和“共享”这两个状态的差别在哪里呢?这个差别就在于,在独占状态下,对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里。其他的 CPU 核,并没有加载对应的数据到自己的 Cache 里。这个时候,如果要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。
在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。这个共享状态是因为,这个时候,另外一个 CPU 核心,也把对应的 Cache Block,从内存里面加载到了自己的 Cache 里来。
而在共享状态下,因为同样的数据在多个 CPU 核心的 Cache 里都有。所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权。
有没有觉得这个操作有点儿像我们在多线程里面用到的读写锁。在共享状态下,大家都可以并行去读对应的数据。但是如果要写,我们就需要通过一个锁,获取当前写入位置的所有权。
整个 MESI 的状态,可以用一个有限状态机来表示它的状态流转。需要注意的是,对于不同状态触发的事件操作,可能来自于当前 CPU 核心,也可能来自总线里其他 CPU 核心广播出来的信号。我把对应的状态机流转图放在了下面,你可以对照着 Wikipedia 里面 MESI 的内容,仔细研读一下。
为什么CPU有多层缓存
缓存的故事
假设你是一位六十年代的白领,在巨大的办公楼里工作,没有电脑,你需要阅读大量的文件并且交叉检索这些文档。
你有一个办公桌(L1 缓存)。桌上的文件是你正在手头处理的资料,还有一些是你最近看过的或者你准备阅读的。通常我们需要阅读文件的每一页(对应于存储单元的一个字节),但除非它们在办公桌上,文件都是作为一个整体。即只想看某一页的内容,我们也必须把整份文件抓过来。
办公室里还有文件柜(L2 缓存)。这些文件柜里存放的是你最近处理过,但目前没有在使用的文件。办公桌上的文件在用完后,通常也会放回文件柜。从文件柜里拿文件就不是顺手拈来了——你需要走过去,打开相应的抽屉,还要查目录卡片,才能找到想要的文件——不过这也还比较快了。
有些时候,其他人也需要查看你的文件柜里的文件。勤杂工会推着一辆推车(环路公共汽车)在各个办公室转。如果有人在自己的文件柜没有找到相应的资料,他会写一个纸条交给勤杂工。为简化起见,假设这位勤杂工知道所有的东西放哪儿。所以当他来到你的办公室的时候,他会检查你的文件柜里是否有其他人需要的文件,如果有,就把这些文件抽出来放到车上。当他转到别的办公室,就会把找到的文件放在文件柜里,并留下收条。
有时候,这些文件并不在文件柜里,而是在办公桌上。那就不可以直接拿了,需要征询主人的意见,如果不行,大家就要商量如何协调。有大量冗长的详尽的合作指引来处理这类情况(至少要一起开会)。
文件柜经常会满,这时就不能放新的文件,需要先腾地方,把一些很久都没用到的文件拿出来。勤杂工会把这些文件放到地下室里(L3 缓存)。地下室里的文件被密集地堆放到纸箱里或者文件架上,交给文档管理员处理,其他人都不会下去,也不会了解文档的存放细节。
当勤杂工来到地下室,会把一堆不需要的文件放到‘in’框里,同时他也会留下一堆纸条,写着在楼上文件柜里找不到的文件名。文档管理员会拿着这些纸条,找到对应的文件,把它们放到‘out’ 框里。下次勤杂工下来的时候,就可以把‘out’框里的文件拿走,交给需要的人。
现在的问题是,文件还是太多,地下室也放不下,而且办公大楼的租金都很贵,所以通常公司都会在离市区较远的地方租一个仓库来存放归档文件(对应于DRAM内存)。文档管理员会记录哪些文件放在地下室,哪些文件放在仓库。这样,当需要拿文件时,管理员就知道哪些是能在地下室找到,哪些要到仓库里拿。每天有一两次,会有一辆货车开到仓库去拿需要的文件,同时把一些地下室的旧文件运过去。
对勤杂工而言,他并不关心这些细节,这些都是文档管理员在处理。他需要做的就是把纸条放到‘in’框里,从‘out’框里取出文件。
回到最初的问题
那么,这个类比的意义何在?简短而言,一个具体的模型比模糊的概念更能清晰地阐明物流的意义。实际上,物流对设计芯片的意义和运作一个高效的办公文件处理系统是一样的。
最初的问题是‘为什么不用一个大的缓存,而是用几层小的缓存?’。 也就是说,如果一个4核芯片配置32K一级缓存,256K二级缓存和2M三级缓存,为什么不能用一个3M的大缓存?
在类比里,类似于问与其给4个人每人分一个1.5米宽的办公桌,为什么不给这4个人一个150米宽的大办公桌?
关键在于,放办公桌上的目的就是要能触手可及。如果办公桌太大,就没有意义了。难道还需要走50米去拿文件? 对一级缓存也是同理,如果太大,存取速度会变慢,而且会消耗更多的电力。所以一级缓存既要足够大到能起作用,又要小到能够快速存取。
另外一点,一级缓存处理的存取类型和其他缓存不同。有L1的数据缓存,也有L1指令缓存。Intel的CPU还有另外的指令缓存,uOp缓存,既叫L1并发指令缓存也叫L0指令缓存。
L1数据缓存通常只处理1到8个字节的数据,但高层级的缓存并不处理单独的字节。在我们的类比里,所有不办公桌上的资料都是以文件为单位(对应于catch line)。 在内存中也一样,高层级缓存通常是批发处理数据,以缓存行为单位(cache line)。
L1指令缓存和数据缓存完全不同,就内核而言,它是只读的。(指令内存的写入通常是用非直接的方式,先把指令放入高层的缓存,再载入一级指令缓存)。由于这个原因,指令缓存和数据缓存通常是分隔的。使用通用的L1缓存意味着把互相冲突的设计原则糅合在一起,妥协的结果就是任何一个目的都达不到。而且用通用的L1缓存处理指令和数据负载也会很大。
另外,作为程序员,我们通常不关心内存带宽。例如,每个时钟周期,i7的CPU的内核能从L1缓存中读取16字节的指令,而且会不断地循环读取。如果是3GHZ,每个核可以读50GB指令/秒。实际上,通常L1指令缓存的能力都足够大,很少需要L2缓存参与处理。但如果是通用缓存,就需要预估指令和数据的高并发情况。(想象一下在L1缓存中用memcopy拷贝几K数据的情况)
顺便提一句,如果都在L1缓存,CPU能在一个时钟周期完成许多存取操作。‘Haswell’或者之后的3GHZ的i7内核可以处理超过300GB的指令和数据, 如果搭配合理的话。这样的处理能力绰绰有余,但你仍然需要考虑数据和指令同时出现峰值的情况。
L1缓存在设计上就是越快越好,以应对峰值情况。只有L1缓存处理不了,才会转给更高层的缓存,但速度会降低。因为高层缓存更关心电力效率和存储密度。
第三点,共享。在上面的比喻中,独立办公桌,亦或L1缓存是私有的,如果在你的办公桌上,你只管拿就好了,不需要询问其他人。
这很关键。如果4个人共享一个大办公桌,你就不能随便拿文件,因为另外三个人可能正在使用。(即使他们只是在阅读其他文件时顺便参考一下你想使用的文件)。任何时候,你想要拿什么东西,你需要先叫一声,‘有人在用吗?’如果别人在你前面,你就必须等待。或者需要一个排队系统,当存在资源冲突的时候,每个人需要拿张票排队等候,或者其它的什么机制,具体实现细节并不重要,但是所有的事情你都需要和其他人协调。
对多核共享缓存的情况也是这样。你不能在不通知别人的情况下随意动那些数据,所有对共享缓存的操作都必须协调进行。
这就是为什么我们要使用私有的L1缓存。L1缓存就是你的办公桌,你可以随便使用桌上的文件。L2缓存处理大部分的协同操作。大部分时间,工作者(CPU内核)坐在办公桌前,勤杂工会走过来,把需求列表拿走,同时把之前你想找的文件放倒文件柜里。整个过程不会打断你的工作(CPU)。
仅仅当你和勤杂工都需要拿文件柜里的同一份文件,或者别人想用你办公桌上的文件,这时就需要停下手头工作,进行交谈。
简单而言,L1缓存的工作就是优先为CPU内核服务。因为是私有的,所以基本不需要协调工作。L2缓存也是私有的,但是它的工作重心还包括在不打扰内核工作的情况下,处理大量的缓存间的数据通信。
L3缓存是共享资源,需要全局协调。在上面的类比中,工作者只有从勤杂工的推车里拿到文件,这就是一个阻塞点。我们只能希望L1和L2缓存足够大以便这类阻塞点不会成为性能瓶颈。
附加说明
本篇文章涵盖了当前台式机(笔记本)CPU的缓存架构:分开的L1/L1 D 缓存,每核统一的L2缓存,共享的L3缓存。
不是每个系统都象这样。一些系统并不区分指令缓存和数据缓存;另外一些则把指令和数据在所有的缓存级全部分开。很多L2缓存是多核共享的,L2缓存就象是连接多个内核的公共汽车。还有一些系统有L3和L4缓存。我也没有提到使用多CPU套接字的系统。
我提到环路公共汽车是因为这是一个很好的类比。环路公共汽车很常见。有些时候,环路汽车是个麻烦(尤其是只需要把两三个街区连起来);有时候,环路公共汽车需要和交叉系统连接起来(就象在办公室,每个楼层用推车,不同楼层则用电梯)。
作为软件工程师,我们自然而然地会假设模块A和模块B可以魔术般地连接,数据则可随意地从一端流向另一端。内存的实际工作机制其实非常复杂,但抽象出来呈现给程序员的只是一组大平面的字节排列。
硬件并不象这样工作。各个部件之间并不能魔法般地自动连接。模块A和模块B并非抽象概念,而是具体的物理设备,实际上可以看作是非常小的机器,在硅片上占有实际的物理空间。芯片都有平面图,是真正的2D地图。如果你想连接A和B,就需要一条实际的导线来连接。 导线也占空间,而且需要消耗电力(越远消耗越多)。用一大捆线连接A和B意味着物理上这一块区域会阻碍其他区域的连接。(当然,芯片可以使用多层连接,如果你有兴趣,可以搜索‘routing congestion’)。 在芯片里移动数据实际上是一个物流问题,并且超级复杂。
所以尽管办公室的故事只是半开玩笑的类比,‘谁需要和谁谈’、‘这个系统的几何构造如何——是有意义的布局吗?’这些问题其实对系统设计和硬件相关并有巨大的影响。 利用空间的隐喻来概念化实际情况是十分有效的。
Intel CPU漏洞技术解读:都是缓存惹的祸!
原因一切还是要从CPU指令执行的框架——流水线说起。Intel当然不至于明知你要用一个用户态的进程读取Kernel内存还会给你许可。但现代CPU流水线的设计,尤其是和性能优化相关的流水线的特性,让这一切充满了变数。
给所有还没有看过云杉网络连载的系列文章《__86高性能编程笺注系列》的读者一点背景知识的介绍:
__86 CPU为了优化性能,在处理器架构方面做了很多努力。诸如“多级缓存”这一类的特性,是大家都比较熟悉的概念。还有一些特性,比如分支预测和乱序执行,也都是一些可以从并行性等方面有效提升程序性能的特性,并且它们也都是组成流水线的几个关键环节。即便你暂时还不能准确理解其含义,但望文生义,也能看出来这肯定是两个熵增的过程。熵增带来无序,无序就会带来更多漏洞。
缓存的困境讲缓存,必然先挂一张memory hierarchy镇楼:
不过我要说的和这个没太大关系。现在需要考虑的是,如果能读取到内核地址的内容,那这部分内容最终肯定是跑到缓存中去了,因为真正直接和CPU核心交互的存储器,就是缓存。这对一级缓存(L1 Cache,业内也常用缩写L1$,取cash之音)提出的要求就是,必须要非常快,唯有如此才能跟上CPU处理核心的速度。
Side Notes: 为什么在不考虑成本的情况下缓存不是越大越好,也是因为当缓存规模越大,查找某一特定数据就会越慢。而缓存首先要满足的要求就是快,其他的都是次要的。
根据内核的基本知识我们知道,进程运行时都有一个虚拟地址「Virtual address」和其所对应的物理地址「physical address」。
从虚拟地址到物理地址的翻译转换也由CPU通过page table完成。Page table并不储存在CPU里,但近期查找到的Page table entry「PTE」都像数据一样,缓存在了CPU中的translation lookaside buffer「TLB」里。为了不再过多堆砌术语和名词,画张图说明一下:
当CPU根据程序要求需要读取某个地址上的数据时,首先会在L1 Cache中查找。为了适应CPU的速度,L1缓存实现为Virtually inde__ed physically tagged「VIPT」的形式,即用虚拟地址即可直接读取该虚拟地址对应的物理地址的内容,而不再需要多加一道转换的工序。
如果L1 Cache miss,则会在下级缓存中查找。但越过L1 Cache之后,对L2$和L3$的速度要求就不再这么严苛。此时CPU core给出的虚拟地址请求会先通过TLB转换为物理地址,再送入下级缓存中查找。而检查进程有没有权限读取某一地址这一过程,仅在地址转换的时候发生,而这种转换和检查是需要时间的,所以有意地安排在了L1 Cache之后。
L1缓存这种必须求“快”的特性,成了整个事件的楔子。
分支预测分支预测是一种提高流水线执行效率的手段。在遇到if..else..这种程序执行的分支时,可以通过以往的历史记录判断哪一分支是最可能被执行的分支,并在分支判断条件真正返回判断结果之前提前执行分支的代码。详情可以在上面提到的连载文章中阅读。
需要强调的是,提前执行的分支代码,即便事后证明不是正确的分支,其执行过程中所读取的数据也可以进入L1缓存。在Intel的官网文档《Intel? 64 and IA-32 Architectures Optimization Reference Manual》第2.3.5.2节中指:
L1 DCache Loads:
- Be carried out speculatively, before preceding branches are resolved.
- Take cache misses out of order and in an overlapped manner.
Show you the [伪] code:
if (likely(A < B)) { value = __(kernel_address_pointer);}
当分支判断条件A < B被预测为真时,CPU会去提前执行对内核地址的读取。当实际条件为A > B时,虽然内核的值不会真正写入寄存器(没有retire),但会存入L1 Cache,再加之上一节介绍的,获取L1 Cache的值毋须地址转换,毋须权限检查,这就为内核信息的泄漏创造了可能。
从理论上来讲,如果可以控制程序的分支判断,并且可以获取L1缓存中的数据(这个没有直接方法,但可以通过其他间接手法)的话,就完全可以获取内核信息。而分支预测这种特性是不能随随便便就关闭的,这也就是这次问题会如此棘手的原因。
乱序执行还有一个原因是乱序执行,但原理大致类似。乱序执行是Intel在1995年首次引入Pentium Pro处理器的机制。其过程首先是将我们在汇编代码中看到的指令“打散”,成为更细粒度的微指令「micro-operations」,更小的指令粒度将会带来更多的乱序排列的组合,CPU真正执行的是这些微指令。
没有数据依赖的微指令在有相应执行资源的情况下乱序并行执行,进而提升程序的并行程度,提高程序性能。但引入的问题是,读取内核数据的微指令可能会在流水线发出e__ception之前将内核数据写入L1 Cache。与分支选择一样,为通过用户态进程获取内核代码提供了可能。
限于篇幅,更详细的内容读者可以在国外安全团队发布的消息中获取。
后续刚刚查阅之前连载中的一些细节的时候,看到在“流水线”那一章里写过这样一段话:
在面对问题的时候,人总是会倾向于引入一个更复杂的机制来解决问题,多级流水线就是一个例子。复杂可以反映出技术的改良,但“复杂”本身就是一个新的问题。这也许就是矛盾永远不会消失,技术也不会停止进步的原因。但“为学日益,为道日损”,愈发复杂的机制总会在某个时机之下发生大破大立,但可能现在时机还没有到来:D
很难讲现在是不是就是所谓的那个“时机”。虽然对整个行业都产生了负面影响,但我对此仍保持乐观。因为这就是事物自然发展的一个正常过程。性能损失并不是一件坏事,尤其是对牙膏厂的用户来说。
CPU缓存冷门知识讲解相关文章: