【Linux 内核】CPU高速缓存行及MESI 协议

整理:

内存屏障今生之Store Buffer, Invalid Queue_wll1228的博客-CSDN博客

hwViewForSwHackers.pdf (puppetmastertrading.com)

目录

hwViewForSwHackers.pdf (puppetmastertrading.com)

概述

高速缓存

1、L1 8k 缓存映射

2、高速缓存的CPU执行计算的流程

3、缓存行对性能影响举例

MESI

1、监听

2、MESI协议缓存状态

3、MESI状态转换

MESI优化和他们引入的问题

1、Store Buffers

(1)Store Buffers问题一:

(2)Store Buffers 问题二:

2、Store queues result in unecessary Stalls

3、内存屏障

总结

附:CPU Cache 是如何存放数据的

你会怎么设计Cache的存放规则

为什么Cache不能做成Fully Associative

为什么Cache不能做成Direct Mapped

什么是N-Way Set Associative

Cache淘汰策略


概述

由于内存的运行速度和CPU的运行速度相差太多,所以现代计算机CPU都不是直接操作内存,而是直接操作寄存器和高速缓存,如果只有一个CPU这个事情就很简单,但是如果计算机中有多个核,那每个CPU都从主内存中读取了同一个变量,如何保证缓存的一致性,就变得非常麻烦,现在常用的解决办法有两种。

  1. 总线锁定:当某个CPU需要修改某个数据的时候,通过锁住内存总线,使得别的CPU无法访问内存中的数据,从而保证缓存的一致性,但这种实现方式会导致CPU执行效率降低,现在很少被使用。
  2. 缓存锁:当一个CPU要修改缓存中的变量时,会对缓存加锁,同时会通过总线通知别的CPU,让他们的变量副本失效,这样同样可以保证一次只有一个CPU修改变量的值,从而保证缓存一致性,原子变量通过锁缓存实现

以上两种方法的实质作用都是为了防止读取到脏数据和更新的结果无效。

高速缓存

其中tag用来定位cache entry,data block用来保存缓存的数据,flag就是重点了,这个标识就是用来标注当前节点的状态,对应下面要介绍的MESI协议的四种状态,分别位M,E,S,I。

链表中节点名称叫做cache entry,下面看一下cache entry的结构: 

在CPU缓存中最小的存储单元称为缓存行(cache line),一般大小为64B。我们需要在缓存行中MESI的4种状态,所以一个缓存行中,都只要有2个Bit位去存储该状态(下图flag标志位,tag用来定位缓存行位置)。

1、L1 8k 缓存映射

以L1 8k介绍缓存映射规则,摘自hwViewForSwHackers.pdf (puppetmastertrading.com)

L1 缓存的管理通过CPU Structure 管理,管理结构如下:

表格术语叫16-sets-2-ways,每way表示一个cache line(假设cache line大小256Byte),

总缓存大小 = 16*2*256 = 8k,这文档较早内存地址以4字节为例。

  1. 0x1 - 0xF:L1 缓存的连续编号,共16个,每个对应的缓存大小为2way * 256B = 512B;
  2. way 0、way1:存储L1已缓存的首地址;

简而言之类似于软件的hash bucket,共16个buckets,每bucket 最多存放2个cache line的起始地址。

对内存地址的hash算法很简单,直接取8-12bit的值,对应0x0 - 0xF。

举例:

  1. 假如cpu访问0x12345F08地址,hash结果为0xF,cpu查询cache structure 中0xF中没有缓存,那么将0x12345F08 & 0xFFFFFF00 结果存入way0,并将内存地址0x12345F00 - 0x12345FFF的数据存放在L1 对应的cache line中;
  2. 假如访问0x3452100D,hash 结果为0x0,0x0中way0已存在数据,将0x3452100D & xFFFFFF00的结果存入way1;
  3. 假如访问0x34521E0D,hash结果为0xE,由于0xE的2way都已存放数据并且不是0x34521E00,那么此时会从0xE中挑选一个way的地址换出存放0x34521E00,并且将换出的地址的cache line数据替换,这种miss术语是associativity miss,如果所有way 都存放了地址,术语是capacity miss,如果是接收了MESI的I消息,术语是communication miss;
  4. 如果cpu访问0x43210E8A地址,hash结果为0xE,0x43210E8A & 0xFFFFFF00 = 0x43210E00,对应way1,表明0x43210E00 - 0x43210EFF地址的内存已缓存在0xE 的缓存中,cpu直接访问[ 0xE*512(缓存偏移) + 256(way0偏移) + 8A(地址偏移)],即可取出数据。

