:mannotop Cache – manno的博客

标签: Cache

漫游CPU Cache作用效应(1)

1. 存储器的层次结构

image.png

寄存器

寄存器是最靠近CPU的控制单元和罗技计算单元的存储器,其使用的材料速度是最快的,一般能在半个时钟周期内完成读写。

伴随最快的速度,其价格自然也是最贵的,所以数量不是很多,一般而言CPU中的寄存器数量大概在几十到几百之间,每个寄存器可以存储一定的字节(byte)的数据,比如:

  • 32位CPU中的大多数寄存器可以存储4个字节;
  • 64位CPU中的大多数寄存器可以存储8个字节。

CPU Cache

CPU Cache使用的材料是SRAM(随机静态存储器),SRAM之所以叫“静电”存储器,是因为只要有电,数据就可以保持存在,不用像DRAM一样需要每隔一段时间充电刷新一次。但是断电后,其数据还是会丢失,这点和磁盘有本质区别。

SRAM里面,一个bit的数据,通常需要6个晶体管,所以SRAM的存储密度不高,同样的物理空间下,能存储的数据是有限的,不过也因为其电路简单,所以访问速度非常快,因此用来做CPU Cache再合适不过了。

CPU的高速缓存,通常可以分为L1、L2和L3三级缓存,也称为一级缓存、二级缓存和三级缓存。

image.png

L1 Cache

L1高速缓存的访问速度大概只需要2-4个时钟周期,而大小在几十KB到几百KB不等。每个CPU核心都有一块属于自己的L1高速缓存,且指令和数据的缓存是分开的,故而L1高速缓存分为数据缓存和指令缓存。

在Linux系统中,我们查看某个CPU中L1数据缓存的容量大小,可以看到,我的服务器上L1数据缓存大小是32K:

$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K

查看某个CPU中L1指令缓存的容量大小:

$ cat /sys/devices/system/cpu/cpu0/cache/index1/size
32K

L2 Cache

L2高速缓存同样是每个CPU核心都有,但是其相对于L1高速缓存距离CPU核心更远,容量更大,通常在几百KB到几MB之间,访问速度则更慢,速度在10-20个时钟周期

在Linux系统中,我们可以通过以下指令查看L2高速缓存的大小:

$ cat /sys/devices/system/cpu/cpu0/cache/index2/size
256K

L3 Cache

L3高速缓存通常是多个CPU核心共用的,位置比L2高速缓存距离CPU核心更远,大小也会更大,通常在几MB到几十MB不等。访问速度也更慢一些,大概在20-60个时钟周期

在Linux系统中,我们可以通过以下指令查看L3高速缓存的大小:

$ cat /sys/devices/system/cpu/cpu0/cache/index3/size
25600K

内存

内存一般使用DRAM作为芯片,相比于SRAM,其密度更高,功耗更低,有更大的容量,而且造价比SRAM便宜很多。

DRAM存储一个bit数据,只需要一个晶体管和一个电容就能存储,但是因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是DRAM之所以被称为「动态」存储器的原因,只有不断刷新,数据才能被存储起来。

DRAM的数据访问电路和刷新电路都比SRAM更复杂,所以访问的速度会更慢,内存速度大概在200~300个 时钟周期之间

硬盘

SSD(*Solid-state disk*)就是我们常说的固体硬盘,结构和内存类似,但是它相比内存的优点是断电后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失。内存的读写速度比SSD大概快 10~1000 倍。

当然,还有一款传统的硬盘,也就是机械硬盘(Hard Disk Drive, HDD),它是通过物理读写的方式来访问数据的,因此它访问速度是非常慢的,它的速度比内存慢 10W 倍左右。

2. CPU Cache的数据结构和读取

我们先了解一下CPU Cache的结构,其由很多个Cache Line组成,Cache Line是CPU从内存中读取数据的基本单位,一次读取大小即为Cache Line的大小。在Linux系统中,我们可以使用如下命令查看L1 Cache Line的大小,即L1 Cache一次载入数据的大小是64字节。

$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64

通用的高速缓存存储器的组织结构

高速缓存的存储器如下图 a)所示,高速缓存被设计成:

  1. 有 S=2sS=2^sS=2s 个组;
  2. 每个组有 EEE 个高速缓存行;
  3. 每行包括1位有效位,ttt 位标记位,以及 B=2bB=2^bB=2b 字节的数据块组成。

可以得出,高速缓存的大小 C=S∗E∗BC=S*E*BC=S∗E∗B。

而正常内存地址就如同以上的图 b)中所示,其由 ttt 位的标记位,sss 位的组索引位和 bbb 位的块偏移位组成。当假设CPU是 mmm 位的时候,有 t=m−s−bt=m-s-bt=m−s−b。

