绝大多数我的读者知道cache是一个快速但是小的内存用来存储最近访问的内存地址。这个描述相当准确,但是处理器缓存的”烦人”细节能帮助你更好的理解程序的性能。
在这片博客里,我将用代码示例来说明缓存如何工作的方方面面,和是什么影响了实际程序的性能。
Example 1: Memory accesses and performance
你认为loop2 比 loop1 快多少?
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;
第二个循环只做了大约第一个循环6%的工作,但是在现代计算机上,这两个循环话费同样的时间:分别是80 和 78 ms 在我的计算机上。
这两个循环话费相同时间的原因与内存有关。运行时间主要是内存访问时间,而不是整数相乘的时间。我会再例2中解释, 在两个循环中硬件会进
行相同的内存访问。
Example 2: Impact of cache lines
让我们更深入的探索这个例子, 我们将尝试更多的步长,不仅仅是1和16:
for (int i = 0; i < arr.Length; i += K) arr[i] *= 3;
下面是不同步长的运行时间:
注意步长为1到16时,运行时间几乎没有变化。 但是从16往后,运行时间随着步长加倍而减半。
原因是现代的cpu不会逐字节的访问内存。取而代之的是以块为单位访问内存,典型的是64字节,称为缓存行。当你访问一个特定的内存位置,整个
缓存行会被从主存读到缓存。 同时,从这个缓存行中访问其他的数据会很快。
因为16个int是64字节,所以从1-16步长的循环访问同样数量的缓存行:所有数组的所有缓存行。但是一旦步长变为32, 我们只需要每隔一个缓存行访问,
如果步长为64,只需每隔四个访问。
理解缓存行对特定类型的优化很重要。例如,数据的对齐决定了一个操作是访问一个还是两个缓存行。就像我们在上面的例子中看到的,不对齐意味着
操作慢两倍。
【备注】自己在一个16核机器上测试的结果,与作者得到的测试结果不一致
step: 1, time: 0.29
step: 2, time: 0.16
step: 4, time: 0.08
step: 8, time: 0.05
step: 16, time: 0.04
step: 32, time: 0.03
step: 64, time: 0.01
step: 2, time: 0.16
step: 4, time: 0.08
step: 8, time: 0.05
step: 16, time: 0.04
step: 32, time: 0.03
step: 64, time: 0.01
Example 3: L1 和 L2 缓存大小
现在计算机通常有两级或三级cache,一般叫做L1, L2, L3. 可通过CoreInfo SysInternals 工具,或者用GetLogicalProcessorInfo ,获得cache的大小信息。
我机器有一个32KBL1 数据cache,32KB L1 指令缓存,和一个4MB L2 数据cache。 L1 cache是每个处理器独享的,L2是两个处理器共享。
让我们通过实验验证一下。我们通过步长16来增加一个数组--一个廉价的修改每一个缓存行的方法。当达到最后一个数据时,返回从头开始。我们会测试
不同的数据大小,来观察数组大小冲过cache大小时,性能的下降。
程序:
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 cache的大小。
Example 4: Instruction-level parallelism 指令级别的并行
现在,让我们来看一些不同的东西。下面两个循环,你认为哪个会更快?
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]++; }
结果是第二个循环比第一个循环快两倍,为什么?这与操作间的依赖有关系。
在第一个循环中操作有如下的依赖:
但在第二个循环中,只有如下的依赖:
现在处理器有很多部分组成,其中有一些并行模板:它可以同时访问L1中的两个内存地址,或者同时执行两个简单的算术运算。在第一个循环中,处
理器无法利用这种指令并行,但在第二个循环中可以。
【更新】许多reddit上的人问到编译器优化,{a[0]++;a[0]++}是否会被优化为{a[0]+=2;}。事实上,当涉及到数组访问时,c#编译器和CLR JIT不会做这种
优化。所有粒子我都在release模式下测试,但是我会检查JIT-ted 验证优化不会对结果造成偏差。
缓存设计的一个关键是每块内存可被存储在每个缓存slot内,还是只能存储在某些slot内。
有三种方法来映射cache slot 和内存块:
1. 直接映射到缓存:
每块内存只能被映射到一个特定的缓存slot。 一个简单方法是利用chunk_index(chunk_index % cache_slots)来计算映射到哪个slot。 映射到同一个slot的内存块不能同时存储在缓存中。
2. N路集合关联缓存
每块内存可被存储在N个特定slots的任何一个中。例如,在一个16路的缓存中,每个内存块可以被存储在16个不同的slot中。通常,内存块index数的低位相同的块共享这16个slot。
3. 全关联缓存
每块内存可被存储在任意一个缓存slot中。缓存可以用hash表有效实现。
直接映射缓存会有冲突,当多个变量竞争同一个缓存slot时,会相互排挤,大大降低命中率。另一方面,全相连缓存在硬件实现上很复杂并且代价高。N路集合相关缓存是典型的处理器缓存解决方案,在实现复杂度和高命中率上有很好的权衡。
例如, 我的机器4ML2缓存分16路。所有64字节的内存块被分配到各个集合(通过块索引的低位),在同一个集合中的内存块竞争这16个slot。
![]()
![]()
L2有65,536个slot(每个大小为64字节),每个set有16个slot,所以一共有4096个set。所以chuck index的低12bit决定内存块落到哪个set中。 所以,地址为262,144(4096*64)倍数的缓存行会竞争相同的slot 集合。在我的机器上最多可以缓存16个这样的缓存行。
为了让缓存结合性的影响变得明显,我需要在同一个集合中重复访问超过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的值进行操作。到数组结尾后,从头开始。
每次设置不同的数组大小和不同的步长,多次运行UpdateEveryKthByte(),得到下图,蓝色表示长的运行时间,白色表示短的运行时间:
蓝色区域表示当我们重复遍历时,更新值不能同时保持在缓存中。
解释一下图标的蓝色部分:
1. 为什么会出现蓝色的竖线?蓝色竖线表示步长会访问很多(>16)相同集合中的内存。对于这些步长我们不能同时把所有的访问内存保持在一个16
路相关的缓存中。
一些很差的步长是2的整数次幂:256 和 512。 例如,考虑步长512和数组大小为8M. 一个8M的缓存行包含32个被262,144分割的值。所有这些值
都会被我们的每一次循环更新,因为262,144 可以被512整除。
所以这32个值会互相竞争同一个16slot的集合。
一些不是2的整数次幂的也不幸地不成比例的过多访问同一个集合中的值.(什么样的值会不幸?)
2. 为什么竖线会在数据大小为4M是结束? 当数组大小为4MB或者更小时,16路相关的cache正好跟全相关缓存一样。262,144*14 = 4M,一个16路
的相关cache能缓存所有以262,144对齐的数据。
3. 为什么蓝色的三角形在左上方? 在三角形区域,我们不能同时保存必须的数据...不是因为相关方式,而是简单的因为L2缓存大小限制。
例如,假设数组大小为16MB步长为128,。 我们不断更新间隔为128的字节,这意味着我们会访问另外的64个字节的内存块。所以为了保存每一个
缓存行,我们需要8M的cache。但我的机器只有4MB的缓存。即使采用全相关,仍然不能缓存8MB的数据。
4. 为什么在左边三角形淡化?步长从0到64, 一个缓存行!就像在example1和2里所阐释的,额外的对同一个缓存行的访问几乎是免费的。例如,如果
步长为16, 每四步会访问另一个缓存行,所以我们用一行的代价得到了四次内存访问。
当你继续扩展这个图时,这些规则仍然成立:
缓存相关理解起来很有意思,并且可以证明, 但是相较本门讨论的问题更倾向不是一个问题。当写程序时,它肯定不会首先出现在你的脑海中。
Example 6: False cache line sharing
在多核机器上,缓存碰到另一个问题--一致性。不同的核有完全或部分独立的缓存。 在我的机器上,L1cache 是独立的,有两对处理器,每对共享
一个L2 缓存。细节各不相同,现代多核机器有多层次的缓存层级,更小的和更快的缓存属于单独的处理器。
当一个处理器修改一个它缓存中的值,其他处理器也不能用旧值。所有缓存中的那个内存位置都要失效。更多的,缓存操作的粒度是缓存行,所以不只是那些字节,整个缓存行都要失效。
为了证明这个问题,看下面的例子:
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;
}
}
在我的四核机器上,如果我调用UpdateCounter 用参数0,1,2,3在四个不同的线程中,需要4.3秒。另一方面,如果用16,32,48,64调用只需0.28秒。
为什么呢?第一种情况下,四个值很可能出现在同一个缓存行中。每次一个处理器增加counter,会使整个缓存行失效。其他的处理器在访问自己的counter是会越到缓存缺失。这种线程行为有效的禁用了缓存,严重影响了程序性能。
Example 7:Hardware complexities
即使你知道基本的cache工作方式,硬件有时候也会让你惊讶。不同处理器在优化,启发式和微妙的做事方式上不同。
在一些处理器上,L1cache 可以并行处理两个访问,如果他们访问的缓存行属于不同的bank,如果属于同一个bank在串行。而且处理器聪明的优化会让你惊讶。例如,我过去在多台机器上用过的假共享的例子在我家机器上不能很好的工作--我家机器可以在家单情况下优化执行减少缓存失效。
这是一个硬件不可思议的例子:
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
增加A,B,C,D,话费比增加A,C,E,G更长的时间。更奇怪的是增加A.C话费比增加A,C,E,G更长的时间。
我不能确定背后的原因,但是我怀疑与内存有关。如果有些人能解释,我会怀着好奇心去听。这个例子的教训是很难完全预测硬件的行为。有很多你可以预测的,但最终测量和验证你的假设是很重要的。