ssslinppp

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

我的大多数读者都知道缓存是一种快速、小型、存储最近已访问的内存的地方。这个描述相当准确,但是深入处理器缓存如何工作的“枯燥”细节,会对尝试理解程序性能有很大帮助。

在这篇博文中,我将通过示例代码来说明缓存是如何工作的,以及它对现实世界中程序性能的影响。

虽然例子用的是 C#,但是不论哪种编程语言,对性能数据和最终结论的影响很小。

例1:内存访问和性能

你预计运行 循环2 比 循环1 快多少?

1
2
3
4
5
6
7
8
9
int[] arr = new int[64 * 1024 * 1024];
 
// 循环1
for (int i = 0; i < arr.Length; i++)
    arr[i] *= 3;
 
// 循环2
for (int i = 0; i < arr.Length; i += 16)
    arr[i] *= 3;

第一个循环对数组中的每个元素都乘以 3,而第二个循环对每隔 16 个元素的数据乘以 3。第二个循环只做了第一个循环的大约6%的计算量,但是在现代计算机上,这两个 for 循环运行的时间差不多相等:我电脑上分别是 80 和 78 毫秒。

这两个循环耗费相同时间的原因与内存有关。这些循环的运行时间主要由访问数组内存来决定,而不是整数乘法。并且我在例2中将解释,硬件对这两个循环执行相同的主存储器访问。

例2:缓存行(cache lines)的影响

(校对注:什么是 cache lines ?在内存和缓存直接传输的数据是大小固定的成块数据,称为 cache lines 。)

我们来深入地研究一下这个例子。我们尝试1和16之外的其他步长:

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

下面是这个循环运行不同步长(K)所花费的时间:

注意步长在1到16的范围内时,循环的运行时间几乎不变。但是从16开始,步长每增加一倍,其运行时间也减少一半。

其背后的原因是,如今的CPU并不是逐个字节地访问内存。相反,它以(典型的)64字节的块为单位取内存,称作缓存行(cache lines)。当你读取一个特定的内存地址时,整个缓存行都被从主内存取到缓存中。并且,此时读取同一个缓存行中的其他数值非常快!

因为16个整数占用了64字节(一个缓存行),因此步长从1到16的for循环都必须访问相同数量的缓存行:即数组中的所有缓存行。但是如果步长是32,我们只需要访问约一半的缓存行;步长是64时,只有四分之一。

理解缓存行对特定类型的程序优化非常重要。例如,数据对齐可能会决定一个操作访问一个还是两个缓存行。如我们上面例子中看到的,它意味着在不对齐的情形下,操作将慢一倍。

例3:一级缓存(L1)和二级缓存(L2)的大小

如今的计算机都有两级或者三级缓存,通常叫做L1,L2以及L3。如果你想知道不同缓存的大小,你可以使用SysInternals的CoreInfo工具,或者调用GetLogicalProcessorInfo Windows API。两个方法都会告诉你各级缓存的大小,以及缓存行的大小。

在我电脑上,CoreInfo报告我有一个32KB的L1数据缓存,一个32KB的L1指令缓存,和一个4MB的L2数据缓存。L1缓存是每个核心独享的,而每个L2缓存在两个核心间共享:

1
2
3
4
5
6
7
8
9
10
11
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
**--  Unified Cache       0, Level 2,    4 MB, Assoc  16, 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       1, Level 2,    4 MB, Assoc  16, LineSize  64

让我们通过实验来核实一下。要做到这一点,我们将以16个整数为步长遍历一个数组——这是修改每一个缓存行的一个简单方法。当我们遍历到最后一个值时,再回到开始向后遍历。我们将实验不同的数组长度,并且我们应该看到,每当数组长度超过一个缓存级别时,性能会随着降低。

下面是程序:

1
2
3
4
5
6
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)
}

下面是时间计时:

你可以看到在 32KB 和 4MB 后有明显的下降——这正是我电脑上L1和L2缓存的大小。

例4:指令级并行

现在,让我们看一些不一样的东西。

下面这两个循环中,你认为哪个会更快一些?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int steps = 256 * 1024 * 1024;
int[] a = new int[2];
 
// 循环1
for (int i=0; i < steps; i++) {
    a[0]++;
    a[0]++;
 }
 
// 循环2
for (int i=0; i < steps; i++) {
    a[0]++;
    a[1]++;
}

