基础知识

首先,大家都知道现在 CPU 的多核技术,都会有几级缓存,现在的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。

我们的数据从内存向上,先到 L3,再到 L2,再到 L1,最后到寄存器进行计算。那么,为什么会设计成三层?这里有以下几方面的考虑:

  • 物理速度,如果要更大的容量就需要更多的晶体管,除了芯片的体积会变大,更重要的是大量的晶体管会导致速度下降,因为访问速度和要访问的晶体管所在的位置成反比。也就是当信号路径变长时,通信速度会变慢,这就是物理问题。

  • 另外一个问题是,多核技术中,数据的状态需要在多个 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。

缓存需要把内存里的数据放进来,英文叫 CPU 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 里的偏移量

索引过程如下图所示:

  • 当拿到一个内存地址的时候,先拿出中间的 6bits 来,找到是哪组;

  • 然后在这一个8组的cache line中,再进行O(n) ,n=8 的遍历,主是要匹配前24bits的tag。如果匹配中了,就算命中,如果没有匹配到,那就是cache miss,如果是读操作,就需要进向后面的缓存进行访问了。L2和L3同样是这样的算法。而淘汰算法有两种,一种是随机,另一种是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 技术。比如,你在 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 硬件上,会有两种方法来解决这个问题。

  1. Directory 协议。这种方法的典型实现是要设计一个集中式控制器,它是主存储器控制器的一部分。其中有一个目录存储在主存储器中,其中包含有关各种本地缓存内容的全局状态信息。当单个 CPU Cache 发出读写请求时,这个集中式控制器会检查并发出必要的命令,以在主存和 CPU Cache 之间或在 CPU Cache 自身之间进行数据同步和传输。

  2. Snoopy 协议。这种协议更像是一种数据通知的总线型的技术。CPU Cache 通过这个协议可以识别其它 Cache 上的数据状态。如果有数据共享的话,可以通过广播机制将共享数据的状态通知给其它 CPU Cache。这个协议要求每个 CPU Cache 都可以“窥探”数据事件的通知并做出相应的反应。如下图所示,有一个 Snoopy Bus 的总线。

因为 Directory 协议是一个中心式的,会有性能瓶颈,而且会增加整体设计的复杂度。而 Snoopy 协议更像是微服务+消息通讯,所以,现在基本都是使用 Snoopy 的总线的设计。

在分布式系统中我们一般用 Paxos/Raft 这样的分布式一致性的算法。而在 CPU 的微观世界里,则不必使用这样的算法。因为 CPU 的多个核的硬件不必考虑网络会断会延迟的问题。所以,CPU 的多核心缓存间的同步的核心就是要管理好数据的状态就好了。

这里介绍几个状态协议,先从最简单的开始,MESI 协议,这个协议跟那个著名的足球运动员梅西没什么关系,其主要表示缓存数据有四个状态:Modified(已修改), Exclusive(独占的),Shared(共享的),Invalid(无效的)。

MESI 这种协议在数据更新后,会标记其它共享的 CPU 缓存的数据拷贝为Invalid 状态,然后当其它 CPU 再次 read 的时候,就会出现 cache miss 的问题,此时再从内存中更新数据。从内存中更新数据意味着 20 倍速度的降低。我们能不能直接从我隔壁的 CPU 缓存中更新?是的,这就可以增加很多速度了,但是状态控制也就变麻烦了。还需要多来一个状态:Owner(宿主),用于标记,我是更新数据的源。于是,出现了 MOESI 协议。

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核心共享的)。

4 程序性能

了解了我们上面的这些东西后,我们来看一下对于程序的影响。

示例一

首先,假设我们有一个64M长的数组,设想一下下面的两个循环:

const int LEN = 64*1024*1024;
int *arr = new int[LEN];
for (int i = 0; i < LEN; i += 2) arr[i] *= i;
for (int i = 0; i < LEN; i += 8) arr[i] *= i;