CPU Cache的读取本质上就是内存地址和Cache结构之间的映射关系

直接映射

image.png

直接映射是每个组只有一行,即 E=1E=1E=1,其读写数据时执行映射的步骤如下:

  1. 组选择:从地址中抽出 sss 个组索引位,定位到组;
  2. 行选择:因为直接映射的每个组只有一行,所以可以明确只有这一行:
    1. 当此行的有效位被设置(=1),且地址中的 ttt 标记和Cache中的标记相匹配,则缓存命中;
    2. 否则缓存不命中,需要从下一级缓存或者内存中读取对应的内存数据;
  3. 字选择:根据最后b位偏移量找出所需内存数据的偏移量。
  4. 行替换:在缓存不命中的情形下,需要从存储器的下一级中取出块,替代现有的行,由于直接映射每个组只有一个行,所以替换的策略很简单,就是将当前行替换掉。

直接映射的优点是硬件简单,成本低,地址变换简单,而且不涉及替换算法问题,但是这种方式可能会存在对于Cache空间利用不充分而带来的性能抖动的问题,在《CSAPP》这本书中就有一个很好的例子来阐述这个问题。

如下的一个函数:

float dotprod(float x[8], float y[8])
{
    float sum = 0.0;
    int i;
    for(i=0; i<8; i++){
        sum += x[i] * y[i];
    }
    return sum;
}

我们假设浮点数是4个字节大小,x被夹在到从地址0-32字节连续内存中,y紧随其后,从32-64字节。假设一个Cache块是16字节,足够容纳4个浮点数,那么就会出现如下的缓存组对应:

元素地址组索引元素地址组索引
x[0]00y[0]320
x[1]40y[1]360
x[2]80y[2]400
x[3]120y[3]440
x[4]161y[4]481
x[5]201y[5]521
x[6]241y[6]561
x[7]281y[7]601

在运行时:

  • 循环第一次引用x[0],缓存不命中,导致x[0]-x[3]被加载到Cache组0;
  • 接下来对y[0]的引用,再次缓存不命中,导致y[0]-y[3]被加载到Cache组0,覆盖了前一次的x[0]-x[3];
  • 下一次迭代中,再次缓存不命中,加载x[0]-x[3],如此往复,导致性能极低。

组相联映射

image.png

前面我们说过,高速缓存的大小 C=S∗E∗BC=S*E*BC=S∗E∗B,当 E=1E=1E=1 的时候,是直接映射;而当 1<E<C/B1<E<C/B1<E<C/B 时,即组的数目大于1,且每组都有至少2行的情况时,称为组相联高速缓存。

和直接映射的区别有:

  • 组相联高速缓存中的行选择时需要遍历组中所有的行以比较标记 ttt;
  • 在缓存不命中时的行替换需要选择某些行进行替换,选择的策略有最不常使用(Least-Frequently-Used, LFU)策略和最近最少使用(Least-Recently-Used, LRU)策略。但是可以避免直接映射性能抖动的问题。

全相联映射

全相联映射是只含有一个组,这个组包含所有的高速缓存的行,即 E=C/BE=C/BE=C/B。因为只有一个组,所以地址中不包含组索引位。

image.png

因为全相联映射必须并行的搜索许多相匹配的标记,构建一个又大又快的相联高速缓存很困难,而且很昂贵。因此,全相联高速缓存只适合做小的高速缓存,比如虚拟内存系统中的翻译备用缓冲器(TLB)。

实际应用

使用以下命令可以查看Linux机器上的Cache的映射方式:

L1 数据缓存,可以看到,该机器的CPU的L1数据Cache是8路相联的高速缓存:

$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K # 缓存大小,即上述的 C
$ cat /sys/devices/system/cpu/cpu0/cache/index0/number_of_sets
64  # 缓存的组数,即上述的 S
$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64  # 缓存的行大小,即上述的 B
$ cat /sys/devices/system/cpu/cpu0/cache/index0/ways_of_associativity
8   # 组的行数,即上述的 E

L2,也是个8路相联的高速缓存:

$ cat /sys/devices/system/cpu/cpu0/cache/index2/size
256K
$ cat /sys/devices/system/cpu/cpu0/cache/index2/number_of_sets
512
$ cat /sys/devices/system/cpu/cpu0/cache/index2/coherency_line_size
64
$ cat /sys/devices/system/cpu/cpu0/cache/index2/ways_of_associativity
8

L3,是一个20路相联的高速缓存:

