关于CPU Cache -- 程序员须要知道的那些事

本文将介绍一些做为程序猿或者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/git

为了更好的体验,请经过此连接阅读:

http://cenalulu.github.io/linux/all-about-cpu-cache/程序员

先来看一张本文全部概念的一个思惟导图
github

为何要有CPU Cache

随着工艺的提高最近几十年CPU的频率不断提高,而受制于制造工艺和成本限制,目前计算机的内存主要是DRAM而且在访问速度上没有质的突破。所以,CPU的处理速度和内存的访问速度差距愈来愈大,甚至能够达到上万倍。这种状况下传统的CPU经过FSB直连内存的方式显然就会由于内存访问的等待,致使计算资源大量闲置,下降CPU总体吞吐量。同时又因为内存数据访问的热点集中性,在CPU和内存之间用较为快速而成本较高的SDRAM作一层缓存,就显得性价比极高了。设计模式

为何要有多级CPU Cache

随着科技发展,热点数据的体积愈来愈大,单纯的增长一级缓存大小的性价比已经很低了。所以,就慢慢出现了在一级缓存(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

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;
    }
}

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开始编上号。同时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-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(512K)*64=32M的连续热点数据才会致使一个set内的conflict(Direct Mapped中512K的连续热点数据就会出现conflict)

addr

为何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 中的测试结果图,来解释下内存对齐在极端状况下带来的性能损失。
memory_align

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

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

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


Cache淘汰策略

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

总结

CPU Cache对于程序猿是透明的,全部的操做和策略都在CPU内部完成。可是,了解和理解CPU Cache的设计、工做原理有利于咱们更好的利用CPU Cache,写出更多对CPU Cache友好的程序

Reference

  1. Gallery of Processor Cache Effects
  2. How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses
  3. Introduction to Caches
相关文章
相关标签/搜索