翻译还待校正。。。
cache 是一种快速而小的缓存设备,cache存储了最近访问的memory数据。 这种描述是相当准确的,但是如果了解 cache 工作的细节将对改善程序的性能有很大的帮助。
?在这篇博客,我将通过代码示例来说明缓存是如何工作的 和 cache是如何影响程序性能的。
?例子用 C# 描述,这并不影响分析和结论。 原文地址Gallery of Processor Cache Effects
一、内存访问和性能
如下一段代码,相比于 Loop 1,?你觉得 Loop 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;
很显然,Loop 2 的迭代次数大概是 Loop 1 迭代次数的 6% 。但是实验结果显示,两个for循环花费的时间分别︰ 80 和 78 ms。
?这种现象的原因是什么?两个循环的运行时间,主要在于对数组的memory访问,而不是整数乘法。 总之, IO 是费时的。
实际上,Loop 1 和 Loop 2 执行了相同的memory访问。
二、缓存行的影响
如下一段代码,我们改变for循环的增长步长。
for (int i = 0; i < arr.Length; i += K)
arr[i] *= 3;
以下是不同步长值 (K) 时,循环的运行时间:
?从上图可以看出,步长在1 到 16 范围内时,for 循环的运行时间几乎没有变化。但是从 16 起,每当我们增加一倍步长,运行时间减半。
?出现上图现象的原因是,CPU 并不是一个字节一个字节的访问内存。相反,他们每次访存操作都将获取一个内存块chunk(通常为64bytes),称为 缓存行( cache line )。所以,当你读取一个特定的内存地址时,从这个内存地址开始的整个 cache line 都将从 memory 加载到 cache 。这样的话,如果下一次要访问的数据正好在该 cache line 中的话,那么将不会产生额外的访问开销(cache 命中了,就不用去memory中获取)!
?由于 16 个 int 刚好占用 64 个字节(cache line 大小),所以for循环在每 1~16 次迭代之间要处理的数据刚好位于同一个cache line中。但是一旦步长变为32,上述循环的memory访问次数就变成了 arr.Length / 32 , 之前是arr.Length / 16。
理解cache line对于优化程序有很大的帮助。例如,数据的对齐方式将会影响一次操作中的数据是否位于不同的cache line中。根据上述的示例,我们可以发现,在未对齐的情况下,该操作将慢一倍。
小结: 每次读内存,都是读取一个cache line大小,而非单个字节。
三、L1 & L2 cache sizes
?现在的计算机都有 2级或者3级 的缓存,通常被称为 L1、 L2 和 L3。如果你想要知道不同level 缓存的大小,你可以使用 CoreInfo SysInternals工具,或者使用 GetLogicalProcessorInfoWindows API 调用。这两种方法都能获得高速缓存 cache 和 cache line 的大小。
?在我的机器,CoreInfo 报告有 32 kB L1 数据缓存(data cache)、 32kb L1 指令缓存(instruction cache)和 4 MB L2 数据缓存。其中 L1 cache是每个core私有的,L2 cache是共享的︰
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
让我们通过实验来验证这些数字。如下一段代码,我们从下标0开始,然后每隔16个位置访问数组元素。当下标增长到最后一个值时,循环又回到了起点。通过改变数组的大小,我们会发现当数组大小超过了cache size时,性能开始下降。
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之后,性能开始下降。 —— 32kb 对应L1 data cache的大小 , 4MB对应L2 cache的大小。
小结:L1 和 L2 的容量是有限的,当L1 被“填满时”,再有想填入L1的 cache line,就得考虑 evcit 其他已经在L1中的 cache line。
四、指令级并行
现在,让我们看看一些不同的东西。以下两个循环,你希望哪一个更快?
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]++;
}
结果显示,Loop2比Loop1快2倍,至少在我所使用的机器上是这样。为什么呢?我们来看看它的执行过程:
在Loop1中,整个执行过程如下:
在Loop2中,整个执行过程如下:
?
这是为什么?
随着现代处理器的发展,CPU的计算能力有了很大的改观,特别是在并行度方面。现在的单个 CPU core,它可以在同一时刻,访问两个不同内存位置的 L1 cache 或 执行两个简单的算术运算 ( 指令 )。在Loop 1中,由于访问相同的内存位置,CPU 不能利用指令级并行性,但在第二个循环中,它可以。
批注:
指令级并行,是指单个 CPU core 能同时执行多条指令。
一般而言,如果程序中相邻的一组指令是相互独立的,即不竞争同一个功能部件、不相互等待对方的运算结果、不访问同一个存储单元,那么它们就可以在处理器内部并行地执行。总之一句话就是,互不干扰 的指令才能并行执行。
指令级并行依赖的CPU技术有 流水线 、多指令发射、超标量、乱序执行 和 超长指令字。
附议: Reddit上有很多人都好奇编译器优化的问题。对于{a[0] + +; a[0] + +;}
,是否会优化为{[0] = 2;}
。而实际上,C#编译器和CLR JIT并不会做这种优化。
五、Cache associativity
思考一下: memory 里的任何一个 chunk 是否都可以映射到任何一个 cache slot 中,还是只有一部分可映射。 memory chunk 和 cache slot 大小一样,都是 64 bytes(与CPU架构相关,目前大部分处理器都是64bytes)。
cache slot 与 memory chunk 是如何建立映射关系的?有以下三种可能的方案:
- 1. Direct mapped cache
每一个 memory chunk 只能映射到一个特定的 cache slot。
具体做法,通过 chunk_index % cache_slots 求出每一个 memory chunk 对应的 cache slot。显然,这是一种“多对一”的关系。而隐含的问题是,隐射到同一个cache slot的两个memory chunk是无法同时加载到同一cache中的(你要进来,它就得出去)。
-> 每个 memory chunk 在 cache 中只有一个候选位置。
- 2. N-way set associative cache
每个memory chunk 可以映射到N个特定cache slot中的任何一个(简称N-way cache)。比如说,一个16-way cache,表示每个memory chunk可以映射到16个不同的cache slot。
-> 每个memory chunk在cache中有多个候选位置。
- 3. Fully associative cache
每个memory chunk可以映射到任何一个cache slot中。cache的管理可以像hash table一样高效。
Direct mapped cache 方案很容易产生冲突 —— 当多个value竞争同一cache slot时,将因为冲突而持续的互相evict,从而导致cache命中率下降。Fully associative cache 方案是复杂的,而且实现起来非常昂贵。N-way set associative caches 方案是典型的cache管理方案,这种方案在简单的实现和命中率之间做了 trade off。
例如,我机器上的 4MB L2 cache 是16-way。所有的 64-byte memory chunk 可划分成多个set,位于同一个set的memory chunk将会竞争16个候选cache slot。
?为了使 Cache associativity 的影响变得明显,我需要在同样的set中重复访问超过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,依次访问数组arr[]。一旦到达数组尾,又从头开始。直到循环迭代 2^20 次之后。
通过传入不同大小的数组(以1MB为增量) 和 不同的步长,我们调用 UpdateEveryKthByte()
。下图是运行结果。
TODO
六、Cache line 假共享
在多核CPU上,cache 还会面临一个问题 —— cache 一致性。
现在大多数机器都是3级cache,其中L1 和 L2 为每个core私有的,L3是共享的。在此,为了方便说明问题,我们假设只有2级cache,其中L1私有,L2共享。
如下图,在memory中有一个 val==1
的变量,在多线程情况下,假设有2个分别运行在 core 1 和 core 2 上的线程(假设分别为 t1 和 t2),并且各自私有的 L1 cache 分别缓存了一份该变量。
如果此时,t1线程修改 val 为 2,那么很显然,t2 和 memory 中的 val 此时都将变为无效。
这就出现了 cache 不一致的问题。此时要做的就是,同步 memory 和 core 2 的 L1 cache 中的 val 值。(如何解决cache一致性问题不在此篇blog的讨论范围)。
我们通过下面一段代码,来看看 cache一致性 会造成多大的开销?
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-core机器上做实验,如果在4个不同的线程中分别传入参数0,1,2,3,那么4个线程全部执行完将花费4.3s。
- 如果传入的参数分别是 16,32,48,64,那么4个线程全部执行完只需要0.28s。
原因是什么呢?在第一个例子中,4个线程处理的数据很可能会是在同一个cache line中,那么每一次更新数组元素值的时候,就有可能会导致缓存了同样数据的 cache 出现 cache 不一致(失效)的情况。
七、硬件多样性
纵然了解了 cache 的一些特性,但是硬件之间仍然有许多差异。不同的 cpu,适应不同的优化方式。
对于某一些处理器,能够并行的访问位于不同 cache bank 中的非连续/连续cache line,和位于同一 cache bank 中的连续cache line。
如下一段代码:
private static int A, B, C, D, E, F, G;
private static void Weirdness()
{
for (int i = 0; i < 200000000; i++)
{
<something>
}
}
在<something>
中分别填入以下3条语句,结果如下:
<something> Time
A++; B++; C++; D++; 719 ms
A++; C++; E++; G++; 448 ms
A++; C++; 518 ms
出现上述情况的原因是什么?很好奇,但是我也不清楚。如果有人能够清楚分析,我也很乐意学习。
总之,各大商家提供的硬件各有差异,硬件自有其复杂性。