为什么系统软件人员要深入了解cache?
在一个系统中,cache无处不在,对于一个系统编程人员来说,你无法躲藏。下图是一个经典的ARM64系统的架构图,由Corte-A72和Cortex-53组成了大小核架构,每个CPU核心都有L1 cache,每个cluster里共享一个L2 cache,另外还有Mali GPU和DMA外设。
对于系统软件人员,下面几个常常疑惑的问题:
-
cache的内部组织架构是怎么样的?能否画出一个cache的layout图?什么是set,way?
-
直接映射,全关联和组相联之间有什么区别?优缺点是啥?
-
重名问题是怎么发生的?
-
同名问题是怎么发生的?
-
VIPT会不会发生重名问题?
-
什么是inner shareability 和outer shareability?怎么区分?
-
什么是PoU?什么是PoC?
-
什么是cache一致性?业界解决cache一致性都有哪些方法?
-
MESI状态转换图,我看不懂。
-
什么cache伪共享?怎么发生的,如何避免?
-
DMA和cache为啥会有cache一致性问题?
-
网卡通过DMA收数据和发数据,应该怎么操作cache?
-
对于self-modifying code,怎么保证data cache和指令cache的一致性问题?
所以,Cache这个玩意,对我们系统编程人员来说,非常重要。Cache没有理解好,或者没有完全搞透了,对系统编程影响很大,有时候我们在编程的时候,一行代码小小的改动可能会影响整个系统的性能,所以,我是建议系统程序员有必要把cache这玩意好好系统的学一学。
笨系列文章主要源自第三季《arm64体系结构与编程》视频课程,大概会有上下两篇:
上篇:介绍cache相关的背景知识。笨叔:ARM64体系结构与编程之cache必修课(上)
中篇:主要介绍ARM特有的inner share和outer share的概念,以及神马是PoU和PoC,还有cache指令的格式。笨叔:ARM64体系结构与编程之cache必修课(中)
下篇:主要介绍MESI协议,怎么去看MESI协议状态图,DMA和cache之间的cache一致性问题,self-modifying code导致的I-cache和D-cache的一致性问题,cache伪共享等问题。
cache一致性协议
其实我们前面的文章已经讲过了,cache一致性产生的原因是因为:同一个内存数据在多个CPU核心的L1 cache中存在多个不同的副本,导致数据不一致。例如下面这个图,数据A在三个地方都同时存在数据的副本:内存,core0的cache,core1的cache里,那么这个系统有4个观察者(arm手册很喜欢用这个词,observer):Core0,Core1,DMA,GPU,那么他们4个观察内存A的数据,会是一致吗?有没有可能产生不一致的情况呢?这个就是cache一致性的问题:比如核与核之间的cache一致性,DMA和cache之间的一致性问题等。
那怎么去做呢,怎么去保证多个cpu核心的cache一致呢?
其实维护cache一致性的关键是跟踪每一个cache line的状态,并根据cpu的读写操作和总线上相应的传输内容来更新所有CPU上的cache line的状态,这样就能保证cache一致性了。维护cache一致性有软件和硬件两种方式。不过现在大多数处理器架构采用硬件方式来维护它。在处理器中通过cache一致性协议来实现,这些协议维护一个状态机,根据存储器读写的指令或总线上的传输内容,进行状态迁移和相应的cache操作来维护cache一致性,这个是不需要软件介入的。
cache一致性协议主要有两大类别:一类是监听协议(snooping protocol),每个cache都要被监听或者监听其他cache的总线活动;另一类是目录协议(directory protocol),用于全局统一管理cache状态。
1983年,James Goodman提出Write-Once总线监听协议,后来演变成目前很流行的MESI协议。Write-Once总线监听协议依赖于这样的事实,即所有的总线传输事务对于处理器系统内的其他单元是可见的。总线是一个基于广播通信的介质,所以呢,可以由每个CPU的cache来进行监听。在最近十几年,大家提出了数十种协议,这些协议基本上都是Write-Once总线监听协议的变种。不同的协议需要不同的通信量,通信量要求太多的 会浪费总线带宽,因为它使总线争用情况变多,留给其他部件使用的带宽减少。所以,做芯片设计工程师需要在性能和带宽方面做一个折中,尝试将保持一致性协议所需要的总线通信量减少到最小,或者尝试优化某些频繁执行的操作。
神马是MESI协议?
目前,ARM或x86等处理器广泛使用MESI协议来维护高速缓存一致性。MESI协议的名字源于该协议使用的修改(Modified,M)、独占(Exclusive,E)、共享(Shared,S)和失效(Invalid,I)这4个状态。高速缓存行中的状态必须是上述4个状态中的1个。MESI协议还有一些变种,如MOESI协议等,部分ARMv7-A和ARMv8-A处理器使用该变种协议。
高速缓存行中有两个标志——脏(dirty)和干净(valid)。它们很好地描述了高速缓存和内存之间的数据关系,如数据是否有效、数据是否被修改过。在MESI协议中,每个高速缓存行有4个状态,可以使用高速缓存行中的2位来表示这些状态每个状态可用两位来表示。
我们来看一下右边这个表,
M:这行数据有效,数据已被修改,和内存中的数据不一致,数据只存在于该高速缓存中
E:这行数据有效,数据和内存中数据一致,数据只存在于该高速缓存中
S:这行数据有效,数据和内存中数据一致,多个高速缓存有这行数据的副本
I:这行数据无效
上面有三个状态都表示这一行cache line数据有效,那我们怎么去区别呢?我要特别总结一下:
-
修改和独占状态的高速缓存行中,数据都是独有的,不同点在于修改状态的数据是脏的,和内存不一致;独占状态的数据是干净的,和内存一致。拥有修改状态的高速缓存行会在某个合适的时刻把该高速缓存行写回内存中,其后的状态变成共享状态。
-
共享状态的高速缓存行中,数据和其他高速缓存共享,只有干净的数据才能被多个高速缓存共享。
-
失效状态表示这个高速缓存行无效。
MESI的操作
我们看完MESI的4个状态的含义之后,我们来看一下MESI的操作。MESI协议在总线上的操作分成本地读写和总线操作。
-
本地读写,指的是local cpu的读写,本地读,在有的文献里也称为process read,简称PrRd,就是指的本地cpu访问本地CPU读取本地的cache line数据。本地写,process write,简称PrWr,就是本地CPU写入本地的cache line。
-
我们先来看总线读(Bus Read, BusRd),总线监听到一个来自其他CPU的读cache的请求。收到信号的CPU先检查自己的cache中是否缓存了该数据,然后广播应答信号。
-
我们来看总线写(Bus Write/BusRdX),总线监听到一个来自其他CPU的写cache的请求。收到信号的CPU先检查自己的cache中是否缓存了该数据,然后广播应答信号。
-
我们来看总线更新(BusUpgr),总线监听到更新请求,请求其他CPU做一些额外事情。其他CPU收到请求后,若CPU上有缓存副本,则需要做额外的一些更新操作,如使本地的高速缓存行无效等。
-
我们来看一下刷新(Flush),总线监听到刷新请求。收到请求的CPU把自己的高速缓存行的内容写回主内存中。
-
我们看一下刷新到总线(FlushOpt),收到该请求的CPU会把高速缓存行内容发送到总线上,这样发送请求的CPU就可以获取到这个高速缓存行的内容。
上面这些操作,就是MESI协议规定的操作。初始状态下,当cache line中没有加载任何数据时,状态为I。本地读写指的是本地CPU读写自己私有的cache line,这是一个私有操作。总线读写指的是有总线的事务(bus transaction),因为实现的是总线监听协议,所以CPU可以发送请求到总线上,所有的CPU都可以收到这个请求。总之,总线读写的目标对象是远端CPU的高速缓存行,而本地读写的目标对象是本地CPU的高速缓存行。这些操作对我们理解 MESI状态的转换非常重要。
MESI状态图
下面这张图是MESI经典的状态图,不过,我相信很多人看这个图,就晕了。
不知道从哪里开始看起,这样,笨叔准备把这个图拆分,用动画的方式来解释这个图。
动画演示:初始化状态为I的cache line
接下来我们用动画的方式来给大家解读这个MESI协议,我们先来看初始化状态为I的cache line相关的操作。
当本地CPU的缓存行状态为I时,发起读操作
我们先看本地读操作。
我们假设CPU0发起了本地读请求,CPU0发出读PrRd请求,因为是本地cache line是无效状态,所以呢,在总线上产生一个BusRd信号,然后广播到其他CPU。其他CPU会监听到该请求(BusRd信号的请求)并且检查它们的缓存来判断是否拥有了该副本,下面分4种情况来考虑。
-
如果CPU1发现本地副本,并且这个高速缓存行的状态为S,那么在总线上回复一个FlushOpt信号,即把当前的cache line的内容发送到总线上,那么刚才发出PrRd请求的CPU0,就能得到这个cache line的数据,然后CPU0状态变成S。那么这个时候的cache line的变化情况是:CPU0上的cache line从I编程S,CPU1上的cache line保存S不变。
-
假设CPU2发现本地副本并且高速缓存行的状态为E,则在总线上回应FlushOpt信号,即把当前的cache line的内容发送到总线上,CPU2上的高速缓存行的状态变成S。那么这个时候 cache line的变化情况:CPU0的cache line从I变成了S,而CPU2上的cache line从E变成了S。
-
假设CPU3发现本地副本并且高速缓存行的状态为M,将数据更新到内存,这时候两个高速缓存行的状态都为S。我们来看一下cache line的变化情况:CPU0上cache line从I变成了S,CPU3上的cache line从M变成了S
-
假设CPU1,CPU2, CPU3上的cache line都没有缓存数据,状态都是I,那么CPU0会从内存中读取数据到L1 cache,把cache line状态设置为E。
当本地CPU的缓存行状态为I时,收到一个总线读的信号
我们来看总线读,如果处于I状态的cache line收到一个总线读操作,它的状态不变,回应一个ACK信号:老兄,我这没有数据副本。
当初始化状态为I时,发起写操作
我们来看一下,当初始化状态为I的cache line发起一个本地写操作,那么cache line会有啥变化?
我们假设CPU0发起了本地写请求,即CPU0发出读PrWr请求:
-
由于本地cache line是无效的,所以,CPU0发送BusRdX信号到总线上。这种情况本地写操作,变成了总线写了,我们要看其他CPU的情况。
-
其他CPU(例如CPU1等)收到BusRdX信号,先检查自己的高速缓存中是否有缓存副本,广播应答信号。
-
假设CPU1上有这份数据的副本,且状态为S,CPU1收到一个BusRdX信号指挥,会回复一个flushopt信号,把数据发送到总线上,然后把自己的cache line设置为无效,状态变成I,然后广播应答信号。
-
假设CPU2上有这份数据的副本,且状态为E,CPU2收到这个BusRdx信号之后,会回复一个flushopt信号,把数据发送到总线上,会把自己的cache line设置为无效,然后广播应答信号。
-
假设CPU3上有这份数据的副本,状态为M,CPU3收到这个BusRdx信号之后,会把自己cache line的内存flush为内存,然后自己状态变成I,然后广播应答信号。
-
若其他CPU上也没有这份数据的副本,也要广播一个应答信号。
-
CPU0会接收其他CPU的所有的应答信号,确认其他CPU上没有这个数据的缓存副本后。CPU0会从总线上或者从内存中读取这个数据:
a)如果其他CPU的状态是S或者E的时候,会把最新的数据通过flushopt信号发送到总线上。
b)如果总线上没有数据,那么直接从内存中读取数据。
最后才修改数据,并且本地cache line的状态变成M。
当本地CPU的缓存行状态为I时,收到一个总线写的信号
我们来看总线写,如果处于I状态的cache line收到一个总线写操作,因为它本来就没有有效的数据副本,所以它的状态不变,回应一个ACK信号:老兄,我这没有数据副本。
动画演示:初始化状态为M的cache line
好,我们来看当本地CPU的cache line状态为M的情况,最简单就是本地读写了,因为M的状态,说明只有你有最新的数据,而且是dirty的数据,所以本地读写,状态不变。
收到一个总线读的信号
本地CPU(假设是CPU0)上的cache line的状态为M,而在其他CPU上没有这个数据的副本。当其他CPU(如CPU1)想读这份数据时,CPU1会发起一次总线读操作,所以,流程是这样的:
-
若CPU0上有这个数据的副本,那么CPU0收到信号后把cache line的内容发送到总线上,然后CPU1就获取这个cache line的内容。另外,CPU0会把相关内容发送到主内存中,把cache line的内容写入主内存中。这时候CPU0的状态从M->S
-
更改CPU1上的cache line状态为S。
收到一个总线写的信号
数据在本地CPU(假设是CPU0)上有副本并且状态为M,而其他CPU上没有这个数据的副本。若某个CPU(假设CPU1)想更新(写)这份数据,CPU1就会发起一个总线写操作。
-
若CPU0上有这个数据的副本,CPU0收到总线写信号后,把自己的高速缓存行的内容发送到内存控制器,并把该cache line的内容写入主内存中。CPU0上的高速缓存行状态变成I
-
CPU1从总线或者内存中取回数据到本地cache line,然后修改自己本地cache line的内容。
CPU1的状态变成M。
动画演示:初始化状态为S的cache line
好,我们来看第3种情况,当本地CPU的cache line状态为S时,
-
如果CPU发出本地读操作。状态不变
-
如果CPU收到总线读(BusRd),状态不变,回应一个flushopt信号,把数据发到总线上。
-
如果CPU发出本地写操作(PrWr)
3.1 发送BusRdX信号到总线上。
3.2 本地CPU修改本地高速缓存行的内容,状态变成M。
3.3 发送BusUpgr信号到总线上。
3.4 其他CPU收到BusUpgr信号后,检查自己的高速缓存中是否有副本,若有,将其状态改成I
动画演示:初始化状态为E的cache line
好,我们来看最后一种情况,当本地CPU的cache line状态为E的时候。
-
本地读,从该cache line中取数据,状态不变
-
本地写,修改该cache line的数据,状态变成M
-
收到一个总线读信号,独占状态的cache line是干净的,因此状态变成S。
3.1 cache line的状态变成S。
3.2 发送FlushOpt信号,把cache line的内容发送到总线上。
3.3 发出总线读信号的CPU,从总线上获取了数据,状态变成S。 -
收到一个总线写
数据被修改,该cache line不能再使用了,状态变成I。
4.1 cache line的状态变成I。
4.2 发送Flushopt信号,把cache line的内容发送到总线上。
4.3 发出总线写信号的CPU,从总线上获取了数据,然后修改,状态变成M。
学会了MESI状态图,有啥用呢?
有的同学会说,我学习MESI这些状态转换有什么用呢?
虽然这个MESI状态机的转换是硬件自动实现的,但是对我们软件编程还是真有帮助。下面我来举一个例子。
假设系统中有4个CPU,每个CPU都有各自的一级cache,它们都想访问相同地址的数据A,大小为64字节。
T0时刻:4个CPU的L1 cache都没有缓存数据A,cache line的状态为I (无效的)
T1时刻:CPU0率先发起访问数据A的操作
T2时刻:CPU1也发起读数据操作
T3时刻:CPU2的程序想修改数据A中的数据
请分析上述过程中, MESI状态的变化。
如果大家能正确分析上述T0~T3时刻的MESI状态的变化,那笨叔觉得,cache一致性这节课,你已经掌握的七七八八了。好,接下来我给大家分析一下这个例子。
-
T0时刻,假设初始状态下数据A还没有缓存到cache中,4个CPU的cache line的默认状态是无效的。
-
T1时刻,CPU0率先发起访问数据A的操作。对于CPU0来说,这是一次本地读。由于CPU0本地的高速缓存并没有缓存数据A,因此,CPU0首先发送一个BusRd信号到总线上。它想询问一下其他3个CPU:“小伙伴们,你们有缓存数据A吗?有的话,麻烦发一份给我。”其他3个CPU收到BusRd信号后,马上查询本地高速缓存,然后给CPU0回应一个应答信号。若CPU1在本地查询到缓存副本,则它把高速缓存行的内容发送到总线上并回应CPU0道:“CPU0,我这里缓存了一份副本,我发你一份。”若CPU1在本地没有缓存副本,则回应:“CPU0,我没有缓存数据A。”假设CPU1上有缓存副本,那么CPU1把缓存副本发送到总线上,CPU0的本地缓存就有了数据A,并且把这个高速缓存行的状态设置为S。同时,提供数据的缓存副本的CPU1也知道一个事实,数据的缓存副本已经分享给CPU0了,因此CPU1的高速缓存行的状态也设置为S。在本场景中,如果其他3个CPU都没有数据的缓存副本,那么CPU0只能老老实实地从主内存中读取数据A并将其缓存到CPU0的cache line中,把高速缓存行的状态设置为E。
-
T2时刻,CPU1也发起读数据操作。这时,整个系统里只有CPU0中有缓存副本,CPU0会把缓存的数据发送到总线上并且应答CPU1,最后CPU0和CPU1都有缓存副本,状态都设置为S。
-
T3时刻,CPU2的程序想修改数据A中的数据。这时CPU2的本地cache line并没有缓存数据A,高速缓存行的状态为I,因此,这是一次本地写操作。首先CPU2会发送BusRdX信号到总线上,其他CPU收到BusRdX信号后,检查自己的高速缓存中是否有该数据。若CPU0和CPU1发现自己都缓存了数据A,那么会使这些cache line无效,然后发送应答信号。虽然CPU3没有缓存数据A,但是它回复了一条应答信号,表明自己没有缓存数据A。CPU2收集完所有的应答信号之后,把CPU2本地的高速缓存行状态改成M,M状态表明这个高速缓存行已经被自己修改了,而且已经使其他CPU上相应的cache line无效。
高速缓存伪共享是个什么鬼玩意?
我们熟悉了MESI状态的转换的之后,我们来看cache伪共享就简单多了。什么是cache伪共享呢?其实,我们知道一个cache line的大小是32字节或者64字节,如果两个频繁访问的数据A和B,他们共处在一个cache line里面,然后不同的CPU都在频繁的访问A或者B的数据,那么就会带来性能上的问题,可能这个cache line的状态要频繁的变来变去,造成一种无畏的颠簸,我们知道MESI本质上是要消耗系统内部总线的带宽的,你一个cache line的状态老是频繁的变来变去,总线带宽都被你消耗了不少,当然会引起性能的问题,所以,这个叫做cache伪共享(英文叫做false sharing)。
我们刚才讲的有点抽象,我们举一个例子,大家就容易理解了。
假设CPU0上的线程0想访问和更新data数据结构中的x成员,同理CPU1上的线程1想访问和更新data数据结构中的y成员,其中x和y成员都缓存到同一个高速缓存行里。
根据我们前面学会的MESI协议的状态图,我们可以分析出CPU0和CPU1之间对高速缓存行的争用情况。
(1)CPU0第一次访问x成员时,因为x成员还没有缓存到高速缓存,所以cache line的状态为I。CPU0把整个data数据结构都缓存到CPU0的L1 cache里,并且把cache line的状态设置为E。
(2)CPU1第一次访问y成员时,因为y成员已经缓存到高速缓存中,而且该cache line的状态是E,所以CPU1先发送一个总线读的请求。CPU0收到请求后,先查询本地高速缓存中是否有这个数据的副本,若有,则把这个数据发送到总线上。CPU1获取了数据后,把本地的高速缓存行的状态设置为S,并且把CPU0上的本地cache line的状态也设置为S,因此所有CPU上对应的高速缓存行状态都设置为S。
(3)CPU0想更新x成员的值时,CPU0和CPU1上的高速缓存行的状态为S。CPU0发送BusUpgr信号到总线上,然后修改本地cache line的数据,将其状态变成M;其他CPU收到BusUpgr信号后,检查自己的高速缓存行中是否有副本,若有,则将其状态改成I。
(4)CPU1想更新y成员的值时,CPU1上的高速缓存行的状态为I,而CPU0上的cache line缓存了旧数据,并且状态为M。这时,CPU1发起本地写的请求,根据MESI协议,CPU1会发送BusRdX信号到总线上。其他CPU收到BusRdX信号后,先检查自己的高速缓存中是否有该数据的副本,广播应答信号。这时CPU0上有该数据的缓存副本,并且状态为M。CPU0先将数据更新到内存,更改其高速缓存行状态为I,然后发送应答信号到总线上。CPU1收到所有CPU的应答信号后,才能修改CPU1上高速缓存行的内容。最后,CPU1上cache line的状态变成M。
(5)若CPU0想更新x成员的值,这和步骤(4)类似,发送本地写请求后,根据MESI协议,CPU0会发送BusRdX信号到总线上。CPU1接收该信号后,把高速缓存行数据写回内存,然后使该高速缓存行无效,即把CPU1上的高速缓存行状态变成I,然后广播应答信号。CPU0收到所有CPU的应答信号后才能修改CPU0上的高速缓存行内容。最后,CPU0上的高速缓存行的状态变成M。
综上所述,如果CPU0和CPU1反复修改,就会不断地重复步骤(4)和步骤(5),两个CPU都在不断地争夺对高速缓存行的控制权,不断地使对方的高速缓存行无效,不断地把数据写回内存,导致系统性能下降,这种现象叫作高速缓存伪共享。
高速缓存伪共享的解决办法就是让多线程操作的数据处在不同的高速缓存行,通常可以采用高速缓存行填充(padding)技术或者高速缓存行对齐(align)技术,即让数据结构按照高速缓存行对齐,并且尽可能填充满一个高速缓存行大小。下面的代码定义一个counter_s数据结构,它的起始地址按照高速缓存行的大小对齐,数据结构的成员通过pad[4]来填充。
系统间的cache一致性
好,我们来给大家介绍一下系统级别的cache一致性。系统cache一致性的解决方案,其实在arm公司实现的AMBA总线里,AMBA总线协议里定义了一个叫做ACE的接口,这个接口规范执行了Hardware Cache Coherency。
cache一致性,需要保证系统中所有的CPU,所有的bus master从,例如GPU,DMA等,他们观察到的内存是一致的。
一般情况下系统的cache一致性有三种方案:我们在前面已经和大家分析过了。
-
关闭cache。这是最简单的办法,不过,它会严重影响性能。
-
软件管理cache一致性。这是最常用的方式,软件需要在合适的时候去clean or flush dirty cache,或者invalidate old data。这种方式需要编写驱动的工程师,特别小心。
-
硬件管理cache一致性。对软件是透明的。
基于ACE接口协议,arm公司设计了几款cache一致性的控制器。
-
提供AMBA 4 Coherency Extensions(ACE)
-
支持1~6个ACE master
-
ACE提供了基于MOESI-base的协议来保证cross-cluster一致性
CCI缓存一致性控制器
这一页,列出了ARM公司几款CCI控制器,CCI就是Cache Coherent Interconnect的简称。
我们以最常见的CCI-400为例,它支持2个cluster,最大支持8个CPU,支持2个ACE slave接口,支持1~3个ACE lite slave接口,然后他使用broadcast snoop coherency策略。
下面这个框图是经典的使用大小核的框图,并且使用了CCI-400这个缓存一致性的控制器。
大家可以看到,中间这个就是CCI-400的控制器,那么大小核的A57和A53都连接到这个CCI-400上,还有Mali GPU也连接到CCI-400里,除此之外,还有DDR内存,通过DMC-400的内存控制器连接到CC400里,当然还有一些外设,也可以通过ACE Lite接口连接到 CCI400上。
CCN缓存一致性控制器
ARM一直想冲击服务器市场,服务器一般cpu核心的数量都是几十个,上百个的,所以,刚才介绍的CCI控制器显然不能满足服务器的需求,所以,arm重新设计了一个新的缓存一致性控制器,叫做Cache Coherent Network,简称CCN,它可以支持48个核心,L3 cache,而且The L3 system cache allows IO and accelerators to allocate memory on chip。这个CCN是基于最新的AMBA 5协议的。
这个表格就是几款CCN IP的规格,最高规格的CCN-512,最大支持48个核心,支持12个cluster,支持L2 cache,1-32MB,最高达到225GB每秒的传输数率。
CCN的这个IP的芯片手册现在还没有公开下载,所以,我们只能通过一些简单的介绍资料来了解。
下面这个图是一个典型的CCN控制器的应用场景。这个一个服务器的配置,它支持48个A72,集成了32MB的L3 cache,还支持DDR3/4内存颗粒,支持24 个cache一致性的IO port。总之就是一个典型的服务器配置。
cache案例分析1:伪共享的避免
第一个案例是cache的伪共享的避免。避免的方法主要有两个。
第一个是:一些常用的数据结构在定义时就约定数据结构以一级缓存对齐。例如使用如下的宏来让数据结构首地址以L1 cache对齐。下面这个宏是利用了GCC的特性,_attribute的属性,来让数据结构的起始地址以某个数字对齐,这里是以L1 cache对齐。
第二个是:数据结构中频繁访问的成员可以单独占用一个高速缓存行,或者相关的成员在高速缓存行中彼此错开,以提高访问效率。
例如struct zone数据结构使用ZONE_PADDING技术(填充字节的方式)来让频繁访问的成员在不同的cache line中。
所以,cache伪共享,在有些情况下是性能杀手,而且你又比较难去发现它,所以需要我们编程的时候,特别注意。你写的数据结构里,有没有可能出现 不同的CPU核心频繁访问某些成员,导致cache伪共享的?这个需要写程序的时候就要思考清楚。
cache案例分析2:DMA和cache的一致性
DMA的全程是:(Direct Memory Access)直接内存访问,它在传输过程中是不需要CPU干预的,可以直接从内存中读写数据。DMA的出现,其实是为了解放CPU的,如果让CPU去搬移大量的数据的话,那就比较慢了,而DMA就比较快。CPU要搬移数据的话,假设是从内存A搬移到内存B,它首先要从内存A中把数据搬移到通用寄存器里,然后从通用寄存器里把数据搬移到内存B,另外一点,CPU搬移的过程中有可能被别的事情打断。而DMA就是专职干 内存搬移的,它可以操作总线,直接从内存A搬移数据到内存B,只要DMA开始干活了,就没有人来打扰它了,所以DMA效率上比CPU搬移要快。
要使用DMA,在DMA开始干活之前,需要CPU来做一下配置,告诉DMA怎么搬移数据,从哪里搬到哪里。
DMA的cache一致性产生的原因
那DMA为什么和CPU的cache会产生cache一致性的问题呢,基本的原因的什么呢?我这里总结了几个。
-
DMA直接操作系统总线来读写内存地址,而CPU并不感知。
-
如果DMA修改的内存地址,在CPU的cache中有缓存,那么CPU并
不知道内存数据被修改了,CPU依然去访问cache的旧数据,导致
Cache一致性问题。
DMA的cache一致性解决方案
我们来看一下DMA的cache一致性的解决方案是啥。
第一种方案是,使用硬件cache一致性的方案,需要CCI这种IP的支持。这个需要去查看一下你用的soc是否支持CCI控制器。
第二种方案就是使用non-cacheable的内存来进行DMA传输,这种方案最简单,但效率最低,严重降低性能,还增加功耗。
第三种使用软件主动干预的方法来帮助cache一致性。这个是比较常规的方法,特别是在类似CCI这种缓存一致性控制器没有出来之前,都用这种方式。对于DMA的操作,我们需要考虑两种情况。
从内存到设备FIFO(网卡发包)
Case 1:传输路径为:内存->设备FIFO (设备例如网卡,通过DMA读取内存数据到设备FIFO)
这种场景下,通常都是CPU的软件来产生了新的数据,然后通过DMA数据搬到设备的FIFO里。这个非常类似的网卡设备的发包的过程。
在DMA传输之前,CPU的cache可能缓存了内存数据,需要调用cache clean/flush操作,把cache内容写入到内存中。因为CPU cache里可能缓存了最新的数据。然后再启动DMA区传输数据,把DMA buffer的数据 传输到设备的FIFO。
所以,理解这里为什么要先做cache的clean或者flush操作的一个关键点是:比如这个图里,大家要想清楚,在DMA开始传输之前,最新的数据在哪里?很明显,在这个图里,在这个场景下的逻辑,最新数据有可能还在cache里,因为主机的软件产生数据,比如网卡发包,CPU的网络软件去组包,这个组包的过程,其实可以看成是CPU去create了新的数据,然后CPU把数据存在内存的DMA buffer里,这个过程中,有可能还有新的数据在CPU的cache里。所以,在启动DMA之前,我们需要调用cache的flush操作,把cache的数据回写到DMA buffer里。这个就是这个逻辑。
从设备FIFO到系统内存(网卡收包)
Case 2:设备FIFO -> 内存 (设备把数据 写入到 内存中)
在这个场景下,设备的FIFO产生了新数据,需要把数据写入到DMA buffer里,然后主机的软件就可以读到设备的数据,这个非常类似网卡的收包的过程。
在DMA传输之前,我们先来看一下 最新的数据在哪里?很显然在这个场景下,最新的数据是在设备的FIFO里。那我们再来看第二个问题,CPU的cache的数据,是否有用?因为最新的数据在设备的FIFO里,这个场景下就是要把FIFO的数据写入到内存的DMA buffer里,那cache的数据显然是无用过时的,所以要把他invalidate掉。因此,在DMA启动之前,需要把cache 做invalid操作。
DMA小结
所以,有不少小伙伴,搞了好多年驱动了,依然对DMA和cache的一致性搞不清楚,我的建议是,想清楚两个问题:
-
在启动DMA之前,最新的数据源在哪里?是在CPU那侧还是设备那侧?
-
在启动DMA之前,cache保存的数据 是最新的还是 过时的?
把这两个问题想清楚了,就能知道是要clean cache还是要invalidate cache里。
cache案例3:self-modifying code
下面我们来看案例3,self-modifying code的案例。
一般情况下,指令cache和数据cache是分开的。指令cache一般只读。指令通常不能修改,但是某些特殊情况指令存在被修改的情况,比如self-modifying code。自修改代码是一种代码,当代码执行时修改它自身的指令。使用自修改代码一般有几个用途:
-
防止被破解。隐藏重要代码,防止反编译
-
Gdb调试的时候,也会采用这个自修改代码的方式,来动态修改程序。
self-modifying code,在执行过程中修改自己的指令,过程如下。
-
把要修改的指令,加载到数据cache里。
-
程序(CPU)修改新指令,数据cache里缓存了最新的指令。
-
但是CPU依然从指令cache里取指令来执行。
问题:
-
指令cache依然缓存了 旧的指令。
-
新指令还在数据cache里。
self-modifying code解决办法:
解决的思路是,使用cache管理指令和内存屏障指令来保证数据cache和指令cache的一致性。
在armv8手册里,有一段简单的英文来介绍self-modifying code,它是在Armv8.6手册最后面术语一章里面。在这段简单的英文里,我们可以得到重要的信息,就是解决self-modifying code的指令cache和数据cache不一致的问题,需要使用cache管理指令和内存屏障指令来手动干预。
解决思路:
-
使用cache clean操作,把cache line的数据写回到内存。
-
使用DSB指令保证其他观察者看到clean操作已经完成
-
无效指令cache
-
使用DSB指令确保其他观察者看到无效操作已经完成
-
ISB指令让程序重新预取指令
在armv8.6手册里,有一段描述这个过程的,它是在第D2.4.4章里。