学习前问题整理
- CPU L1, L2, L3 cache、主(内)存,它们是如何组成一个完整的工作体的(CPU缓存架构)
- 当待处理的数据处于不同的层次时,指令执行的时间大致是多少
- 缓存行,及其对程序运行时间的影响
- 缓存读、写策略有哪些,CPU缓存映射方式有哪几种
- 缓存一致性协议 —— MESI
- 缓存一致性协议能否保证并发编程中的可见性(Visibility)和有序性(Ordering)
参考资料
L1,L2,L3 Cache究竟在哪里? —— 知乎 老狼
Cache是怎么组织和工作的? —— 知乎 老狼
存储器 - 高速缓存(CPU Cache):为什么要使用高速缓存? —— 博客园 binarylei
使用 sysctl 命令查看苹果笔记本macOS系统CPU等硬件详细信息 —— CSDN 郝伟老师(安徽理工大学)
详解高速缓存存储器的3种映射方式 —— 简书 Leon_Geo
CPU Cache 机制以及 Cache miss —— 博客园 JokerJason
高并发编程--多处理器编程中的一致性问题(上) —— 知乎 三四
MESI协议 —— 维基百科
正文
1. 什么是Cache
从广义的角度来看,Cache是快设备为了缓解访问慢设备延时的预留Buffer,从而可以在掩盖访问延时的同时,尽可能地提高数据传输率。
2. CPU多级缓存架构
在最初的PC-AT/XT和286时代是没有Cache的,那时的CPU和内存都很慢,CPU直接访问内存。
但CPU处理速度的提升远远超过内存访问速度的提升(根据摩尔定律,CPU的访问速度每18个月翻一番,而内存访问速度每年只增长7%左右),为了弥补两者之间逐渐产生的性能差距,充分利用CPU,现代CPU中引入了高速缓存(CPU Cache)。如下图(具体架构由硬件实现为准,下图为一种简化的示例):
其中,L3 Cache多核共享,L2, L1 Cache单核独享,Store Buffer单核独享。其中,L1 Cache分为两块:L1-I(指令), L1-D(数据)。(根据不同的架构实现,还可能存在Load Buffer, Invalidate queue)
CPU访问速率从上到下依次递减,访问寄存器和(Store/Load) Buffers最快,访问内存最慢,两者速率相差过百倍。(磁盘寻道时间更长,1次磁盘寻道时间在10ms左右,耗时约为内存访问的10^5倍)
程序运行的时间主要花在将对应的数据从内存中读取出来,加载到CPU Cache中。CPU从内存中读取数据至CPU Cache,是一小块一小块进行的,这样的一小块,被称作缓存行(Cache Line)。常见的缓存行大小为64字节。
上图为"sysctl hw"命令显示出的mac cpu相关信息,cache size单位为B(字节)。
3. CPU Cache读操作
当CPU需要读取一个数据时,数据查找路径如下:Store Buffer > L1 > L2 > L3 > 内存。当当前缓存中没有找到相应的数据时,去下一层级中找。
在各类基准测试(Benchmark) 和实际应用场景中,CPU Cache 的命中率通常能达到 95% 以上。
4. CPU缓存映射方式
- 直接映射(Direct Mapped Cache)
- 全相连(Fully Associative Cache)
- 组相连(Set Associative Cache)
4.1 直接映射
一种直接的实现方式:按Cache大小取余
优点:硬件简单,容易实现,成本低
缺点:发生块冲突的概率较大,导致Cache命中率低、效率下降
CPU如何判断这个缓存行中的数据,是否是我们想要的数据?
- 组标记(Tag)
- 有效位(valid bit):标记对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。
4.2 全相连
理念:内存块可以映射到任意一个Cache Line上
优点:块冲突率低
缺点:Cache命中与否判断的代价很高,硬件控制复杂
4.3 组相连
将整个Cache分为S组,每组包含E行。每个内存块映射到固定的分组中,可以存储在该分组内的任意一行。
当E=1时,即为直接映射。
5. CPU Cache写策略
(暂时忽略Store Buffer,之后讨论完缓存一致性协议再来关注它)
- Write-Through:Cache内容的更新会立刻写回内存中
- Write-Back:Cache内容更新后不立刻更新内存
Dirty Block何时写回内存?
当这个缓存行即将被替换出去(或出现写内存屏障)时。
目前的cpu基本都使用Write-Back策略(性能考虑)。
6. 并发问题 及 缓存一致性协议
基于以上知识,我们很容易发现如果仅有上述规则,在并发、共享的环境下,会出现很多问题。后续将逐步提出并介绍相应的概念及实现。
- 多个核(后文用"core"代替)同时操作一份共享数据(写写、写读、读写),以谁为准,如何读取其他core新写的数据,如何写回内存。
相关协议:缓存一致性协议 MESI
缓存行的四种状态:
- Modified(M):缓存行是脏(dirty)的,与主存值不同。当前core可继续写入;如果其他core要读主存这块数据,该缓存行必须回写到主存,状态变为S。
- Exclusive(E):缓存行只在当前缓存中,但是是干净的(clean,未被修改过),缓存数据与主存数据相同。当前core可写入,写入后状态变为M;当别的缓存读取它时,状态变为S。
- Shared(S):缓存行存在于其他缓存中,且是干净的,可在任意时刻抛弃。
- Invalid(I):无效缓存。
处理器对缓存的请求:
- PrRd: 处理器请求读一个缓存块
- PrWr: 处理器请求写一个缓存块
总线对缓存的请求:
- BusRd: 窥探器请求指出其他处理器请求读一个缓存块
- BusRdX: 窥探器请求指出其他处理器请求写一个该处理器不拥有的缓存块
- BusUpgr: 窥探器请求指出其他处理器请求写一个该处理器拥有的缓存块
- Flush: 窥探器请求指出请求回写整个缓存到主存
- FlushOpt: 窥探器请求指出整个缓存块被发到总线以发送给另外一个处理器(缓存到缓存的复制)
FlushOpt在一定程度上提升了整体的性能(减少了同步等待写回内存,并从内存中读取),但即使如此,如果严格遵守缓存一致性协议,在大量的场景下,cpu的整体性能都将受到很大的影响,如:
- 当core 0需要写一个变量a,但a不在core 0的L1 Cache中:此时需要等待从内存中读取数据至L1 Cache,并将变量a的值写入相应Cache Line。
- 当core 0中变量a所在的缓存行状态为M,此时,有另一个core 1需要读/写这个变量:core 1需要先发起BusRd/BusRdX请求,core 0嗅探到相应请求后,修改相应缓存行状态并发出总线FlushOpt信号,core 1及主存接收相应信号并处理。
- ...
针对上述第一个场景,为了提升性能,现代CPU引入了Store Buffer,此时,core 0不再等待从内存中读取相应缓存行至L1 Cache,而是直接将变量a的值写入Store Buffer,并执行后续指令。
该机制的引入,又带来了两个新的问题。
考虑以下场景一:
//a, b init to 0.
a = 1;
b = a + 1;
assert(b == 2);
假设,变量a不在core 0的Cache中,变量b在core 0的Cache中。当语句a=1执行后,core 0将变量a的新值1写入Store Buffer中并继续执行语句2,此时内存中的变量a的值仍为0,b变量根据内存中的值写入新值1,assert失败。
针对此问题,CPU的设计者通过使用"Store Forwarding"的方式解决这个问题:执行load操作时先从Store Buffer中查找。
考虑场景二:
void foo(void) {
a = 1;
b = 1;
}
void bar(void) {
while (b == 0) continue;
assert(a == 1);
}
假设,foo, bar两个函数分别在两个不同的core上执行,假设core 0执行foo,core 1执行bar,a不在core 0的Cache Line中,b在core 0的Cache Line中,且对应缓存行状态为M。
此时,当core 0执行a=1的时候会将a=1放到Store Buffer中,然后再执行b=1,因为b在core 0上,切状态为M,所以修改b不需要与其他core进行同步,b的修改直接就在Cache Line中进行了,所以也不会进Store Buffer。这时候,core 1执行while(b==0)的判断,发现b=1了,那么就会进入到assert,但这个时候如果a=1的Store Buffer还没有更新core 0中的a的Cache Line的话,core 1获得的a的值为0,那么这个时候结果也是不符合预期的。
同样,除了写延迟机制,编译器、处理器对指令的优化,也会导致类似的问题:多数处理器架构基于性能的考虑,对于指令的优化,只禁止了有数据依赖的语句的重排序,对于上述代码中出现的无依赖的多个变量的指令重排序,没有做限制。并发问题仍旧存在。
鉴于此,我们也可以看出,即使CPU设计者不考虑性能因素,严格的遵守了缓存一致性协议,也只能保证单个变量在并发环境中的一致性。
因此,并发问题无法在CPU设计层面解决,需要编码人员介入,编码人员需要告诉CPU现在需要将Store Buffer里的数据flush到cache里,或是将Invalidate Queue中提到的缓存行失效处理,即,写、读内存屏障。
题外实践
缓存行对程序运行时间的影响
原型:
for (int i = 0; i < N; i+=k)
arr[i] *= 3;
在Cache Line为64B的机器下,k取不同值,代码的运行时间比率大致如下:
本地java实践:
int[] arr = new int[65536];
for (int i = 0; i < 65536; i++)
arr[i] = i;
long start = System.currentTimeMillis();
for (int j = 0; j < 16384; j++)
for (int i = 0; i < 65536; i++)
arr[i] += 3;
System.out.println(System.currentTimeMillis() - start);
for (int i = 0; i < 65536; i++)
arr[i] = i;
start = System.currentTimeMillis();
for (int j = 0; j < 16384; j++)
for (int i = 0; i < 65536; i+=64)
arr[i] += 3;
System.out.println(System.currentTimeMillis() - start);