翻译自: http://igoro.com/archive/gall...
还有参考了文章: http://wsfdl.com/linux/2016/0...

原文代码是C#的,部分我改成了golang
我的电脑是Mac Pro2018,

例1. 内存访问性能

func clearCache() {
    var arr [1024 * 1024]int
    for i := range arr {
        arr[i]++
    }
    time.Sleep(time.Second)
}

func main() {
    var arr [4 * 1024 * 1024]int
    for i := range arr {
        arr[i]++
    }

    clearCache()

    // Loop 1
    tt := time.Now()
    for i := 0; i < len(arr); i++ {
        arr[i] *= 3
    }
    sub := time.Now().Sub(tt)
    fmt.Println("Loop 1", sub)

    clearCache()

    // Loop 2
    tt = time.Now()
    for i := 0; i < len(arr); i += 8 {
        arr[i] *= 3
    }
    sub = time.Now().Sub(tt)
    fmt.Println("Loop 2", sub)
}

运行结果

Loop 1 4.956348ms
Loop 2 3.191618ms

循环花费相同时间的原因与内存有关。这些循环的运行时间取决于对数组的内存访问,而不是整数乘法。而且,正如我将在示例 2 中解释的那样,硬件将为两个循环执行相同的主内存访问。

例2: Impact of cache lines

让我们更深入地探索这个例子。我们将尝试其他步长值,而不仅仅是 1 和 16
以下是此循环针对不同步长(K)的运行时间(我的电脑是64位的,所以从6开始就指数递减)
image.png

请注意,虽然 step 在 1 到 16 的范围内,但 for 循环的运行时间几乎没有变化。但是从 16 开始,每增加一倍,运行时间就会减半

这背后的原因是今天的 CPU 不会逐字节访问内存。相反,它们以(通常)64 字节的块(称为缓存行)获取内存。当您读取特定内存位置时,整个缓存行将从主内存中提取到缓存中。而且,从同一缓存行访问其他值很便宜!

由于 16 个整数占用 64 字节(一个缓存行),步长在 1 到 16 之间的 for 循环必须接触相同数量的缓存行:数组中的所有缓存行。但是一旦步长是 32,我们将只接触大约每隔一个缓存行,而一旦它是 64,每四个。

了解缓存行对于某些类型的程序优化很重要。例如,数据的对齐可以确定操作是触及一个还是两条高速缓存行。正如我们在上面的例子中看到的,这很容易意味着在未对齐的情况下,操作会慢两倍

例3: L1 and L2 cache sizes

今天的计算机带有两级或三级缓存,通常称为 L1、L2 和 L3。如果您想知道不同缓存的大小,可以使用 CoreInfo SysInternals 工具,或使用 GetLogicalProcessorInfo Windows API 调用。除了缓存大小外,这两种方法还会告诉您缓存行大小。

在我的机器上,CoreInfo 报告我有一个 32kB 的 L1 数据缓存、一个 32kB 的 L1 指令缓存和一个 256Kb的 L2 数据缓存, 一个4M的L3缓存。 L1 缓存是每核的,L2 缓存在核对之间共享:

$ sysctl -a

hw.pagesize: 4096
hw.pagesize32: 4096

hw.cachelinesize: 64
hw.l1icachesize: 32768
hw.l1dcachesize: 32768
hw.l2cachesize: 262144
hw.l3cachesize: 4194304

image.png

让我们通过实验来验证这些数字。为此,我们将遍历一个每 16 个整数递增的数组——一种修改每个缓存行的廉价方法。当我们到达最后一个值时,我们循环回到开头。我们将尝试使用不同的阵列大小,并且应该看到阵列溢出一个缓存级别后,阵列大小的性能下降。

这是程序

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]++; }

事实证明,第二个循环比第一个循环快两倍,至少在我测试的所有机器上都是如此。为什么?这与两个循环体中操作之间的依赖关系有关。(我本地是一样快)

在第一个循环体中,操作相互依赖如下
image.png
但是在第二个示例中,我们只有这些依赖项:
image.png
现代处理器的各个部分都具有一点并行性:它可以同时访问 L1 中的两个内存位置,或者执行两个简单的算术运算。在第一个循环中,处理器无法利用这种指令级并行性,但在第二个循环中,它可以。

[更新]:reddit 上的很多人都在询问编译器优化,以及是否 { a[0]++; [0]++; } 只会优化为 { a[0]+=2; }.事实上,C# 编译器和 CLR JIT 不会做这种优化——当涉及到数组访问时不会。我在发布模式下构建了所有测试(即使用优化),但我查看了 JIT-ted 程序集以验证优化不会扭曲结果。

