CPU缓存的的学习与MESI协议

文章转载了:https://blog.csdn.net/aBOUNTWINTER/article/details/78652754

文章转载了:https://blog.csdn.net/unei66/article/details/25738977

文章转载了:https://blog.csdn.net/qq_27680317/article/details/78486220?locationNum=5&fps=1

 

CPU Cache概述

随着CPU的频率不断提升,而内存的访问速度却没有质的突破,为了弥补访问内存的速度慢,充分发挥CPU的计算资源,提高CPU整体吞吐量,在CPU与内存之间引入了一级Cache。随着热点数据体积越来越大,一级Cache L1已经不满足发展的要求,引入了二级Cache L2,三级Cache L3。(注:若无特别说明,本文的Cache指CPU Cache,高速缓存)CPU Cache在存储器层次结构中的示意如下图:

图片描述

计算机早已进入多核时代,软件也越来越多的支持多核运行。一个处理器对应一个物理插槽,多处理器间通过QPI总线相连。一个处理器包含多个核,一个处理器间的多核共享L3 Cache。一个核包含寄存器、L1 Cache、L2 Cache,下图是Intel Sandy Bridge CPU架构,一个典型的NUMA多处理器结构:

图片描述

作为程序员,需要理解计算机存储器层次结构,它对应用程序的性能有巨大的影响。如果需要的程序是在CPU寄存器中的,指令执行时1个周期内就能访问到他们。如果在CPU Cache中,需要1~30个周期;如果在主存中,需要50~200个周期;在磁盘上,大概需要几千万个周期。充分利用它的结构和机制,可以有效的提高程序的性能。

以我们常见的X86芯片为例,Cache的结构下图所示:整个Cache被分为S个组,每个组是又由E行个最小的存储单元——Cache Line所组成,而一个Cache Line中有B(B=64)个字节用来存储数据,即每个Cache Line能存储64个字节的数据,每个Cache Line又额外包含一个有效位(valid bit)、t个标记位(tag bit),其中valid bit用来表示该缓存行是否有效;tag bit用来协助寻址,唯一标识存储在CacheLine中的块;而Cache Line里的64个字节其实是对应内存地址中的数据拷贝。根据Cache的结构题,我们可以推算出每一级Cache的大小为B×E×S。

图片描述

扫描二维码关注公众号,回复: 5266181 查看本文章

那么如何查看自己电脑CPU的Cache信息呢?

在windows下查看方式有多种方式,其中最直观的是,通过安装CPU-Z软件,直接显示Cache信息,如下图:

图片描述

此外,Windows下还有两种方法:

①Windows API调用GetLogicalProcessorInfo。 
②通过命令行系统内部工具CoreInfo。

如果是Linux系统, 可以使用下面的命令查看Cache信息:

ls /sys/devices/system/cpu/cpu0/cache/index0

图片描述
还有lscpu等命令也可以查看相关信息,如果是Mac系统,可以用sysctl machdep.cpu 命令查看cpu信息。

如果我们用Java编程,还可以通过CacheSize API方式来获取Cache信息, CacheSize是一个谷歌的小项目,java语言通过它可以进行访问本机Cache的信息。示例代码如下:

 
  1. public static void main(String[] args) throws CacheNotFoundException {
    
    CacheInfo info = CacheInfo.getInstance();
    
    CacheLevelInfo l1Datainf = info.getCacheInformation(CacheLevel.L1, CacheType.DATA_CACHE);
    
    System.out.println("第一级数据缓存信息:"+l1Datainf.toString());
    
    
    CacheLevelInfo l1Instrinf = info.getCacheInformation(CacheLevel.L1, CacheType.INSTRUCTION_CACHE);
    
    System.out.println("第一级指令缓存信息:"+l1Instrinf.toString());
    
    }