下面附中有一些缓存设计的思路

2、高速缓存的CPU执行计算的流程

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU的高速缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

单核CPU:

 多核CPU:

大致关系: CPU Cache --> 前端总线 FSB (上图中的Memory bus) --> Memory 内存

CPU 为了更快的执行代码。于是当从内存中读取数据时,并不是只读自己想要的部分。而是读取足够的字节来填入高速缓存行。根据不同的 CPU ,高速缓存行大小不同。如 X86 是 32BYTES ,而 ALPHA 是 64BYTES 。并且始终在第 32 个字节或第 64 个字节处对齐。这样,当 CPU 访问相邻的数据时,就不必每次都从内存中读取,提高了速度。 因为访问内存要比访问高速缓存用的时间多得多。

下面一张图可以看出各级缓存之间的响应时间差距,以及内存到底有多慢!

前端总线(FSB)就是负责将CPU连接到内存的一座桥,前端总线频率则直接影响CPU与内存数据交换速度,如果FSB频率越高,说明这座桥越宽,可以同时通过的车辆越多,这样CPU处理的速度就更快。目前PC机上CPU前端总线频率有533MHz、800MHz、1066MHz、1333MHz、1600MHz等几种,前端总线频率越高,CPU与内存之间的数据传输量越大。
前端总线——Front Side Bus(FSB),是将CPU连接到北桥芯片的总线。选购主板和CPU时,要注意两者搭配问题,一般来说,前端总线是由CPU决定的,如果主板不支持CPU所需要的前端总线,系统就无法工作 。

3、缓存行对性能影响举例

我们来看下面这个C语言中常用的循环优化例子
下面两段代码中,第一段代码在C语言中总是比第二段代码的执行速度要快(相差几百倍的关系)。

缓存行的换入换出是以64B为单位,如果频繁访问的数据分布在多个缓存行,该缓存行可能同时存储多个线程的数据,频繁换入换出势必会导致多个线程并发而出现缓存行抖动。

for(int i = 0; i < n; i++) {
    for(int j = 0; j < n; j++) {
        int num;   
        arr[i][j] = num;
    }
}
for(int i = 0; i < n; i++) {
    for(int j = 0; j < n; j++) {
        int   num;       
        arr[j][i] = num;
    }
}

多CPU内存访问框架的问题,试想下面这样一个情况。

  1. CPU1 读取了一个字节,以及它和它相邻的字节被读入 CPU1 的高速缓存。
  2. CPU2 做了上面同样的工作。这样 CPU1 , CPU2 的高速缓存拥有同样的数据。
  3. CPU1 修改了那个字节,被修改后,那个字节被放回 CPU1 的高速缓存行。但是该信息并没有被写入 RAM 。
  4. CPU2 访问该字节,但由于 CPU1 并未将数据写入 RAM ,导致了数据不同步。

MESI

方案类似于读写锁的方式,使得针对同一地址的读内存操作是并发的,而针对同一地址的写内存操作是独占的。(转载博客中描述通过锁缓存行实现MESI不太赞同,MESI应该是基于缓存行的原子操作,网上没找到MESI的标准协议原文,暂时这样推断)

MESI 详细流程参考hwViewForSwHackers.pdf (puppetmastertrading.com)

1、监听

上面我们可以得知当有一个核去修改了自己的缓存行,需要同步到其他的核并更新他们的状态。所以说在MESI中每个cache控制器,不仅需要知道自己的操作,还会监听其他的cache的操作

我们把CPU各个内核对缓存的操作可以总结为4种操作:

  1. local read:CPU内核读取自己的本地缓存
  2. local write:CPU内核写入自己的本地缓存
  3. remote read:其他的CPU内核读取了DRAM中当前内核的缓存行
  4. remote write:其他的CPU内核写入了DRAM中当前内核的缓存行

CPU内核中的缓存会监听这些事件来修改自己缓存的缓存行中的Flag标志位。然后通过该标志位来决定CPU如何处理这个缓存数据。

2、MESI协议缓存状态

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。缓存行必须时刻监听所有试图读该缓存行相对应主存的操作,这种读操作必须在该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid)该Cache line无效。

每个CPU对缓存状态的修改会通过通知->应答(部分需要应答)机制通知各CPU。