按我们的想法,第二个循环要比第一个循环少 4 倍的计算量。其应该要快 4 倍的。但实际跑下来并不是,在我的机器上,第一个循环需要 128 毫秒,第二个循环则需要 122 毫秒,相差无几。这里最主要的原因就是 Cache Line,因为 CPU 会以一个 Cache Line 64Bytes 最小时单位加载,也就是 16 个 32bits 的整型,所以,无论你步长是 2 还是 8,都差不多。而后面的乘法其实是不耗 CPU 时间的。

示例二

接下来,我们再来看个示例。下面是一个二维数组的两种遍历方式,一个逐行遍历,一个是逐列遍历,这两种方式在理论上来说,寻址和计算量都是一样的,执行时间应该也是一样的。

const int row = 1024;
const int col = 512
int matrix[row][col];
//逐行遍历
int sum_row=0;
for(int _r=0; _r<row; _r++) {for(int _c=0; _c<col; _c++){sum_row += matrix[_r][_c];}
}
//逐列遍历
int sum_col=0;
for(int _c=0; _c<col; _c++) {for(int _r=0; _r<row; _r++){sum_col += matrix[_r][_c];}
}

然而,并不是,在我的机器上,得到下面的结果。

逐行遍历:0.083ms

逐列遍历:1.072ms

执行时间有十几倍的差距。其中的原因,就是逐列遍历对于 CPU Cache 的运作方式并不友好,所以,付出巨大的代价。

示例三

接下来,我们来看一下多核下的性能问题,参看如下的代码。两个线程在操作一个数组的两个不同的元素(无需加锁),线程循环 1000 万次,做加法操作。在下面的代码中,我高亮了一行,就是 p2 指针,要么是 p[1],或是 p[30],理论上来说,无论访问哪两个数组元素,都应该是一样的执行时间。

void fn (int* data) {for(int i = 0; i < 10*1024*1024; ++i)*data += rand();
}
int p[32];
int *p1 = &p[0];
int *p2 = &p[1]; // int *p2 = &p[30];
thread t1(fn, p1);
thread t2(fn, p2);

然而,并不是,在我的机器上执行下来的结果是:

对于 p[0] 和 p[1] :570ms

对于 p[0] 和 p[30]:105ms

这是因为 p[0] 和 p[1] 在同一条 Cache Line 上,而 p[0] 和 p[30] 则不可能在同一条 Cache Line 上 ,CPU 的缓存最小的更新单位是 Cache Line,所以,这导致虽然两个线程在写不同的数据,但是因为这两个数据在同一条 Cache Line 上,就会导致缓存需要不断进在两个 CPU 的 L1/L2 中进行同步,从而导致了 5 倍的时间差异。