$ cat /sys/devices/system/cpu/cpu0/cache/index3/size
25600K
$ cat /sys/devices/system/cpu/cpu0/cache/index3/number_of_sets
20480
$ cat /sys/devices/system/cpu/cpu0/cache/index3/coherency_line_size
64
$ cat /sys/devices/system/cpu/cpu0/cache/index3/ways_of_associativity
20

Tip

Q:为什么用中间位作为高速缓存的组索引?

image.png

如果用最高位做索引

情况如上图中的中间所示,连续的块都别映射到了同一个组中(特别的,如果是直接映射高速缓存,连续的块被映射到同一行中)这样的确也能利用缓 存,如上图所示,当引用第一个元素的时候,会把第1、2、3、4个拷贝到缓存的组0中,以后对2、3、4的引用就能直接在缓存中提取。引用第5个元素的时 候,把第5、6、7、8个拷贝到缓存的组1中,同样的,对4、5、6的引用能直接在缓存中提取。后面的情况类似就不再叙述。 

通过上面的叙述,你可能已经发现一个问题:当对缓存的组1进行操作的时候,缓存中的其它组是没有被利用的,这和缓存中只有一个组其实效果是一样的。对缓存中的其他组没有很好的利用,也就是说,虽然也有缓存的利用,但有最大化。

改用中间位做索引,如上图中的右图所示,同一组中的块不再是连续的,这样可以保证缓存中的所有组都能被有效的利用。

引用第1个元素的时候,将把第1、5、9、13放入缓存组1中

引用第2个元素的时候, 把第2、6、10、14放入缓存

引用第3个,把3、7、11、15放入缓存

引用第4个,把4、8、12、16放入缓存

这样对前四个元素的引用都不会命中,而而对后面的引用都能命中。这种过程也就是所谓的缓存预热

3. 如何写出让CPU跑的更快的代码

3.1 局部性原理

一个编写良好的计算机程序常常具有良好的局部性(locality),即,它们倾向于引用邻近于其他引用过的数据项的数据项,或者最近引用过的数据项本身,这种倾向性,被称为局部性原理一般而言,有良好局部性的程序比局部性差的程序运行的更快。

局部性通常有两种不同的形式:

  • 时间局部性:被引用一次的内存位置很可能在不远的将来再被多次引用;
  • 空间局部性:一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近一个内存位置。

3.1 如何写出让CPU跑得更快的代码

由于CPU访问Cache的速度比访问内存的速度要快100多倍,所以编写缓存命中率更高的程序可以有效提升程序性能,同时这也是代码局部性良好的体现。

所以,如何让CPU跑得更快,本质上是让代码的缓存命中率更好,即有良好的局部性

3.2 提升数据缓存命中率

const int N = 64;

int sum1(int a[N][N]) {
    int i, j, sum = 0;

    for (i = 0; i < N; i++)
        for (j = 0; j < N; j++)
            sum += a[i][j];

    return sum;
}

int sum2(int a[N][N]) {
    int i, j, sum = 0;

    for (i = 0; i < N; i++)
        for (j = 0; j < N; j++)
            sum += a[j][i];

    return sum;
}

如上的代码,sum1sum2函数的效果是一致的,但是因为二维数组所占用的内存是连续的,所以sum1的局部性比sum2要好,其缓存命中率也更高(当然在现在的大存储器上可能体现不出差距,可以增大N)。

从局部性分析:

  1. 时间局部性:两个函数对于sum这个局部变量的反复引用是好的,因为编译器能够将他们缓存在寄存器文件中;
  2. 空间局部性:sum1函数内循环对于内存的每次引用是步长为1的引用,这在空间上的局部性是好的。

其实可以总结如下:

  • 将注意力集中在内循环上,大部分计算和内存访问都发生在这里;特别地,以步长为1来读数据,从而使程序的空间局部性最大;
  • 一旦从存储器中读入了一个数据对象,尽可能多地使用它,从而使得程序中的时间局部性最大。

3.3 提升指令缓存的命中率

void foo() {
    int i, array[N];
    for (i = 0; i < N; i++)
        array[i] = rand() % 100;

    // 操作一:数组遍历
    for (i = 0; i < N; i++)
        if (array[i] < 50)
            handle(array[i]);
    
    // 操作二:排序
    sort(array, array+N)
}

比如以上函数,主要是对一个随机数组进行两种操作,第一种是数组遍历,第二种是排序,假设二者没有什么相关性,那么操作一和操作二的先后顺序对代码运行速度有影响么?

在回答这个问题之前,我们先了解 CPU 的分支预测器。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 还是 else 中的指令。那么,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快

当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。

因此,先排序再遍历的速度会更快!

比如,C/C++的编译器就提供了likelyunlikely的两种宏,如果true的概率比较大,那么使用likely,反之使用unlikely

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