举个例子:

  1. 只有cpu1读取了数据到cache1,此时cache1状态为E状态;
  2. cpu2读取cache1,cpu2通知各cpu后,cpu1将cache1置为S状态并应答,同时cpu2的状态置为S状态;
  3. cpu1修改了数据,将消息通知cpu2,cpu2将其cache1置位 I (无效)状态;cpu1将cache1置位为M状态;
  4. 此时如果cpu2再次访问数据,由于cache1的I状态,那么cpu2发消息到cpu1,通知cpu1将数据写会内存,cpu1将状态置E并将数据写会内存,此时cpu2将数据重新读取到cache1中;同理如果cpu3读取cache1,由于cpu1的状态是M的话,cpu1页会将cache1写回内存;

3、MESI状态转换

状态机的详细解释:

状态触发本地读取触发本地写入触发远端读取触发远端写入
M状态(修改)本地cache:M 
触发cache:M
其他cache:I
本地cache:M 
触发cache:M
其他cache:I
本地cache:M→E→S
触发cache:I→S
其他cache:I→S
同步主内存后修改为E独享,同步触发、其他cache后本地、触发、其他cache修改为S共享
本地cache:M→E→S→I
触发cache:I→S→E→M
其他cache:I→S→I
同步和读取一样,同步完成后触发cache改为M,本地、其他cache改为I
E状态(独享)本地cache:E
触发cache:E
其他cache:I
本地cache:E→M
触发cache:E→M
其他cache:I
本地cache变更为M,其他cache状态应当是I(无效)
本地cache:E→S
触发cache:I→S
其他cache:I→S
当其他cache要读取该数据时,其他、触发、本地cache都被设置为S(共享)
本地cache:E→S→I
触发cache:I→S→E→M
其他cache:I→S→I
当触发cache修改本地cache独享数据时时,将本地、触发、其他cache修改为S共享.然后触发cache修改为独享,其他、本地cache修改为I(无效),触发cache再修改为M
S状态(共享)本地cache:S
触发cache:S
其他cache:S
本地cache:S→E→M
触发cache:S→E→M
其他cache:S→I 
当本地cache修改时,将本地cache修改为E,其他cache修改为I,然后再将本地cache为M状态
本地cache:S
触发cache:S
其他cache:S
本地cache:S→I
触发cache:S→E→M
其他cache:S→I
当触发cache要修改本地共享数据时,触发cache修改为E(独享),本地、其他cache修改为I(无效),触发cache再次修改为M(修改)
I状态(无效)本地cache:I→S或者I→E
触发cache:I→S或者I →E
其他cache:E、M、I→S、I
本地、触发cache将从I无效修改为S共享或者E独享,其他cache将从E、M、I 变为S或者I
本地cache:I→S→E→M
触发cache:I→S→E→M
其他cache:M、E、S→S→I
既然是本cache是I,其他cache操作与它无关既然是本cache是I,其他cache操作与它无关

下图示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。

MESI
M×××
E×××
S××
I

简洁的解释:我们这里用CPU的两个核CA(coreA)和CB(coreB)以及缓存行数据 X 来解释下上面图片的转变,当CA中存在X:

状态是M(修改):此时只有CA内部有X,并且X和RAM的X值是不一致的。

事件行为下一个状态
local read直接从CA的cache中读,状态不发生改变M
local write直接修改CA的cache数据,状态不变M
remote readCB需要最新数据,将CA的X值写回到RAM中
CB从RAM再读取X,CA和CB的缓存行标志为设置为S
S
remote write先将CA的X值写回到RAM中
CB读取X并修改,CA的状态变为I,CB的状态变为M
I

状态是S(共享):此时CA和CB都有X,且和RAM的值都一致。

事件行为下一个状态
local read直接从CA的cache中读,状态不发生改变S
local writeCA直接修改cache,状态变为M。CB变为IM
remote readCB读的和CA的一样的数据,状态不变S
remote writeCB对应成了上面CA的local write,CB的cache变为M,CA的变为II

状态是E(独占):此时只有CA有X,且X和RAM的X值一致(值一致代表着不需要写回RAM)

事件行为下一个状态
local read直接从CA的cache中读,状态不发生改变E
local write直接修改CA的cache,状态变为MM
remote readCB发送读事件,CA和CB需要共享X,所以状态都变为SS
remote writeCA将X置为II

