漫游CPU缓存效应,让你的程序性能飙升!
共 8917字,需浏览 18分钟
·
2024-04-24 08:35
推荐一个原创技术号-非科班大厂码农,号主是机械专业转行进入腾讯的后端程序员!
大多数读者都知道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)下的运行时间:
理解缓存行对于某些类型的程序优化非常重要。例如,数据字节对齐可能觉定一次操作是接触一个还是两个缓存行。正如我们在上面的示例中看到的,这很容易意味着在未对齐的情况下,操作速度会慢两倍。
示例3:L1和L2缓存的大小
如今的计算机一般都配备两级( L1、L2)或三级(L3)高速缓存如果您想了解不同缓存的大小,可以使用CoreInfo SysInternals 工具,或使用GetLogicalProcessorInfo Windows API 调用。两者都将告诉你缓存行以及缓存本身的大小。
这是程序:
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)
}
示例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]++;
}
第一个循环体内,操作做是相互依赖的(译者注:下一次依赖于前一次):
示例5:缓存关联性
缓存设计的一个关键决定是确保每个主存块(chunk)能够存储在任何一个缓存槽里,或者只是其中一些(译者注:此处一个槽位就是一个缓存行)。
-
直接映射(Direct mapped cache) 每个内存块只能映射到一个特定的缓存槽。一个简单的方案是通过块索引chunk_index映射到对应的槽位(chunk_index % cache_slots)。被映射到同一内存槽上的两个内存块是不能同时换入缓存的。(译者注:chunk_index可以通过物理地址/缓存行字节计算得到) -
N路组关联(N-way set associative cache) 每个内存块能够被映射到N路特定缓存槽中的任意一路。比如一个16路缓存,每个内存块能够被映射到16路不同的缓存槽。一般地,具有一定相同低bit位地址的内存块将共享16路缓存槽。(译者注:相同低位地址表明相距一定单元大小的连续内存) -
完全关联(Fully associative cache) 每个内存块能够被映射到任意一个缓存槽。操作效果上相当于一个散列表。
为了使得缓存关联效果更加明了,我需要重复地访问同一组中的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)和不同的步长传入UpdateEveryKthByte()。以下是绘制的图表,蓝色代表运行较长时间,白色代表较短时间:
蓝色区域(较长时间)表明当我们重复数组迭代时,更新的值无法同时放在缓存中。浅蓝色区域对应80毫秒,白色区域对应10毫秒。
-
为何有垂直线?垂直线表明步长值过多接触到同一组中内存位置(大于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的幂的步长运行时间长仅仅是运气不好,最终访问到的是同一组中不成比例的许多元素,这些步长值同样显示为蓝线。 -
为何垂直线在4MB数组长度的地方停止?因为对于小于等于4MB的数组,16路关联缓存相当于完全关联缓存。一个16路关联缓存最多能够维护16个以262,144字节分隔的缓存行,4MB内组17或更多的缓存行都没有对齐在262,144字节边界上,因为16*262,144=4,194,304。 -
为何左上角出现蓝色三角?在三角区域内,我们无法在缓存中同时存放所有必要的数据,不是出于关联性,而仅仅是因为L2缓存大小所限。举个例子,考虑步长128遍历16MB数组,数组中每128字节更新一次,这意味着我们一次接触两个64字节内存块。为了存储16MB数组中每两个缓存行,我们需要8MB大小缓存。但我的机器中只有4MB缓存(译者注:这意味着必然存在冲突从而延时)。即使我机器中4MB缓存是全关联,仍无法同时存放8MB数据。 为何三角最左边部分是褪色的?注意左边0~64字节部分——正好一个缓存行!就像上面示例1和2所说,额外访问相同缓存行的数据几乎没有开销。比如说,步长为16字节,它需要4步到达下一个缓存行,也就是说4次内存访问只有1次开销。
在相同循环次数下的所有测试用例中,采取省力步长的运行时间来得短。
将图表延伸后的模型:
示例6:错误的缓存行共享
在多核机器上,缓存遇到了另一个问题——一致性。不同的处理器拥有完全或部分分离的缓存。在我的机器上,L1缓存是分离的(这很普遍),而我有两对处理器,每一对共享一个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;
}
}
在我的四核机上,如果我通过四个线程传入参数0,1,2,3并调用UpdateCounter,所有线程将花费4.3秒。
门示例7:硬件复杂性
即使你懂得了缓存的工作原理,但有时候硬件行为仍会使你惊讶。不同处理器在工作时有不同的优化细节。
下面是一个“硬件怪事”的奇怪例子:
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执行更久!
这个例子的给我们的启示是:你很难完全预测硬件的行为。你虽然可以预测很多事情,但最终需要验证你的假设,这点非常重要。
本文主要翻译自微软大牛Igor Ostrovsky一篇博文,此外加了一些自己的思考。
原文地址:https://igoro.com/archive/gallery-of-processor-cache-effects/
推荐阅读:
专注服务器后台技术栈知识总结分享
码农有道,和您聊技术,和您聊职场,和您聊互联网那些事!