CPU 缓存用途及原理详细介绍相关推荐

  1. shell编程入门 linux解释器原理,Shell编程入门Linux解释器原理详细介绍 使用Shell进行工作的人们对.doc...

    Shell编程入门Linux解释器原理详细介绍 使用Shell进行工作的人们对 Shell编程入门:Linux解释器原理详细介绍使用Shell进行工作的人们对Unix/Linux下的Shell编程都很 ...

  2. Android百度地图(一):百度地图定位sdk 类方法参数、定位原理详细介绍

    ***转载.引用请标明出处*** http://www.jianshu.com/p/29ccac3e1e42 本文出自[zhh_happig的简书博客](http://www.jianshu.com/ ...

  3. 系统构架及工作原理详细介绍

    我们知道计算机网络依据物理连接和逻辑连接来实现不同网络之间.不同主机之间.主机 与终端之间的信息交换与信息共享.绿盟安全隔离与信息交换系统既然隔离.阻断了网络的所 有连接,实际上就是隔离.阻断了网络的 ...

  4. 大数据之Kafka内部原理详细介绍

    目录 前言: 1.Kafka整体结构 2.Consumer与topic关系 3.Kafka消息的分发 4.Consumer的负载均衡 5.kafka文件存储机制 总结: 目录 前言: 本篇文章所介绍的 ...

  5. ARP欺骗原理详细介绍

    一台主机A如果要向目标主机B发送数据,无论主机B在本网段还是在远程网络,这些需要发出去的数据包中需要四样必不可少的地址,那就是源IP地址和源MAC地址,以及目标IP地址和目标MAC地址,当主机B在封装 ...

  6. [缓存]关于memcached的详细介绍以及用法

    很久没用过memcached,觉得有些生疏了,现在做一次笔记,回忆一下,也为了以后更加方便的理解. 关于memcached的历史之类的,就不说了,不熟,也没怎么看过,主要就是说memcached的原理 ...

  7. 降压电荷泵原理详细介绍

    本文来自公众号:工程师看海 文章原名:一文理解电荷泵电源原理 电荷泵电源是一种常见架构的电源,与基于电感的开关电源相比,电荷泵尺寸小,没有电感带来的磁场和EMI干扰. 近年来,电荷泵比较热门应用是手机 ...

  8. BERT原理详细介绍

    目录 1. 前言 1.1 Word2Vec和BERT对比 1.1.1 word2vec缺点: 1.1.2 bert 1.2 BERT简介 2. ELMO介绍 2.1 为什么要说到ELMO呢? 2.2 ...

  9. 计算机主板的选购技巧,CPU主板怎么选购 CPU主板选购技巧【详细介绍】

    关于装机,处理器和主板的组合是非常重要的,装机时大家也比较容易犯的错误,下面小编给大家介绍一下三个CPU主板选购误区,看看你是否也存在这样的问题呢. CPU主板选购误区-- CPU 只看核心数量 CP ...

最新文章

  1. 沈向洋:读论文的三个层次
  2. Vue之vue-cli安装与简单调试
  3. 13.2.虚拟化工具--jstat
  4. 来一份全面的面试宝典练练手,面试真题解析
  5. SQLServer 优化SQL语句 in 和not in的替代方案
  6. lamp中php怎么连接mysql_LAMP分离安装环境下,php无法连接mysql的问题
  7. liunx下mysql远程连接_Linux下mysql实现远程连接
  8. 麻将游戏软件————附带核心算法
  9. 全国信息流广告优化师交流群,不容错过!赶紧加入!
  10. 十二种人生感悟(转贴)
  11. IEEE协会首次在京举办研讨会,王飞跃称不存在AI芯片
  12. 《连线》杂志主编Kevin Kelly 给年轻人的99条人生建议
  13. 《树莓派开发笔记 - 第1部分 基础篇》第7章 树莓派变身路由器
  14. 五路循迹传感器的优点_对于安装五路循迹传感器的机器人来说,如果五个传感器都遇到黑线,说明机器人的状态是()。...
  15. 手机下载神器批量下载图片教程
  16. 还在用QQ邮箱?教你如何创建自己的高逼格邮箱
  17. mysql 主码和外码_sql如何用sql语句对一个已经编辑好的表在设置主码和外码?Atlassian Confluence安装以及咋更改数据库...
  18. 服务器删除安装的系统教程,服务器安装系统教程
  19. EVERYTHING本地搜索工具
  20. html中iframe显示多个子页面

热门文章

  1. substr函数的用法,全是精华。
  2. Python机器学习算法入门教程(三)
  3. python turtle画彩虹简单_python绘制简单彩虹图
  4. html5毕业论文题目,web设计论文选题推荐 web设计专业论文题目怎么拟
  5. 连续梁的matlab编程,MATLAB在连续梁计算和作图中的应用
  6. 进击的企业服务赛道,2020年有哪些增长新思路?
  7. 用Python提取图片中的文字——百度智能云API
  8. 如何在微信小程序中调用腾讯地图api
  9. iOS - 加解密(对称,非对称)(AES DES base64这里都有),数字签名,cookie
  10. vue 引入外部字体(以思源为例)