状态是I(失效):需要依据CB是否有X,以及响应的状态来决定操作。

事件行为下一个状态
local read如果CB没有X,则CA读取X,状态为E
如果CB有X,状态为M,则CB需要写回到RAM,然后CA读取,状态变为S
如果CB有X,状态为S或者E,那么CA直接读,CA和CB都变为S
E or S
local writeCA需要从RAM拉取数据
如果CB没有X,那么CA就直接拉取,修改后设置为M
如果CB有X,状态为M,那么CB需要先写回RAM,然后CA读取最新到cache,修改并设置为M,CB变为I
如果CB有X,状态为S或者E,那么CA读取并设置为M,CB为I
M
remote read已经失效,只和自己读写有关,和其他的读写无关,状态不变。I
remote write已经失效,只和自己读写有关,和其他的读写无关,状态不变。I

上面写的可能复杂,但是其实原理很简单!只要了解了原理,你可以自己对着这些状态就能演算出来,主要抓住几个规则:

  1. 当有核心读的时候,需要关注其他核心的状态是否有M的,有M的必须要先让M的写入到RAM,再读最新数据。即:当read操作需要到RAM中取时,整个CPU层面不能有该缓存行状态为M的,有的必须让其先写回到RAM中。
  2. 当有核心写的时候,涉及会比较多,但是核心就是:写的时候CPU层面不能有任何其他核心处于M状态,有就需要将其先写回RAM,拿到最新的数据修改,修改完成,该核心以外的核都变为失效。
  3. 所有的这些操作都是为保证:任何核心的修改不能被覆盖!任何核心的读取,都需要拿到当前CPU缓存层面最新的值!
  4. 上面的状态很多都是相互的对应案例,比如CA的remote read,对应这CB的local read。
  5. 本端M,远端读的情况,远端发read invalidate消息后需要等待,本端将数据写入到主存后,本端缓存中的值回复给远端。但是本端和远端都是S状态或者本端是E状态,读取只从缓存中读取。
  6. 对于M状态和E状态的缓存,本端写入会直接写入到主存中,对于S状态需要等待对方回复invalidate ack后再修改缓存行状态写入主存。

MESI优化和他们引入的问题

缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题,所以引入store buffers模块。需要写入缓存的数据先入队到store buffers中,之后立刻发送invalidate消息通知其他CPU。store buffers中的数据只有收到其他CPU回复的acknowledge消息才会写入缓存。

1、Store Buffers

(1)Store Buffers问题一:

比如你需要修改本地缓存中的一条共享(S)信息,那么你必须将I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。因为这个等待远远比一个指令的执行时间长的多

为了避免这种CPU运算能力的浪费,Store Bufferes被引入使用。处理器把它想要写入到主存的值写到Bufferes缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交。

以这段代码为例,摘自hwViewForSwHackers.pdf (puppetmastertrading.com):

 假设a,b全局变量,并且初始化为0,cpu1 cache line缓存a,cpu0 cache line缓存b.

  1. cpu0 开始执行a=1;
  2. cpu0发现a cache line miss,加载缓存,并将cache line置为S;
  3. cpu0 发送“read invalidate”消息,为了获取包含a的cache line 的Exclusive权限;
  4. cpu0 将a=1记录在store buffer中;
  5. cpu1接收到“read invalidate”消息,将包含a从cache line清除(注意这里清楚的整个cache line),并回复带有a=0的ack消息,注意这里cpu1的处理顺序;
  6. cpu0执行b=a+1;
  7. cpu0接收到cpu1发来的带有a=0的ack消息;
  8. cpu0从自己的缓存中加载出a=0;
  9. cpu0从store队列中取出a=1的条目,并将本地缓存修改为1;
  10. cpu0将步骤8从本机缓存获取的a=0,加1后直接将b=1写入到缓存中;
  11. cpu0开始执行assert,failed;

上述流程问题出现在本地a=1在store buffer中,而b=a+1 获取的a是从缓存中获取,通过直接用内存屏障将store buffer全部写回内存,这显然更耗费cpu。

对于这种漏洞可以从硬件上解决,办法是处理器会尝试从存储缓存(Store buffer)中读取值a。这个的解决方案称为Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回。

问题:为什么第2行和第3行的b变量并没有出问题?

因为b是cpu0独享(E)cpu0不需要发送invalidate消息给其他cpu,所以不需要将b入队到store buffer中;