打印输出结果如下:

  1. 第一级数据缓存信息:CacheLevelInfo [cacheLevel=L1, cacheType=DATA_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]
    
    
    第一级指令缓存信息:CacheLevelInfo [cacheLevel=L1, cacheType=INSTRUCTION_CACHE, cacheSets=64, cacheCoherencyLineSize=64, cachePhysicalLinePartitions=1, cacheWaysOfAssociativity=8, isFullyAssociative=false, isSelfInitializing=true, totalSizeInBytes=32768]

还可以查询L2、L3级缓存的信息,这里不做示例。从打印的信息和CPU-Z显示的信息可以看出,本机的Cache信息是一致的,L1数据/指令缓存大小都为:C=B×E×S=64×8×64=32768字节=32KB。

主内存(RAM)是你的数据(包括代码行)存放的地方。本文将忽略硬件驱动和网络之类的东西,因为Disruptor(一个并发框架)的目标是尽可能多的在内存中运行。

CPU和主内存之间有好几层缓存,因为即使直接访问主内存也是非常慢的。如果你正在多次对一块数据做相同的运算,那么在执行运算的时候把它加载到离CPU很近的地方就有意义了(比如一个循环计数-你不想每次循环都跑到主内存去取这个数据来增长它吧)。

越靠近CPU的缓存越快也越小。所以L1缓存很小但很快(译注:L1表示一级缓存),并且紧靠着在使用它的CPU内核。L2大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有 CPU 核共享。

当CPU执行运算的时候,它先去L1查找所需的数据,再去L2,然后是L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在L1缓存中。

Martin和Mike的 QCon presentation演讲中给出了一些缓存未命中的消耗数据:

从CPU到 大约需要的 CPU 周期 大约需要的时间
主存   约60-80纳秒
QPI 总线传输
(between sockets, not drawn)
  约20ns
L3 cache 约40-45 cycles, 约15ns
L2 cache 约10 cycles, 约3ns
L1 cache 约3-4 cycles, 约1ns
寄存器 1 cycle  

如果你的目标是让端到端的延迟只有 10毫秒,而其中花80纳秒去主存拿一些未命中数据的过程将占很重的一块。

缓存行

现在需要注意一件有趣的事情,数据在缓存中不是以独立的项来存储的,如不是一个单独的变量,也不是一个单独的指针。缓存是由缓存行组成的,通常是64字节(译注:这篇文章发表时常用处理器的缓存行是64字节的,比较旧的处理器缓存行是32字节),并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。

(为了简化,我将忽略多级缓存)

非常奇妙的是如果你访问一个long数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个。因此你能非常快地遍历这个数组。事实上,你可以非常快速的遍历在连续的内存块中分配的任意数据结构。我在第一篇关于ring buffer的文章中顺便提到过这个,它解释了我们的ring buffer使用数组的原因。

因此如果你数据结构中的项在内存中不是彼此相邻的(链表,我正在关注你呢),你将得不到免费缓存加载所带来的优势。并且在这些数据结构中的每一个项都可能会出现缓存未命中。

不过,所有这种免费加载有一个弊端。设想你的long类型的数据不是数组的一部分。设想它只是一个单独的变量。让我们称它为head,这么称呼它其实没有什么原因。然后再设想在你的类中有另一个变量紧挨着它。让我们直接称它为tail。现在,当你加载head到缓存的时候,你也免费加载了tail

听想来不错。直到你意识到tail正在被你的生产者写入,而head正在被你的消费者写入。这两个变量实际上并不是密切相关的,而事实上却要被两个不同内核中运行的线程所使用。

设想你的消费者更新了head的值。缓存中的值和内存中的值都被更新了,而其他所有存储head的缓存行都会都会失效,因为其它缓存中head不是最新值了。请记住我们必须以整个缓存行作为单位来处理(译注:这是CPU的实现所规定的,详细可参见深入分析Volatile的实现原理),不能只把head标记为无效。

现在如果一些正在其他内核中运行的进程只是想读tail的值,整个缓存行需要从主内存重新读取。那么一个和你的消费者无关的线程读一个和head无关的值,它被缓存未命中给拖慢了。