结果是,至少在我测试过的所有电脑上,第二个循环都比第一个循环快一倍。为什么呢?这与两个循环主体中指令间的依赖关系有关。

在第一个循环主体中,指令间的依赖关系如下:

但是第二个循环中,依赖关系是这样的:

现代处理器包含多个有并行机制的部件:它能同时读取L1的两个内存地址,或者同时运行两条简单的算数指令。在第一个循环内,处理器不能施展这种指令级并行;但是第二个循环中可以。

[更新]:reddit上很多人问编译器优化的事情,以及是否能够把 { a[0]++; a[0]++; } 优化成 { a[0]+=2; }。事实上,在涉及数组访问时,C# 编译器和 CLR JIT 不会做这个优化。我在 release 模式下(即包含优化选项)编译了所有的例子,并在JIT之后的代码中检查是否有这个优化,但是没有发现。

例5:缓存相关性

缓存设计的一个重要决策是,主存的每个块是否能够放入任何一个缓存槽,或某几个缓存槽中的一个。

(译者注:这里一个缓存槽和前面的缓存行相同;按照槽的大小,把主存分成若干块,以块为单位与缓存槽映射。下文提到的块索引chunk index等于主存大小除以槽大小)。

把缓存槽映射到内存块,有 3 种可选方案:

1. 直接映射缓存(Direct mapped cache)

每个内存块只能存储到一个缓存槽。一个简单方案是通过块索引把内存块映射到缓存槽(块索引 % 缓存槽数量(即取余数操作))。映射到同一个槽的内存块不能同时存储在缓存中。

2. N路关联缓存(N-way set associative cache)

每个内存块映射到N个特定缓存槽的任意一个槽。例如一个16路缓存,任何一个内存块能够被映射到16个不同的缓存槽。通常,具有相同低bit位地址的内存块共享相同的16个槽。

3. 完全关联缓存(Fully associative cache)

每个内存块可以被映射到任意一个缓存槽(cache slot)。事实上,缓存操作和哈希表很像。

直接映射会遭遇冲突的问题——当多个块同时竞争缓存的同一个槽时,它们不停地将对方踢出缓存,这将降低命中率。另一方面,完全关联过于复杂,很难在硬件层面实现。N路关联是典型的处理器缓存设计方案,因为它在实现难度和提高命中率之间做了良好的折衷。

例如,我电脑上的4M L2 缓存采用 16 路关联的方案。所有的64字节大小的内存块被分配到集合中(基于块索引的低字节),同一个集合中的块竞争使用 L2 缓存的16个槽。

由于 L2 缓存有65536 个槽,而每个集合需要16个槽,因此我们有4096个集合。由此,块索引的低12比特能够确定这个块所在的集合(2^12 = 4096)。进而可以计算出,相差262144字节倍数的地址(4096*64)会竞争同一个槽。

为了使缓存相关性的影响表现出来,我需要重复地访问同一个集合中的超过16个块(译者注:这样16个缓存槽容纳不下就会出现竞争)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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次),循环结束。

我使用不同尺寸的数组(每次递增1MB大小),和不同的步长K,来运行UpdateEveryKthByte()。下面的图呈现了结果,颜色越绿表示运行时间越长,颜色越白表示运行时间越短。

蓝色区域(运行时间较长)部分表示当我们重复更新数组值时,这些值不能同时保存在缓冲中。比较亮的区域对应的运行时间约是80毫秒,接近白色的区域对饮运行时间约是10毫秒。

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

1. 为什么会出现竖直线?

竖直线对应的这些步长,在一次循环中访问到的值跨越了同一个集合中的多个内存块(大于16个)。对于这些步长访问到的值,我电脑上的16路关联缓存不能同时保存这些值。

一些糟糕的步长是2的幂次方:256和512。例如当数组是8MB,步长是512时。8MB的缓存行包含地址互相间隔262144字节倍数的32个值。由于512能够整除262144,因此在一次循环内,这32个值都会被访问到。

由于32大于16,因此这32个值将一直竞争缓存中相同的16个槽。

而一些不是2幂次方的值则是因为不够幸运,它们刚好访问到了同一个集合内的很多值。这些步长同样会显示成蓝色线。

2. 为什么蓝色线在4MB位置结束了呢?