至此硬件结构发生变化:

 

(2)Store Buffers 问题二:

上诉Store Forwarding 还存在问题:

 

假设全局变量a、b为0,cpu0执行foo(),cpu执行bar() ,进一步假设cpu0缓存了b,cpu1缓存了a。

  1. cpu0执行a=1,缓存miss,cpu0将a=1入队store buffer,发送“read invalidate”消息;(这里为什么不是invalidate消息,read invalidate消息包换两个操作,一个read一个invalidate操作,文档中的解释:Read Invalidate: The “read invalidate” message contains the physical address of the cache line to be read, while at the same time directing other caches to remove the data. Hence, it is a combination of a “read” and an “invalidate”, as indicated by its name. A “read invalidate” message requires both a “read response” and a set of “invalidate acknowledge” messages in reply)
  2. cpu1执行while(b == 0) continure,缓存miss,发送“read”消息;
  3. cpu0执行b=1,将b==1直接写入到缓存,此时cpu1发送b的“read”消息还没到达;
  4. cpu0 收到cpu1发来的b “read”消息,cpu 0回复read response消息,消息中携带缓存b的新值1,并将cache line修改为S状态;
  5. CPU1 收到b 的read response消息将b==1直接写入到cpu1的缓存中;
  6. cpu1发现b=1,继续执行下一条语句;
  7. cpu1执行assert(a == 1),这是因为cpu1 执行用的a的old 值(注意此时还没有收到cpu0发来的a “read invalidate”消息),assert 失败;
  8. cpu1收到cpu0发的a “read invalidate”消息,将cpu1 缓存中a的old value 回复给cpu0,将cache line移除,此时太晚了;
  9. cpu0 接收到cpu1回复的a “read”消息,及时将a=1存入到缓存中,但已经于事无补;

这个问题出现在cpu1并不知道cpu0已经将a修改成1,a=1存在cpu0的store buffer中,仅从功能上看,貌似foo()发生假指令重排:

指令重排:分为两种类型,一种是「主动的」,编译器会主动重排代码使得特定的cpu执行更快。另外一个类型是「被动的」,为了异步化指令的执行,引入Store Buffer和Invalidate Queue,却导致了「指令顺序改变」的副作用。

cpu为了优化指令的执行效率,引入了store buffer(forwarding),而又因此导致了指令执行顺序的变化。要保证这种顺序一致性,靠硬件是优化不了了,需要在软件层面支持,cpu提供了写屏障(write memory barrier)指令,Linux操作系统将写屏障指令封装成了smp_wmb()函数,cpu执行smp_mb()的思路是,会先把当前store buffer中的数据刷到cache之后,再执行屏障后的“写入操作”,该思路有两种实现方式: 一是简单地停止等待store buffers 空后再入store buffers,二是将当前store buffer中的条目打标,然后将屏障后的“写入操作”也写到store buffer中,cpu继续干其他的事,当被打标的条目全部刷到cache line,之后再刷后面的条目(注意此时未打标记的entry才会发送invalidate消息),以第二种实现逻辑为例,我们看看以下代码执行过程:

  1. cpu0执行a=1,缓存miss,cpu0将a=1入队store buffer,发送“read invalidate”消息;
  2. cpu1执行while(b == 0) continure,缓存miss,发送“read”消息;
  3. cpu0执行smp_mp(),标记所有store buffer中的条目(如a=1);
  4. cpu0执行b=1,但是在store buffer中有标记的条目,虽然b是cpu0独享的缓存,但是由于sotre buffer中标记的条目,将b=1入队到store buffer中(b=1未标记);
  5. cpu0收到cpu1发的b "read"消息,将cpu0原始缓存中b的值0回复cpu1(注意这种mesi消息的读取并没有遵循store Forwarding从store buffer读取数据),并且将改缓存行修改为S状态,并且将store buffer中的copy 置位shared状态;
  6. cpu1收到b 的“read response”消息,将其存入缓存中;
  7. cpu1现在可以执行while(b==0) continue,但是由于缓存中b的值为0,因为b=1在cpu0的store buffer中,所以继续循环;
  8. cpu1收到cpu0 a的“read invalidate”消息,回复携带a=0的消息,并将cache line移除;
  9. cpu0收到步骤8的消息,运行store buffers;
  10. 由于a=1是唯一被smp_wmb()标记的条目,store buffer中b=1的条目可以被执行;
  11. b由于未打标记并且缓存行是S状态,所以cpu0 发送一个b“invalidate”消息到cpu1;
  12. cpu1收到步骤11的消息,将包含b的cache line移除,并回复“acknowledgement”消息;
  13. cpu1继续执行while(b==0) continue,发现缓存miss,因此发送b “read”消息到cpu0;
  14. cpu0收到步骤12的消息,将包含b的cache line修改为E独享状态,cpu0现在可以将b=1存入到缓存中;
  15. cpu0收到步骤13的消息,此时cpu0的b cache line为独享状态,cpu0发送写到b=1的“read response”消息,将b 的cache line修改为S状态;
  16. cpu1收到步骤15的消息,将b=1存入缓存中;
  17. cpu1可以执行完while(b==0) continue,继续执行下一条语句;
  18. cpu1执行assert,但是缓存miss,一旦它从cpu0获取a的值,a的assert可以生效;