if (likely(array[i] < 50)) {
    // do something
} else {
    // do something
}

实际上,CPU自身的动态分支预测已经比较准了,只有当确信CPU预测不准时才会使用这两个宏。那么作为码农,我们最重要的就是在循环体中写出有规律的分支判断语句,帮助CPU预测正确,提升代码性能。

3.4 提升多核CPU的缓存命中率

多核CPU上,线程可能在不同线程之间来回切换执行,这对CPU Cache是不利的,虽然L3 Cache是多核共享的,但是L1和L2却是核心独有的,线程的切换必然会降低缓存的命中率。

所以Linux提供了sched_setaffinity方法,实现将线程绑定到某个CPU核心这一功能,从而提升缓存的命中率。

int sched_setaffinity(pid_t pid , size_t cpusetsize , const cpu_set_t * mask );

漫游CPU Cache作用效应(2)

原文地址:Gallery of Processor Cache Effects

译文地址:https://coolshell.cn/articles/10249.html

大多数读者都知道cache是一种快速小型的内存,用以存储最近访问内存位置。这种描述合理而准确,但是更多地了解一些处理器缓存工作中的“烦人”细节对于理解程序运行性能有很大帮助。

在这篇博客中,我将运用代码示例来详解cache工作的方方面面,以及对现实世界中程序运行产生的影响。

下面的例子都是用C#写的,但语言的选择同程序运行状况以及得出的结论几乎没什么影响。

示例1:内存访问和运行

你认为相较于循环1,循环2会运行多快?

int[] arr = new int[64 * 1024 * 1024];

// Loop 1
for (int i = 0; i < arr.Length; i++) arr[i] *= 3;

// Loop 2
for (int i = 0; i < arr.Length; i += 16) arr[i] *= 3;

第一个循环将数组的每个值乘3,第二个循环将每16个值乘3,第二个循环只做了第一个约6%的工作,但在现代机器上,两者几乎运行相同时间:在我机器上分别是80毫秒和78毫秒。

两个循环花费相同时间的原因跟内存有关。循环执行时间长短由数组的内存访问次数决定的,而非整型数的乘法运算次数。经过下面对第二个示例的解释,你会发现硬件对这两个循环的主存访问次数是相同的。

示例2:缓存行的影响

让我们进一步探索这个例子。我们将尝试不同的循环步长,而不仅仅是1和16。

for (int i = 0; i < arr.Length; i += K) arr[i] *= 3;

下图为该循环在不同步长(K)下的运行时间:

running times of this loop for different step values (K)

注意当步长在1到16范围内,循环运行时间几乎不变。但从16开始,每次步长加倍,运行时间减半

背后的原因是今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存,并且访问同一个缓存行内的其它值的开销是很小的。

由于16个整型数占用64字节(一个缓存行),for循环步长在1到16之间必定接触到相同数目的缓存行:即数组中所有的缓存行。当步长为32,我们只有大约每两个缓存行接触一次,当步长为64,只有每四个接触一次。步长为16的倍数,那么每次访问必然会换入Cache line。数组长度一定,步长加倍,自然循环执行的时间就减倍。

理解缓存行对某些类型的程序优化而言可能很重要。比如,数据字节对齐可能决定一次操作接触1个还是2个缓存行。那上面的例子来说,很显然操作不对齐的数据将损失一半性能。

示例3:L1和L2缓存大小

今天的计算机具有两级或三级缓存,通常叫做L1、L2以及可能的L3。如果你想知道不同缓存的大小,你可以使用系统内部工具CoreInfo,或者Windows API调用GetLogicalProcessorInfo。两者都将告诉你缓存行以及缓存本身的大小。

在我的机器上,CoreInfo现实我有一个32KB的L1数据缓存,一个32KB的L1指令缓存,还有一个4MB大小L2数据缓存。L1缓存是处理器独享的,L2缓存是成对处理器共享的。

Logical Processor to Cache Map:
*— Data Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64
*— Instruction Cache 0, Level 1, 32 KB, Assoc 8, LineSize 64
-*– Data Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64
-*– Instruction Cache 1, Level 1, 32 KB, Assoc 8, LineSize 64
–*- Data Cache 2, Level 1, 32 KB, Assoc 8, LineSize 64
–*- Instruction Cache 2, Level 1, 32 KB, Assoc 8, LineSize 64
—* Data Cache 3, Level 1, 32 KB, Assoc 8, LineSize 64
—* Instruction Cache 3, Level 1, 32 KB, Assoc 8, LineSize 64

