本文将介绍一些做为程序猿或者IT从业者应该知道的CPU Cache相关的知识。本章从“为何会有CPU Cache”,“CPU Cache的大体设计架构”,“几个认识CPU Cache的实验”多个方面介绍做为一个程序员所需知道的关于CPU Cache的基础知识。并经过知识总结出,做为程序员了解这些知识后能对平时开发带来什么帮助html
文章欢迎转载,但转载时请保留本段文字,并置于文章的顶部
做者:卢钧轶(cenalulu)
本文原文地址:http://cenalulu.github.io/linux/all-about-cpu-cache/linux
http://cenalulu.github.io/linux/all-about-cpu-cache/程序员
先来看一张本文全部概念的一个思惟导图
github
随着工艺的提高最近几十年CPU的频率不断提高,而受制于制造工艺和成本限制,目前计算机的内存主要是DRAM而且在访问速度上没有质的突破。所以,CPU的处理速度和内存的访问速度差距愈来愈大,甚至能够达到上万倍。这种状况下传统的CPU经过FSB直连内存的方式显然就会由于内存访问的等待,致使计算资源大量闲置,下降CPU总体吞吐量。同时又因为内存数据访问的热点集中性,在CPU和内存之间用较为快速而成本较高的SDRAM作一层缓存,就显得性价比极高了。设计模式
随着科技发展,热点数据的体积愈来愈大,单纯的增长一级缓存大小的性价比已经很低了。所以,就慢慢出现了在一级缓存(L1 Cache)和内存之间又增长一层访问速度和成本都介于二者之间的二级缓存(L2 Cache)。下面是一段从What Every Programmer Should Know About Memory中摘录的解释:数组
Soon after the introduction of the cache the system got more complicated. The speed difference between the cache and the main memory increased again, to a point that another level of cache was added, bigger and slower than the first-level cache. Only increasing the size of the first-level cache was not an option for economical rea- sons.缓存
此外,又因为程序指令和程序数据的行为和热点分布差别很大,所以L1 Cache也被划分红L1i (i for instruction)和L1d (d for data)两种专门用途的缓存。架构
Cache Line能够简单的理解为CPU Cache中的最小缓存单位。目前主流的CPU Cache的Cache Line大小都是64Bytes。假设咱们有一个512字节的一级缓存,那么按照64B的缓存单位大小来算,这个一级缓存所能存放的缓存个数就是512/64 = 8
个。具体参见下图:app
为了更好的了解Cache Line,咱们还能够在本身的电脑上作下面这个有趣的实验。
下面这段C代码,会从命令行接收一个参数做为数组的大小建立一个数量为N的int数组。并依次循环的从这个数组中进行数组内容访问,循环10亿次。最终输出数组总大小和对应总执行时间。
#include "stdio.h" #include <stdlib.h> #include <sys/time.h> long timediff(clock_t t1, clock_t t2) { long elapsed; elapsed = ((double)t2 - t1) / CLOCKS_PER_SEC * 1000; return elapsed; } int main(int argc, char *argv[]) #******* { int array_size=atoi(argv[1]); int repeat_times = 1000000000; long array[array_size]; for(int i=0; i<array_size; i++){ array[i] = 0; } int j=0; int k=0; int c=0; clock_t start=clock(); while(j++<repeat_times){ if(k==array_size){ k=0; } c = array[k++]; } clock_t end =clock(); printf("%lu\n", timediff(start,end)); return 0; }
若是咱们把这些数据作成折线图后就会发现:总执行时间在数组大小超过64Bytes时有较为明显的拐点(固然,因为博主是在本身的Mac笔记本上测试的,会受到不少其余程序的干扰,所以会有波动)。缘由是当数组小于64Bytes时数组极有可能落在一条Cache Line内,而一个元素的访问就会使得整条Cache Line被填充,于是值得后面的若干个元素受益于缓存带来的加速。而当数组大于64Bytes时,必然至少须要两条Cache Line,继而在循环访问时会出现两次Cache Line的填充,因为缓存填充的时间远高于数据访问的响应时间,所以多一次缓存填充对于总执行的影响会被放大,最终获得下图的结果:
若是读者有兴趣的话也能够在本身的linux或者MAC上经过gcc cache_line_size.c -o cache_line_size
编译,并经过./cache_line_size
执行。
了解Cache Line的概念对咱们程序猿有什么帮助?
咱们来看下面这个C语言中经常使用的循环优化例子
下面两段代码中,第一段代码在C语言中老是比第二段代码的执行速度要快。具体的缘由相信你仔细阅读了Cache Line的介绍后就很容易理解了。
for(int i = 0; i < n; i++) { for(int j = 0; j < n; j++) { int num; //code arr[i][j] = num; } }
for(int i = 0; i < n; i++) { for(int j = 0; j < n; j++) { int num; //code arr[j][i] = num; } }
咱们先来尝试回答一下那么这个问题:
假设咱们有一块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的功能在成本上是很是高的。
Fully Associative 字面意思是全关联。在CPU Cache中的含义是:若是在一个Cache集内,任何一个内存地址的数据能够被缓存在任何一个Cache Line里,那么咱们成这个cache是Fully Associative。从定义中咱们能够得出这样的结论:给到一个内存地址,要知道他是否存在于Cache中,须要遍历全部Cache Line并比较缓存内容的内存地址。而Cache的本意就是为了在尽量少得CPU Cycle内取到数据。那么想要设计一个快速的Fully Associative的Cache几乎是不可能的。
和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开始编上号。同时64位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个Cache Line做为一组(set),缓存按组划为等分。这样一个64位系统的内存地址在4MB二级缓存中就划成了三个部分(见下图),低位6个bit表示在Cache Line中的偏移量,中间12bit表示Cache组号(set index),剩余的高位46bit就是内存地址的惟一id。这样的设计相较前两种设计有如下两点好处:
2^18(512K)*64
=32M
的连续热点数据才会致使一个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的架构下会出现的问题能够详细阅读如下两篇文章:
在文章的最后咱们顺带提一下CPU Cache的淘汰策略。常见的淘汰策略主要有LRU
和Random
两种。一般意义下LRU对于Cache的命中率会比Random更好,因此CPU Cache的淘汰策略选择的是LRU
。固然也有些实验显示在Cache Size较大的时候Random策略会有更高的命中率
CPU Cache对于程序猿是透明的,全部的操做和策略都在CPU内部完成。可是,了解和理解CPU Cache的设计、工做原理有利于咱们更好的利用CPU Cache,写出更多对CPU Cache友好的程序