至此cpu1只要等到cpu 0 store buffer中marked的条目执行完即可。

由此在编码时SMP 两个全局变量有相互依赖关系,需要内存屏障,为了达到功能稳定只能牺牲硬件的一部分性能。

2、Store queues result in unecessary Stalls

引入了store buffer,再辅以store forwarding、写屏障,看起来好像可以自洽了,然而还有一个问题没有考虑: store buffer的大小是有限的,所有的写入操作发生cache missing(数据不再本地)都会使用store buffer,特别是出现内存屏障时,后续的所有写入操作(不管是否cache missing)都会挤压在store buffer中(直到store buffer中屏障前的条目处理完),因此store buffer很容易会满,当store buffer满了之后,cpu还是会卡在等对应的Invalidate ACK以处理store buffer中的条目。因此还是要回到Invalidate ACK中来,Invalidate ACK耗时的主要原因是cpu要先将对应的cache line置为Invalid后再返回Invalidate ACK,一个很忙的cpu可能会导致其它cpu都在等它回Invalidate ACK。解决思路还是化同步为异步: cpu不必要处理了cache line之后才回Invalidate ACK,而是可以先将Invalid消息放到某个请求队列Invalid Queue,然后就返回Invalidate ACK。CPU可以后续再处理Invalid Queue中的消息,大幅度降低Invalidate ACK响应时间。但是如果已经在invalidate 队里里的消息,再次收到invalidate信息那么此次必须要等待队里的invalidate处理完成后才恢复ackonwledge。此时的CPU Cache结构图如下:

加入invalidate queue后还会存在问题,如下代码:

假设cpu0、cpu1共享a,cpu0独享b,

不再做详细的流程分析,可参考原文档,问题出现在cpu1收到a=1的“invalidate”消息,将缓存无效放入invalidate queue后回复了acknowledge消息,cpu0会继续执行代码到b=1,此时cpu1跳过while循环,而a是cpu0和cpu1共享的数据,cpu1可以直接从缓存中拿到old value 0,导致触发不了断言。简而言之cpu1的invalidate queue的a的缓存行无效动作还没执行,开始执行了assert代码。

由上优化的办法就是让cpu1能优先处理invalidate queue的东西,再执行接下来的代码,修改代码如下:

smp_mb(),是满内存屏蔽,他有两个动作,一个清空store buffers再继续向下执行代码;一是清空invalidate queue后再向下执行。这样看上述代码明显耗费了cpu性能,我们需要区分出写内存屏障和读内存屏障。 

3、内存屏障

内存屏障分为满内存屏障smp_mb()、写内存屏障smp_wmb()、读内存屏障smp_rmb()。

  1. smp_mb(),如上描述;
  2. smp_wmb(),只清空store buffers中的条目;
  3. smp_rmb(),只清空invalidate queue中的条目。

内存屏障不通架构cpu支持程度不同,但在Linux内核中已大部分做了兼容,在对代码进行性能优化时尽量减少内存屏障的使用,从代码结构上优化。

总结

回到开始,MESI是基于什么实现缓存一致?基于对缓存的原子操作,包括从缓存回写到内存。MESI实现的目标之一是当有cpu写缓存时其他cpu可以立即感知并且可以使用最新的数据,如cpu0写cpu1读,cpu0发“invalidate”消息通知cpu1,只有cpu1对缓存的操作是原子的才能保证接下来cpu1使用的是最新的数据。