**– Unified Cache 0, Level 2, 4 MB, Assoc 16, LineSize 64
–** Unified Cache 1, Level 2, 4 MB, Assoc 16, LineSize 6

通过一个实验来验证这些数字。遍历一个整型数组,每16个值自增1——一种节约地方式改变每个缓存行。当遍历到最后一个值,就重头开始。我们将使用不同的数组大小,可以看到当数组溢出一级缓存大小,程序运行的性能将急剧滑落。

int steps = 64 * 1024 * 1024;
// Arbitrary number of steps
int lengthMod = arr.Length - 1;
for (int i = 0; i < steps; i++)
{
    arr[(i * 16) & lengthMod]++; // (x & lengthMod) is equal to (x % arr.Length)
}
cache size

下图是运行时间图表:

你可以看到在32KB和4MB之后性能明显滑落——正好是我机器上L1和L2缓存大小。

示例4:指令级别并发

现在让我们看一看不同的东西。下面两个循环中你以为哪个较快?

int steps = 256 * 1024 * 1024;
int[] a = new int[2];

// Loop 1
for (int i=0; i<steps; i++) { a[0]++; a[0]++; }

// Loop 2
for (int i=0; i<steps; i++) { a[0]++; a[1]++; }

结果是第二个循环约比第一个快一倍,至少在我测试的机器上。为什么呢?这跟两个循环体内的操作指令依赖性有关。

same value dependency
different values dependency

第一个循环体内,操作做是相互依赖的(译者注:下一次依赖于前一次):

但第二个例子中,依赖性就不同了:

现代处理器中对不同部分指令拥有一点并发性(译者注:跟流水线有关,比如Pentium处理器就有U/V两条流水线,后面说明)。这使得CPU在同一时刻访问L1两处内存位置,或者执行两次简单算术操作。在第一个循环中,处理器无法发掘这种指令级别的并发性,但第二个循环中就可以。

[原文更新]:许多人在reddit上询问有关编译器优化的问题,像{ a[0]++; a[0]++; }能否优化为{ a[0]+=2; }。实际上,C#编译器和CLR JIT没有做优化——在数组访问方面。我用release模式编译了所有测试(使用优化选项),但我查询了JIT汇编语言证实优化并未影响结果。

示例5:缓存关联性

缓存设计的一个关键决定是确保每个主存块(chunk)能够存储在任何一个缓存槽里,或者只是其中一些(译者注:此处一个槽位就是一个缓存行)。

有三种方式将缓存槽映射到主存块中:

  1. 直接映射(Direct mapped cache)
    每个内存块只能映射到一个特定的缓存槽。一个简单的方案是通过块索引chunk_index映射到对应的槽位(chunk_index % cache_slots)。被映射到同一内存槽上的两个内存块是不能同时换入缓存的。(译者注:chunk_index可以通过物理地址/缓存行字节计算得到)
  2. N路组关联(N-way set associative cache)
    每个内存块能够被映射到N路特定缓存槽中的任意一路。比如一个16路缓存,每个内存块能够被映射到16路不同的缓存槽。一般地,具有一定相同低bit位地址的内存块将共享16路缓存槽。(译者注:相同低位地址表明相距一定单元大小的连续内存)
  3. 完全关联(Fully associative cache)
    每个内存块能够被映射到任意一个缓存槽。操作效果上相当于一个散列表。

直接映射缓存会引发冲突——当多个值竞争同一个缓存槽,它们将相互驱逐对方,导致命中率暴跌。另一方面,完全关联缓存过于复杂,并且硬件实现上昂贵。N路组关联是处理器缓存的典型方案,它在电路实现简化和高命中率之间取得了良好的折中。

直接映射和完全关联可以看做N路组关联的两个极端,从图中可知当N=1时,即直接映射;当N取最大值时,即完全关联。

4MB大小的L2缓存在我机器上是16路关联。所有64字节内存块将分割为不同组,映射到同一组的内存块将竞争L2缓存里的16路槽位。

L2缓存有65,536个缓存行(译者注:4MB/64),每个组需要16路缓存行,我们将获得4096个集。这样一来,块属于哪个组取决于块索引的低12位bit(2^12=4096)。因此缓存行对应的物理地址凡是以262,144字节(4096*64)的倍数区分的,将竞争同一个缓存槽。我机器上最多维持16个这样的缓存槽。(译者注:请结合上图中的2路关联延伸理解,一个块索引对应64字节,chunk0对应组0中的任意一路槽位,chunk1对应组1中的任意一路槽位,以此类推chunk4095对应组4095中的任意一路槽位,chunk0和chunk4096地址的低12bit是相同的,所以chunk4096、chunk8192将同chunk0竞争组0中的槽位,它们之间的地址相差262,144字节的倍数,而最多可以进行16次竞争,否则就要驱逐一个chunk)。

