本文转自“芯片验证日记”
本篇文章主要聚焦cpu cache,文章比较长,主要分成这么几个部分:
缓冲基础知识
缓存的命中
缓冲的一致性
延伸阅读
文中会讲述一些多核 cpu 的系统架构以及其原理。这篇文章我会尽量地写简单和通俗易懂一些,主要是讲清楚相关的原理和问题,而对于一些细节和延伸阅读我会在文章最后会给出相关的资源
因为无论你写什么样的代码都会交给cpu来执行,所以,如果你想写出性能比较高的代码,这篇文章中提到的技术还是值得认真学习的。另外,千万别觉得这些东西没用,这些东西非常有用,十多年前就是这些知识在性能调优上帮了我的很多大忙,从而跟很多人拉开了差距……
1缓存基础知识
首先,我们都知道现在的cpu多核技术,都会有几级缓存,老的cpu会有两级缓存(l1和l2),新的cpu会有三级缓存(l1,l2,l3 ),如下图所示:
其中:
l1缓存分成两种,一种是指令缓存,一种是数据缓存。l2缓存和l3缓存不分指令和数据。
l1缓存和l2缓存在每一个cpu核中,l3则是所有cpu核心共享的缓存。
l1、l2、l3缓存离cpu越近就越小,速度也越快,离cpu越远,速度就越慢。
再往后面就是内存,内存的后面就是硬盘。我们来看一些他们的速度:
l1 的存取速度:4 个cpu时钟周期
l2 的存取速度: 11 个cpu时钟周期
l3 的存取速度:39 个cpu时钟周期
ram内存的存取速度:107 个cpu时钟周期
我们可以看到,l1的速度是ram的27倍,但是l1/l2的大小基本上也就是kb级别的,l3会是mb级别的。例如:intel core i7-8700k ,是一个6核的cpu,每核上的l1是64kb(数据和指令各32kb),l2 是 256k,l3有2mb(我的苹果电脑是 intel core i9-8950hk,和core i7-8700k的cache大小一样)。
我们的数据就从内存向上,先到l3,再到l2,再到l1,最后到寄存器进行cpu计算。为什么会设计成三层?这里有下面几个方面的考虑:
一个方面是物理速度,如果要更大的容量就需要更多的晶体管,除了芯片的体积会变大,更重要的是大量的晶体管会导致速度下降,因为访问速度和要访问的晶体管所在的位置成反比,也就是当信号路径变长时,通信速度会变慢。这部分是物理问题。
另外一个问题是,多核技术中,数据的状态需要在多个cpu中进行同步,并且,我们可以看到,cache和ram的速度差距太大,所以,多级不同尺寸的缓存有利于提高整体的性能。
这个世界永远是平衡的,一面变得有多光鲜,另一面也会变得有多黑暗。建立这么多级的缓存,一定就会引入其它的问题,这里有两个比较重要的问题,
一个是比较简单的缓存的命中率的问题。
另一个是比较复杂的缓存更新的一致性问题。
尤其是第二个问题,在多核技术下,这就很像分布式的系统了,要对多个地方进行更新。
2缓存的命中
在说明这两个问题之前。我们需要了解一个术语 cache line。缓存基本上来说就是把后面的数据加载到离自己近的地方,对于cpu来说,它是不会一个字节一个字节的加载的,因为这非常没有效率,一般来说都是要一块一块的加载的,对于这样的一块一块的数据单位,术语叫“cache line”,一般来说,一个主流的cpu的cache line 是 64 bytes(也有的cpu用32bytes和128bytes),64bytes也就是16个32位的整型,这就是cpu从内存中捞数据上来的最小数据单位。
比如:cache line是最小单位(64bytes),所以先把cache分布多个cache line,比如:l1有32kb,那么,32kb/64b = 512 个 cache line。
一方面,缓存需要把内存里的数据放进来,英文叫 cache associativity。cache的数据放置策略决定了内存中的数据块会拷贝到cpu cache中的哪个位置上,因为cache的大小远远小于内存,所以,需要有一种地址关联的算法,能够让内存中的数据可以被映射到cache中来。这个有点像内存地址从逻辑地址向物理地址映射的方法,但不完全一样。
基本上来说,我们会有如下的一些方法。
一种方法是,任何一个内存地址的数据可以被缓存在任何一个cache line里,这种方法是最灵活的,但是,如果我们要知道一个内存是否存在于cache中,我们就需要进行o(n)复杂度的cache遍历,这是很没有效率的。
另一种方法,为了降低缓存搜索算法,我们需要使用像hash table这样的数据结构,最简单的hash table就是做“求模运算”,比如:我们的l1 cache有512个cache line,那么,公式(内存地址 mod 512)* 64 就可以直接找到所在的cache地址的偏移了。但是,这样的方式需要我们的程序对内存地址的访问要非常地平均,不然冲突就会非常严重。这成了一种非常理想的情况了。
为了避免上述的两种方案的问题,于是就要容忍一定的hash冲突,也就出现了 n-way 关联。也就是把连续的n个cache line绑成一组,然后,先找到相关的组,然后再在这个组内找到相关的cache line。这叫 set associativity。如下图所示。
对于 n-way 组关联,可能有点不好理解,这里个例子,并多说一些细节(不然后面的代码你会不能理解),intel 大多数处理器的l1 cache都是32kb,8-way 组相联,cache line 是64 bytes。这意味着,
32kb的可以分成,32kb / 64 = 512 条 cache line。
因为有8 way,于是会每一way 有 512 / 8 = 64 条 cache line。
于是每一路就有 64 x 64 = 4096 byts 的内存。
为了方便索引内存地址,
tag:每条 cache line 前都会有一个独立分配的 24 bits来存的 tag,其就是内存地址的前24bits
index:内存地址后续的6个bits则是在这一way的是cache line 索引,2^6 = 64 刚好可以索引64条cache line
offset:再往后的6bits用于表示在cache line 里的偏移量
如下图所示:(图片来自《cache: a place for concealment and safekeeping》)
当拿到一个内存地址的时候,先拿出中间的 6bits 来,找到是哪组。
然后,在这一个8组的cache line中,再进行o(n) n=8 的遍历,主要是要匹配前24bits的tag。如果匹配中了,就算命中,如果没有匹配到,那就是cache miss,如果是读操作,就需要进向后面的缓存进行访问了。l2/l3同样是这样的算法。而淘汰算法有两种,一种是随机一种是lru。现在一般都是以lru的算法(通过增加一个访问计数器来实现)
这也意味着:
l1 cache 可映射 36bits 的内存地址,一共 2^36 = 64gb的内存
当cpu要访问一个内存的时候,通过这个内存中间的6bits 定位是哪个set,通过前 24bits 定位相应的cache line。
就像一个hash table的数据结构一样,先是o(1)的索引,然后进入冲突搜索。
因为中间的 6bits 决定了一个同一个set,所以,对于一段连续的内存来说,每隔4096的内存会被放在同一个组内,导致缓存冲突。
此外,当有数据没有命中缓存的时候,cpu就会以最小为cache line的单元向内存更新数据。当然,cpu并不一定只是更新64bytes,因为访问主存实在是太慢了,所以,一般都会多更新一些。好的cpu会有一些预测的技术,如果找到一种pattern的话,就会预先加载更多的内存,包括指令也可以预加载。这叫 prefetching 技术 (参看,wikipedia 的 cache prefetching 和 纽约州立大学的 memory prefetching)。比如,你在for-loop访问一个连续的数组,你的步长是一个固定的数,内存就可以做到prefetching。(注:指令也是以预加载的方式执行,参看本站的《代码执行的效率》中的第三个示例)
了解这些细节,会有利于我们知道在什么情况下有可能导致缓存的失效。
3缓存的一致性
对于主流的cpu来说,缓存的写操作基本上是两种策略(参看本站《缓存更新的套路》),
一种是write back,写操作只写在cache上,然后再flush到内存上。
一种是write through,写操作同时写到cache和内存上。
为了提高写操作的性能,一般来说,主流的cpu(如:intel core i7/i9)采用的是write back的策略,因为直接写内存实在是太慢了。
好了,现在问题来了,如果有一个数据 x 在 cpu 第0核的缓存上被更新了,那么其它cpu核上对于这个数据 x 的值也要被更新,这就是缓存一致性的问题。(当然,对于我们上层的程序我们不用关心cpu多个核的缓存是怎么同步的,这对上层的代码来说都是透明的)
一般来说,在cpu硬件上,会有两种方法来解决这个问题。
directory 协议。这种方法的典型实现是要设计一个集中式控制器,它是主存储器控制器的一部分。其中有一个目录存储在主存储器中,其中包含有关各种本地缓存内容的全局状态信息。当单个cpu cache 发出读写请求时,这个集中式控制器会检查并发出必要的命令,以在主存和cpu cache之间或在cpu cache自身之间进行数据同步和传输。
snoopy 协议。这种协议更像是一种数据通知的总线型的技术。cpu cache通过这个协议可以识别其它cache上的数据状态。如果有数据共享的话,可以通过广播机制将共享数据的状态通知给其它cpu cache。这个协议要求每个cpu cache 都可以“窥探”数据事件的通知并做出相应的反应。如下图所示,有一个snoopy bus的总线。
因为directory协议是一个中心式的,会有性能瓶颈,而且会增加整体设计的复杂度。而snoopy协议更像是微服务+消息通讯,所以,现在基本都是使用snoopy的总线的设计。
这里,我想多写一些细节,因为这种微观的东西,让人不自然地就会跟分布式系统关联起来,在分布式系统中我们一般用paxos/raft这样的分布式一致性的算法。而在cpu的微观世界里,则不必使用这样的算法,原因是因为cpu的多个核的硬件不必考虑网络会断、会延迟的问题。所以,cpu的多核心缓存间的同步的核心就是要管理好数据的状态就好了。
这里介绍几个状态协议,先从最简单的开始,mesi协议,这个协议跟那个著名的足球运动员梅西没什么关系,其主要表示缓存数据有四个状态:modified(已修改), exclusive(独占的),shared(共享的),invalid(无效的)。
这些状态的状态机如下所示(有点复杂,你可以先不看,这个图就是想告诉你状态控制有多复杂):
下面是个示例(如果你想看一下动画演示的话,这里有一个网页(mesi interactive animations),你可以进行交互操作,这个动画演示中使用的write through算法):
mesi 这种协议在数据更新后,会标记其它共享的cpu缓存的数据拷贝为invalid状态,然后当其它cpu再次read的时候,就会出现 cache miss 的问题,此时再从内存中更新数据。从内存中更新数据意味着20倍速度的降低。我们能不能直接从我隔壁的cpu缓存中更新?是的,这就可以增加很多速度了,但是状态控制也就变麻烦了。还需要多来一个状态:owner(宿主),用于标记,我是更新数据的源。于是,出现了 moesi 协议
moesi协议的状态机和演示示例我就不贴了(有兴趣可以上berkeley上看看相关的课件),我们只需要理解moesi协议允许 cpu cache 间同步数据,于是也降低了对内存的操作,性能是非常大的提升,但是控制逻辑也非常复杂。
顺便说一下,与 moesi 协议类似的一个协议是 mesif,其中的 f 是 forward,同样是把更新过的数据转发给别的 cpu cache 但是,moesi 中的 owner 状态 和mesif 中的 forward 状态有一个非常大的不一样—— owner状态下的数据是dirty的,还没有写回内存,forward状态下的数据是clean的,可以丢弃而不用另行通知。
需要说明的是,amd用moesi,intel用mesif。所以,f 状态主要是针对 cpu l3 cache 设计的(前面我们说过,l3是所有cpu核心共享的)。(相关的比较可以参看stackoverlow上这个问题的答案)
ZnO纳米颗粒生长的简单湿化学法晶体结构分析
亮风台推出第二代AR眼镜 搭载高通820
被纳入标普500指数 特斯拉股价周一盘后大涨13%
PLC入门需要多久_PLC入门需要看哪些书籍
坚守原创的凌科电气LP系列产品,用匠心打造连接器“金品牌”
CPU缓存基础知识
触控加速技术将提升游戏的体验感
基于PIC16F87X的电磁继电器控制设计
华为mate10上市在即,华为mate9降价让路与小米6一样的售价该如何选?
用于过压保护的齐纳二极管电路分享
7段数码管管脚顺序及译码驱动集成电路
任正非接受美媒采访:感谢特朗普宣传“华为5G”
元宇宙链游是什么
自恢复保险丝工作原理 自恢复保险丝如何选型
怎样借助边缘计算来保护物联网设施
全球范围内储能市场增速明显,新基建成为储能产业发展的有力推手
面向高端ADAS开发出业界超稳定运行的DC-DC转换器IC“BD9S402MUF-C”
一种用于嵌入式通信传输和存储设备的C语言实现方法
推动EDA工具国产化 深圳:企业买国产EDA软件 最高补助1000万
华硕COMPUTEX发表全新NB并展现保健穿戴装置