结合以上硬件结构,假设cpu0和cpu1共享数据a,cpu1执行a=1,cpu2执行a=2,会出现什么情况?两者同时将a入store buffers中,同时发“invalidate”消息,同时入invalidate queue中,同时回复acknowledge消息,同时将a写入缓存中,此时cpu0和cpu1的invalidate queue中的之前的无效条目执行先后顺序不确定,这就导致两者将缓存写回内存的顺序不确定,导致最终内存中的数据不确定,这是atomic存在的作用,原子变量锁的粒度是到内存终止。

附:CPU Cache 是如何存放数据的

你会怎么设计Cache的存放规则

我们先来尝试回答一下那么这个问题:

假设我们有一块4MB的区域用于缓存,每个缓存对象的唯一标识是它所在的物理内存地址。每个缓存对象大小是64Bytes,所有可以被缓存对象的大小总和(即物理内存总大小)为4GB。那么我们该如何设计这个缓存?

如果你和博主一样是一个大学没有好好学习基础/数字电路的人的话,会觉得最靠谱的的一种方式就是:Hash表。把Cache设计成一个Hash数组。内存地址的Hash值作为数组的Index,缓存对象的值作为数组的Value。每次存取时,都把地址做一次Hash然后找到Cache中对应的位置操作即可。
这样的设计方式在高等语言中很常见,也显然很高效。因为Hash值得计算虽然耗时(10000个CPU Cycle左右),但是相比程序中其他操作(上百万的CPU Cycle)来说可以忽略不计。而对于CPU Cache来说,本来其设计目标就是在几十CPU Cycle内获取到数据。如果访问效率是百万Cycle这个等级的话,还不如到Memory直接获取数据。当然,更重要的原因是在硬件上要实现Memory Address Hash的功能在成本上是非常高的。

为什么Cache不能做成Fully Associative

Fully Associative 字面意思是全关联。在CPU Cache中的含义是:如果在一个Cache集内,任何一个内存地址的数据可以被缓存在任何一个Cache Line里,那么我们成这个cache是Fully Associative。从定义中我们可以得出这样的结论:给到一个内存地址,要知道他是否存在于Cache中,需要遍历所有Cache Line并比较缓存内容的内存地址。而Cache的本意就是为了在尽可能少得CPU Cycle内取到数据。那么想要设计一个快速的Fully Associative的Cache几乎是不可能的。

为什么Cache不能做成Direct Mapped

和Fully Associative完全相反,使用Direct Mapped模式的Cache给定一个内存地址,就唯一确定了一条Cache Line。设计复杂度低且速度快。那么为什么Cache不使用这种模式呢?让我们来想象这么一种情况:一个拥有1M L2 Cache的32位CPU,每条Cache Line的大小为64Bytes。那么整个L2Cache被划为了1M/64=16384条Cache Line。我们为每条Cache Line从0开始编上号。同时32位CPU所能管理的内存地址范围是2^32=4G,那么Direct Mapped模式下,内存也被划为4G/16384=256K的小份。也就是说每256K的内存地址共享一条Cache Line。

但是,这种模式下每条Cache Line的使用率如果要做到接*100%,就需要操作系统对于内存的分配和访问在地址上也是*乎*均的。而与我们的意愿相反,为了减少内存碎片和实现便捷,操作系统更多的是连续集中的使用内存。这样会出现的情况就是0-1000号这样的低编号Cache Line由于内存经常被分配并使用,而16000号以上的Cache Line由于内存鲜有进程访问,几乎一直处于空闲状态。这种情况下,本来就宝贵的1M二级CPU缓存,使用率也许50%都无法达到。

什么是N-Way Set Associative

为了避免以上两种设计模式的缺陷,N-Way Set Associative缓存就出现了。他的原理是把一个缓存按照N个Cache Line作为一组(set),缓存按组划为等分。这样一个64位系统的内存地址在4MB二级缓存中就划成了三个部分(见下图),低位6个bit表示在Cache Line中的偏移量,中间12bit表示Cache组号(set index),剩余的高位46bit就是内存地址的唯一id。这样的设计相较前两种设计有以下两点好处:

  • 给定一个内存地址可以唯一对应一个set,对于set中只需遍历16个元素就可以确定对象是否在缓存中(Full Associative中比较次数随内存大小线性增加)
  • 每2^18(256K)*64=16M的连续热点数据才会导致一个set内的conflict(Direct Mapped中512K的连续热点数据就会出现conflict)