为了使得缓存关联效果更加明了,我需要重复地访问同一组中的16个以上的元素,通过如下方法证明:

public static long UpdateEveryKthByte(byte[] arr, int K)
{
    Stopwatch sw = Stopwatch.StartNew();
    const int rep = 1024*1024; // Number of iterations – arbitrary
    int p = 0;
    for (int i = 0; i < rep; i++)
    {
        arr[p]++;
        p += K;
        if (p >= arr.Length) p = 0;
    }
    sw.Stop();
    return sw.ElapsedMilliseconds;
}

该方法每次在数组中迭代K个值,当到达末尾时从头开始。循环在运行足够长(2^20次)之后停止。

timing

我使用不同的数组大小(每次增加1MB)和不同的步长传入UpdateEveryKthByte()。以下是绘制的图表,蓝色代表运行较长时间,白色代表较短时间:

蓝色区域(较长时间)表明当我们重复数组迭代时,更新的值无法同时放在缓存中。浅蓝色区域对应80毫秒,白色区域对应10毫秒。

让我们来解释一下图表中蓝色部分:

1.为何有垂直线?垂直线表明步长值过多接触到同一组中内存位置(大于16次)。在这些次数里,我的机器无法同时将接触过的值放到16路关联缓存中。

一些糟糕的步长值为2的幂:256和512。举个例子,考虑512步长遍历8MB数组,存在32个元素以相距262,144字节空间分布,所有32个元素都会在循环遍历中更新到,因为512能够整除262,144(译者注:此处一个步长代表一个字节)。

由于32大于16,这32个元素将一直竞争缓存里的16路槽位。

(译者注:为何512步长的垂直线比256步长颜色更深?在同样足够多的步数下,512比256访问到存在竞争的块索引次数多一倍。比如跨越262,144字节边界512需要512步,而256需要1024步。那么当步数为2^20时,512访问了2048次存在竞争的块而256只有1024次。最差情况下步长为262,144的倍数,因为每次循环都会引发一个缓存行驱逐。)

有些不是2的幂的步长运行时间长仅仅是运气不好,最终访问到的是同一组中不成比例的许多元素,这些步长值同样显示为蓝线。

2.为何垂直线在4MB数组长度的地方停止?因为对于小于等于4MB的数组,16路关联缓存相当于完全关联缓存。

一个16路关联缓存最多能够维护16个以262,144字节分隔的缓存行,4MB内组17或更多的缓存行都没有对齐在262,144字节边界上,因为16*262,144=4,194,304。

3.为何左上角出现蓝色三角?在三角区域内,我们无法在缓存中同时存放所有必要的数据,不是出于关联性,而仅仅是因为L2缓存大小所限。

举个例子,考虑步长128遍历16MB数组,数组中每128字节更新一次,这意味着我们一次接触两个64字节内存块。为了存储16MB数组中每两个缓存行,我们需要8MB大小缓存。但我的机器中只有4MB缓存(译者注:这意味着必然存在冲突从而延时)。

即使我机器中4MB缓存是全关联,仍无法同时存放8MB数据。

4.为何三角最左边部分是褪色的?注意左边0~64字节部分——正好一个缓存行!就像上面示例1和2所说,额外访问相同缓存行的数据几乎没有开销。比如说,步长为16字节,它需要4步到达下一个缓存行,也就是说4次内存访问只有1次开销。

在相同循环次数下的所有测试用例中,采取省力步长的运行时间来得短。

timing2

将图表延伸后的模型:

缓存关联性理解起来有趣而且确能被证实,但对于本文探讨的其它问题比起来,它肯定不会是你编程时所首先需要考虑的问题。

示例6:缓存行的伪共享(false-sharing)

在多核机器上,缓存遇到了另一个问题——一致性。不同的处理器拥有完全或部分分离的缓存。在我的机器上,L1缓存是分离的(这很普遍),而我有两对处理器,每一对共享一个L2缓存。这随着具体情况而不同,如果一个现代多核机器上拥有多级缓存,那么快速小型的缓存将被处理器独占。

当一个处理器改变了属于它自己缓存中的一个值,其它处理器就再也无法使用它自己原来的值,因为其对应的内存位置将被刷新(invalidate)到所有缓存。而且由于缓存操作是以缓存行而不是字节为粒度,所有缓存中整个缓存行将被刷新!

为证明这个问题,考虑如下例子:

private static int[] s_counter = new int[1024];
private void UpdateCounter(int position)
{
    for (int j = 0; j < 100000000; j++)
    {
        s_counter[position] = s_counter[position] + 3;
    }
}