例5 Cache associativity

缓存设计中的一个关键决定是主内存的每个块是可以存储在任何缓存槽中,还是仅存储在其中的一些槽中。

有三种可能的方法将缓存槽映射到内存块;

  • Direct mapped cache: 数据 A 在 cache 的存放位置只有固定一处。
  • N-way set associative cache: 数据 A 在 cache 的存放位置可以有 N 处。
  • Full associative cache: 数据 A 可存放在 cache 的任意位置。

从硬件的角度出发,direct mapped cache 设计简单,full associative cache 设计复杂,特别当 cache size 很大时,硬件成本非常之高。但是在 direct mapped cache 下数据的存放地址是固定唯一的,所以容易产生碰撞,最终降低 cache 的命中率,影响性能。在成本和性能的权衡下,当前的 CPU 都是 N-way set associative cache,N 通常为 4,8 或 16。

以大小为 32 KB,cache line 的大小为 64 Byte 的某 cache 为例,对于不同存放规则,其硬件设计也不同,下列图片依次展示其原理。

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()。这是结果图,蓝色代表长时间运行,白色代表运行时间短:

image.png

蓝色区域(运行时间长)是更新值无法同时保存在缓存中的情况,因为我们反复迭代它们。亮蓝色区域对应约 80 毫秒的运行时间,接近白色的区域对应约 10 毫秒。

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

  1. 为什么是垂直线?垂直线显示了触及同一组中太多内存位置 (>16) 的步长值。对于这些步骤,我们不能同时在我的机器上的 16 路关联缓存中保存所有接触的值。

一些错误的步长值是 2 的幂:256 和 512。例如,考虑 8MB 阵列上的步长 512。一个 8MB 的高速缓存行包含 32 个值,它们的间距为 262,144 字节。所有这些值都将在我们循环的每次传递中更新,因为 512 除以 262,144。

由于 32 > 16,这 32 个值将继续竞争缓存中相同的 16 个插槽。

一些不是 2 的幂的值只是不幸的,并且最终会访问来自同一集合的不成比例的许多值。这些步长值也将显示为蓝线。

  1. 为什么垂直线在 4MB 阵列长度处停止?在 4MB 或更小的阵列上,16 路关联缓存与完全关联缓存一样好。

一个 16 路关联高速缓存最多可容纳 16 条高速缓存行,这些行是 262,144 字节的倍数。没有一组 17 个或更多的缓存线全部对齐在 4MB 内的 262,144 字节边界上,因为 16 * 262,144 = 4,194,304。

  1. 为什么是左上角的蓝色三角形?在三角形区域中,我们不能同时将所有必要的数据保存在缓存中……不是因为关联性,而仅仅是因为 L2 缓存大小限制。

例如,考虑第 128 步的长度为 16MB 的数组。我们重复更新数组中的每 128 个字节,这意味着我们每隔 64 字节的内存块就会接触一次。要存储 16MB 阵列的所有其他缓存行,我们需要 8MB 缓存。但是,我的机器只有 4MB 的缓存。

即使我机器上的 4MB 缓存是完全关联的,它仍然无法容纳 8MB 的数据。

  1. 为什么三角形在左边淡出?请注意渐变从 0 到 64 字节 - 一个缓存行!如示例 1 和示例 2 中所述,对同一高速缓存行的额外访问几乎是免费的。例如,当步进 16 个字节时,需要 4 个步骤才能到达下一个缓存行。因此,我们以一次的价格获得了四次内存访问。

由于所有情况下的步数都相同,因此更便宜的步会导致更短的运行时间。

当您扩展图表时,这些模式将继续保持:

image.png

缓存关联性理解起来很有趣,当然可以演示,但与本文中讨论的其他问题相比,它往往不是一个问题。在您编写程序时,这当然不应该放在您的脑海中。

示例 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 秒才能完成所有线程。

另一方面,如果我使用参数 16、32、48、64 调用 UpdateCounter,操作将在 0.28 秒内完成!

为什么?在第一种情况下,所有四个值很可能最终出现在同一缓存行上。每次内核增加计数器时,它都会使保存所有四个计数器的缓存行无效。所有其他内核在下次访问自己的计数器时都会遇到缓存未命中。这种线程行为有效地禁用了缓存,削弱了程序的性能。

示例 7:硬件复杂性


xxx小M
30 声望11 粉丝

暂时放一些读书笔记, 很多内容没有整理好