当然如果两个独立的线程同时写两个不同的值会更糟。因为每次线程对缓存行进行写操作时,每个内核都要把另一个内核上的缓存块无效掉并重新读取里面的数据。你基本上是遇到两个线程之间的写冲突了,尽管它们写入的是不同的变量。

这叫作“伪共享”(译注:可以理解为错误的共享),因为每次你访问head你也会得到tail,而且每次你访问tail,你也会得到head。这一切都在后台发生,并且没有任何编译警告会告诉你,你正在写一个并发访问效率很低的代码。

解决方案-神奇的缓存行填充

你会看到Disruptor消除这个问题,至少对于缓存行大小是64字节或更少的处理器架构来说是这样的(译注:有可能处理器的缓存行是128字节,那么使用64字节填充还是会存在伪共享问题),通过增加补全来确保ring buffer的序列号不会和其他东西同时存在于一个缓存行中。

1 public longp1, p2, p3, p4, p5, p6, p7; // cache line padding
2     privatevolatile long cursor = INITIAL_CURSOR_VALUE;
3     publiclong p8, p9, p10, p11, p12, p13, p14;// cache line padding

因此没有伪共享,就没有和其它任何变量的意外冲突,没有不必要的缓存未命中。

在你的Entry类中也值得这样做,如果你有不同的消费者往不同的字段写入,你需要确保各个字段间不会出现伪共享。

MESI协议:

1)Write through(写通)
每次CPU修改了cache中的内容,Cache立即更新内存的内容

2) Write back(写回)
内核修改cache的内容后,cache并不会立即更新内存中的内容,而是等到这个cache line因为某种原因需要从

cache中移除时,cache才会更新内存中的内容。

Write through(写通)由于有大量的访问内存的操作,效率太低,大多数处理器都使用Writeback(写回)策略。

Cache如何知道这行有没有被修改?需要一个标志-dirty标志。Dirty标志为1,表示cache的内容被修改,和内存的

内容不一致,当该cache line被移除时,数据需要被更新到内存,dirty标志位0(称为clean),表示cache的内容

和内存的内容一致。

有dirty标志的cache结构:

程序cache不会被修改,不需要dirty标志,数据cache需要dirty标志。

2.Cache一致性
1)一致性问题的产生-信息不对称导致的问题
在多核处理器中,内存中有一个数据x,值为3,被缓存到core0和core1中,如果core0将x修改为5,而core1

不知道x被修改,还在使用旧事,就会导致程序出错,这就是cache的不一致。

2)Cache一致性的底层操作
为了保证cache的一致性,处理器提供了两个保证cache一致性的底层操作:Writeinvalidate和Write update。

Write invalidate(置无效):当一个内核修改了一份数据,其他内核上如果有这份数据的复制,就置成无效。


Write update(写更新):当一个内核修改了一份数据,其他地方如果有这份数据的复制,就都更新到最新值。


 

Write invalidate是一种简单的方式,不需要更新数据,如果core1和core2以后不再使用变量x,这时候采用

write invalidate就非常有效,不过由于一个valid标志对应一个Cache line,将valid标志置成invalid后,这个

cache line中其他的有效的数据也不能使用了。Write upodate策略会产生大量的数据更新操作,不过只用更

新修改的数据,如果core1和core2会使用变量x,那么writeupdate就比较有效。由于Writeinvalidate简单,

大多数处理器都是用Writeinvalidate策略。

3) Cache一致性协议
在MESI协议中,每个Cacheline有4个状态,可用两个bit表示,它们分别是:

E状态(独占):


S状态(共享):


M状态和I状态:


MESI协议状态迁移图:


Local Read表示本内核读本Cache的值,Local Write表示本内核写Cache中的值,Remote Read表示其他内核

读其他Cache中的值,Remote write 表示其他内核写其他Cache的值,箭头表示本Cache line状态的迁移,唤

醒箭头表示状态不变。

猜你喜欢

转载自blog.csdn.net/wjxhhh123/article/details/84304937