当数组长度为4MB或者更小时,16路关联缓存的表现和完全关联缓存相同。

16路关联缓存最多可以保存以262144字节长度分割的16个缓存行。在4MB中,由于16 * 262144 = 4194304 = 4MB,因此不会出现第17个或者更多个集合。

3. 为什么蓝色的三角形位于左上角?

在三角形的区域,我们不能把所需的数据同时放入缓存——与缓存相关性无关,而与L2缓存大小有关系。

举个数组长度为16MB、步长为128时的例子。我们重复地每隔128个字节更新数组中的值,即每次跨越了一个64字节的内存块。对于16MB的数组,每隔一个块存储到缓存,这样我们需要8MB大小的缓存。但是,我机器的缓存只有4MB。

即使我电脑上的4MB缓存使用完全关联的方式,它仍然无法容纳8MB的数据。

4. 为什么三角形最左侧颜色变淡了呢?

注意变淡部分是从0开始,到64结束——正好是一个缓存行!正如例1和例2中解释的,访问同一个缓存行内的其他数据非常快。例如,当步长为16时,需要4步到达下一个缓存行。因此,这4次内存访问的代价和1次访问差不多。

由于对于所有用例,步数是相同,因此步数越少,运行时间越短。

当扩展这个图时,规律是一样的:

缓存相关性非常有趣,并且容易被证实,但是与本文中讨论的其他问题相比,它并不是一个很大的问题。当你编写程序时,它不应该是你首先要考虑的问题。

例6:缓存行共享假象

在多核机器上,缓存遇到了另一个问题——一致性。不同的核有完全独立或者部分独立的缓存。在我的电脑上,L1缓存是独立的(这很常见);有两组处理器,每组处理器共享一个L2缓存。具体来说,现代多核机器拥有多层次的缓存机制,其中更快和更小的缓存属于独立的处理器。

当一个处理器在它的缓存中修改一个值时,其他的处理器不能再使用旧的值了。在所有的缓存中,这个内存地址将变成无效地址。另外,由于缓存的粒度是缓存行,而不是单独的字节,因此在所有缓存中的整个缓存行都变成无效!

为了演示这个问题,考虑下面的例子:

1
2
3
4
5
6
7
8
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;
    }
}

在我的4核机器上,如果我在4个线程中调用UpdateCounter,参数分别是0、1、2、3,所有线程运行结束后花费的时间是4.3秒。

另一方面,如果我分别使用16、32、48、64的参数调用UpdateCounter,只花费了0.28秒!

为什么呢?在第一种情形下,所有的4个数据很可能位于同一个缓存行。内核每递增一个数值,它就使包含这4个值的那个缓存行无效。其他所有内核访问这个数值时,就会出现缓存未命中的情况。线程的这种行为使缓存失去了效果,消弱了程序的性能。

例7:硬件复杂性

即使你了解缓存工作的基本知识,但有时候硬件仍然会让你惊讶。在优化措施、启发式调度以及工作的细节上,不同的处理器存在差异。

在一些处理器上,当两次访问操作分别访问不同的内存体(Memory Bank)时,L1缓存能够并行执行这两次访问;而如果访问相同的内存体,则会串行执行。同样的,处理器的高级优化也会使你吃惊。例如,我过去在多台电脑上运行过的“缓存行共享假象”例子,在我家里的电脑上需要微调代码才能得到期望的结果——对于一些简单的情况电脑能够优化执行,以减少缓存失效。

下面是一个表明“硬件离奇性”的例子:

1
2
3
4
5
6
7
8
private static int A, B, C, D, E, F, G;
private static void Weirdness()
{
    for (int i = 0; i < 200000000; i++)
    {
        <something>
    }
}

当我分别使用下面的三段不同代码替换“”时,我得到下面的运行时间:

1
2
3
4
<something>           Time
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更长的时间!

我并不清楚这些时间数字背后的原因,但是我猜测它与内存体(Memory Bank)有关。如果有人能够解释它的原因,我将非常愿意倾听。

这个例子告诉我们,很难完全地预测硬件性能。你确实可以预测很多方面,但是最后,你需要测试并验证你的预测结果,这非常重要。

结论

真心希望本文能够帮助你理解缓存工作的细节,并在你的程序中应用这些知识。


 



posted on 2015-08-22 15:14  ssslinppp  阅读(414)  评论(0编辑  收藏  举报