为什么N-Way Set Associative的Set段是从低位而不是高位开始的

下面是一段从How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses摘录的解释:

The vast majority of accesses are close together, so moving the set index bits upwards would cause more conflict misses. You might be able to get away with a hash function that isn’t simply the least significant bits, but most proposed schemes hurt about as much as they help while adding extra complexity.

由于内存的访问通常是大片连续的,或者是因为在同一程序中而导致地址接*的(即这些内存地址的高位都是一样的)。所以如果把内存地址的高位作为set index的话,那么短时间的大量内存访问都会因为set index相同而落在同一个set index中,从而导致cache conflicts使得L2, L3 Cache的命中率低下,影响程序的整体执行效率。

了解N-Way Set Associative的存储模式对我们有什么帮助

了解N-Way Set的概念后,我们不难得出以下结论:2^(6Bits <Cache Line Offset> + 12Bits <Set Index>) = 2^18 = 512K。即在连续的内存地址中每512K都会出现一个处于同一个Cache Set中的缓存对象。也就是说这些对象都会争抢一个仅有16个空位的缓存池(16-Way Set)。而如果我们在程序中又使用了所谓优化神器的“内存对齐”的时候,这种争抢就会越发增多。效率上的损失也会变得非常明显。具体的实际测试我们可以参考: How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses 一文。

这里我们引用一张Gallery of Processor Cache Effects 中的测试结果图,来解释下内存对齐在极端情况下带来的性能损失。

该图实际上是我们上文中第一个测试的一个变种。纵轴表示了测试对象数组的大小。横轴表示了每次数组元素访问之间的index间隔。而图中的颜色表示了响应时间的长短,蓝色越明显的部分表示响应时间越长。从这个图我们可以得到很多结论。当然这里我们只对内存带来的性能损失感兴趣。有兴趣的读者也可以阅读原文分析理解其他从图中可以得到的结论。

从图中我们不难看出图中每1024个步进,即每1024*4即4096Bytes,都有一条特别明显的蓝色竖线。也就是说,只要我们按照4K的步进去访问内存(内存根据4K对齐),无论热点数据多大它的实际效率都是非常低的!按照我们上文的分析,如果4KB的内存对齐,那么一个80MB的数组就含有20480个可以被访问到的数组元素;而对于一个每512K就会有set冲突的16Way二级缓存,总共有512K/20480=25个元素要去争抢16个空位。那么缓存命中率只有64%,自然效率也就低了。

想要知道更多关于内存地址对齐在目前的这种CPU-Cache的架构下会出现的问题可以详细阅读以下两篇文章:

  • How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses
  • Gallery of Processor Cache Effects

Cache淘汰策略

在文章的最后我们顺带提一下CPU Cache的淘汰策略。常见的淘汰策略主要有LRU和Random两种。通常意义下LRU对于Cache的命中率会比Random更好,所以CPU Cache的淘汰策略选择的是LRU。当然也有些实验显示在Cache Size较大的时候Random策略会有更高的命中率

缓存刷会主存时机:

摘自:什么时候CPU的缓存冲回主内存?_culun797375的博客-CSDN博客

o answer the question in your post’s title, it depends on what the caching protocol is. If it is write-back, the cache will only be flushed back to main memory when the cache controller has no choice but to put a new cache block in already occupied space. The block that previously occupied the space is removed and its value is written back to main memory.

要回答帖子标题中的问题,这取决于什么是缓存协议。 如果是回写,则仅当缓存控制器别无选择,只能将新的缓存块放入已占用的空间时,才将缓存刷新回主内存。 先前占用该空间的块将被删除,并将其值写回到主存储器。

The other protocol is write-through. In that case, anytime the cache block is written on level n, the corresponding block on level n+1 is updated. It is similar in concept to filling out a form with carbon paper underneath; whatever you write on top is copied on the sheet below. This is slower because it obviously involves more writing operations, but the values between caches are more consistent. In the write-back scheme, only the highest level cache would have the most up-to-date value for a particular memory block.

另一种协议是直写。 在那种情况下,无论何时将缓存块写入第n级,都会更新第n + 1级的相应块。 这在概念上类似于在下面用复写纸填写表格。 您在顶部写的所有内容都会复制到下面的工作表中。 这比较慢,因为它显然涉及更多的写入操作,但是缓存之间的值更一致。 在回写方案中,只有最高级别的缓存才具有特定存储块的最新值。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值