在我的四核机上,如果我通过四个线程传入参数0,1,2,3并调用UpdateCounter,所有线程将花费4.3秒。

另一方面,如果我传入16,32,48,64,整个操作进花费0.28秒!

为何会这样?第一个例子中的四个值很可能在同一个缓存行里,每次一个处理器增加计数,这四个计数所在的缓存行将被刷新,而其它处理器在下一次访问它们各自的计数(译者注:注意数组是private属性,每个线程独占)将失去命中(miss)一个缓存。这种多线程行为有效地禁止了缓存功能,削弱了程序性能。

示例7:硬件复杂性

即使你懂得了缓存的工作基础,有时候硬件行为仍会使你惊讶。不用处理器在工作时有不同的优化、探试和微妙的细节。

有些处理器上,L1缓存能够并发处理两路访问,如果访问是来自不同的存储体,而对同一存储体的访问只能串行处理。而且处理器聪明的优化策略也会使你感到惊讶,比如在伪共享的例子中,以前在一些没有微调的机器上运行表现并不良好,但我家里的机器能够对最简单的例子进行优化来减少缓存刷新。

下面是一个“硬件怪事”的奇怪例子:

private static int A, B, C, D, E, F, G;
private static void Weirdness()
{
    for (int i = 0; i < 200000000; i++)
    {
        // do something...
    }
}

当我在循环体内进行三种不同操作,我得到如下运行时间:

           操作                    时间
A++; B++; C++; D++;     719 ms
A++; C++; E++; G++;     448 ms
A++; C++;                      518 ms

增加A,B,C,D字段比增加A,C,E,G字段花费更长时间,更奇怪的是,增加A,C两个字段比增加A,C,E,G执行更久!

我无法肯定这些数字背后的原因,但我怀疑这跟存储体有关,如果有人能够解释这些数字,我将洗耳恭听。

这个例子的教训是,你很难完全预测硬件的行为,很显然这涉及到执行单元里指令是怎样终止的,机器处理存储-命中-加载的速度,以及如何快速且优雅地处理试探性执行的循环展开(比如是否由于内部冲突而多次循环)。但这意味着你需要非常细致的流水线跟踪器和模拟器才能弄明白。在纸上预测流水线里的乱序指令是无比困难的工作,就算是设计芯片的人也一样。对于门外汉来说,没门,抱歉!你可以预测很多事情,但最终,衡量及验证你的假设非常重要。

总结:局部性原理和CPU流水线并发性

程序的运行存在时间和空间上的局部性,前者是指只要内存中的值被换入缓存,今后一段时间内会被多次引用,后者是指该内存附近的值也被换入缓存。如果在编程中特别注意运用局部性原理,就会获得性能上的回报。

比如C语言中应该尽量减少静态变量的引用,这是因为静态变量存储在全局数据段,在一个被反复调用的函数体内,引用该变量需要对缓存多次换入换出,而如果是分配在堆栈上的局部变量,函数每次调用CPU只要从缓存中就能找到它了,因为堆栈的重复利用率高。

再比如循环体内的代码要尽量精简,因为代码是放在指令缓存里的,而指令缓存都是一级缓存,只有几K字节大小,如果对某段代码需要多次读取,而这段代码又跨越一个L1缓存大小,那么缓存优势将荡然无存。

关于CPU的流水线(pipeline)并发性简单说说,Intel Pentium处理器有两条流水线U和V,每条流水线可各自独立地读写缓存,所以可以在一个时钟周期内同时执行两条指令。但这两条流水线不是对等的,U流水线可以处理所有指令集,V流水线只能处理简单指令。

CPU指令通常被分为四类,第一类是常用的简单指令,像mov, nop, push, pop, add, sub, and, or, xor, inc, dec, cmp, lea,可以在任意一条流水线执行,只要相互之间不存在依赖性,完全可以做到指令并发。

第二类指令需要同别的流水线配合,像一些进位和移位操作,这类指令如果在U流水线中,那么别的指令可以在V流水线并发运行,如果在V流水线中,那么U流水线是暂停的。

第三类指令是一些跳转指令,如cmp,call以及条件分支,它们同第二类相反,当工作在V流水线时才能通U流水线协作,否则只能独占CPU。

第四类指令是其它复杂的指令,一般不常用,因为它们都只能独占CPU。

如果是汇编级别编程,要达到指令级别并发,必须要注重指令之间的配对。尽量使用第一类指令,避免第四类,还要在顺序上减少上下文依赖。

参考资料

wiki上的CPU cache解析(中文版)(英文版)。

上海交通大学师生制作的一个关于cache映射功能、命中率计算的教学演示程序,模拟了不同关联模式下cache的映射和命中几率,形象直观。

网易数据库大牛@何_登成自制PPT《CPU Cache and Memory Ordering》,信息量超大!

南京大学计算机教学公开PPT,温馨提示,地址域名里面改变字段”lecture”后面的数字编号可切换课程;-)

Cache Refresh Best Pratice

更新缓存的Design Pattern有四种:Cache aside, Read / Write through, Write behind caching,我们下面一一来看一下这四种Pattern。

Cache Aside Pattern

这是最常用最常用的pattern了。其具体逻辑如下:

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

注意,我们的更新是先更新数据库,成功后,让缓存失效。那么,这种方式是否可以没有文章前面提到过的那个问题呢?我们可以脑补一下。

一个是查询操作,一个是更新操作的并发,首先,没有了删除cache数据的操作了,而是先更新了数据库中的数据,此时,缓存依然有效,所以,并发的查询操作拿的是没有更新的数据,但是,更新操作马上让缓存的失效了,后续的查询操作再把数据从数据库中拉出来。而不会像文章开头的那个逻辑产生的问题,后续的查询操作一直都在取老的数据。

这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。

那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。

但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

所以,这也就是Quora上的那个答案里说的,要么通过2PC或是Paxos协议保证一致性,要么就是拼命的降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,而Paxos太复杂。当然,最好还是为缓存设置上过期时间。

Read/Write Through Pattern

我们可以看到,在上面的Cache Aside套路中,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

Write Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

下图自来Wikipedia的Cache词条。其中的Memory你可以理解为就是我们例子里的数据库。

Write Behind Caching Pattern(Write Back)

Write Behind 又叫 Write Back。一些了解Linux操作系统内核的同学对write back应该非常熟悉,这不就是Linux文件系统的Page Cache的算法吗?是的,你看基础这玩意全都是相通的。所以,基础很重要,我已经不是一次说过基础很重要这事了。

Write Back套路,一句说就是,在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write back还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

但是,其带来的问题是,数据不是强一致性的,而且可能会丢失(我们知道Unix/Linux非正常关机会导致数据丢失,就是因为这个事)。在软件设计上,我们基本上不可能做出一个没有缺陷的设计,就像算法设计中的时间换空间,空间换时间一个道理,有时候,强一致性和高性能,高可用和高性性是有冲突的。软件设计从来都是取舍Trade-Off。

另外,Write Back实现逻辑比较复杂,因为他需要track有哪数据是被更新了的,需要刷到持久层上。操作系统的write back会在仅当这个cache需要失效的时候,才会被真正持久起来,比如,内存不够了,或是进程退出了等情况,这又叫lazy write。

在wikipedia上有一张write back的流程图,基本逻辑如下:

1)上面讲的这些Design Pattern,其实并不是软件架构里的mysql数据库和memcache/redis的更新策略,这些东西都是计算机体系结构里的设计,比如CPU的缓存,硬盘文件系统中的缓存,硬盘上的缓存,数据库中的缓存。基本上来说,这些缓存更新的设计模式都是非常老古董的,而且历经长时间考验的策略,所以这也就是,工程学上所谓的Best Practice,遵从就好了。

2)有时候,我们觉得能做宏观的系统架构的人一定是很有经验的,其实,宏观系统架构中的很多设计都来源于这些微观的东西。比如,云计算中的很多虚拟化技术的原理,和传统的虚拟内存不是很像么?Unix下的那些I/O模型,也放大到了架构里的同步异步的模型,还有Unix发明的管道不就是数据流式计算架构吗?TCP的好些设计也用在不同系统间的通讯中,仔细看看这些微观层面,你会发现有很多设计都非常精妙……所以,请允许我在这里放句观点鲜明的话——如果你要做好架构,首先你得把计算机体系结构以及很多老古董的基础技术吃透了

3)在软件开发或设计中,我非常建议在之前先去参考一下已有的设计和思路,看看相应的guideline,best practice或design pattern,吃透了已有的这些东西,再决定是否要重新发明轮子。千万不要似是而非地,想当然的做软件设计。

4)上面,我们没有考虑缓存(Cache)和持久层(Repository)的整体事务的问题。比如,更新Cache成功,更新数据库失败了怎么吗?或是反过来。关于这个事,如果你需要强一致性,你需要使用“两阶段提交协议”——prepare, commit/rollback,比如Java 7 的XAResource,还有MySQL 5.7的 XA Transaction,有些cache也支持XA,比如EhCache。当然,XA这样的强一致性的玩法会导致性能下降,关于分布式的事务的相关话题,你可以看看《分布式系统的事务处理》一文。