【问题标题】:Why is performance gain of C# SIMD low with larger arrays than tiny arrays?为什么 C# SIMD 的性能增益对​​于较大的阵列比微型阵列低?
【发布时间】:2019-12-22 15:00:02
【问题描述】:

我一直在自己编写一个深度学习库。在矩阵运算中,获得最佳性能对我来说是关键。我一直在研究编程语言及其在数值运算方面的表现。一段时间后,我发现 C# SIMDC++ SIMD 的性能非常相似。所以,我决定用 C# 编写这个库。

首先,我测试了C# SIMD(我测试了很多东西,但这里就不写了)。我注意到在使用较小的数组时效果更好使用更大的数组时效率不佳。我认为这很荒谬。通常情况下,事情越大,效率越高。

我的问题是“为什么在 C# 中使用更大的数组时矢量化的工作速度会变慢?”

我将使用 BenchmarkNet 分享基准(由我自己完成)。

Program.Size = 10

| Method |      Mean |     Error |    StdDev |
|------- |----------:|----------:|----------:|
|     P1 |  28.02 ns | 0.5225 ns | 0.4888 ns |
|     P2 | 154.15 ns | 1.1220 ns | 0.9946 ns |
|     P3 | 100.88 ns | 0.8863 ns | 0.8291 ns |

Program.Size = 10000

| Method |     Mean |    Error |   StdDev |   Median |
|------- |---------:|---------:|---------:|---------:|
|     P1 | 142.0 ms | 3.065 ms | 8.989 ms | 139.5 ms |
|     P2 | 170.3 ms | 3.365 ms | 5.981 ms | 170.1 ms |
|     P3 | 103.3 ms | 2.400 ms | 2.245 ms | 102.8 ms |

如您所见,我将大小增加了 1000 倍,这意味着将数组的大小增加了 1000000 倍P2 一开始需要 154 ns。在第二次测试中,花费了 170 毫秒,这是我们预期的 1000 倍以上。此外,P3 花费的时间正好是 1000 倍(100ns - 100ms)。但是,我想在这里指出的是,矢量化循环的 P1 的性能明显低于以前。我想知道为什么。

请注意,P3 独立于该主题。 P1 是 P2 的矢量化版本。所以,我们可以说向量化的效率是 P2/P1 就他们所花费的时间而言。我的代码如下:

矩阵类:

public sealed class Matrix1
{
    public float[] Array;
    public int D1, D2;
    const int size = 110000000;
    private static ArrayPool<float> sizeAwarePool = ArrayPool<float>.Create(size, 100);

    public Matrix1(int d1, int d2)
    {
        D1 = d1;
        D2 = d2;
        if(D1*D2 > size)
        { throw new Exception("Size!"); }
        Array = sizeAwarePool.Rent(D1 * D2);
    }

    bool Deleted = false;
    public void Dispose()
    {
        sizeAwarePool.Return(Array);
        Deleted = true;
    }

    ~Matrix1()
    {
        if(!Deleted)
        {
            throw new Exception("Error!");
        }
    }

    public float this[int x, int y]
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get
        {
            return Array[x * D2 + y];
        }
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        set
        {
            Array[x * D2 + y] = value;
        }
    }
}

程序类:

public class Program
{
    const int Size = 10000;

    [Benchmark]
    public void P1()
    {
        Matrix1 a = Program.a, b = Program.b, c = Program.c;
        int sz = Vector<float>.Count;
        for (int i = 0; i < Size * Size; i += sz)
        {
            var v1 = new Vector<float>(a.Array, i);
            var v2 = new Vector<float>(b.Array, i);
            var v3 = v1 + v2;
            v3.CopyTo(c.Array, i);
        }

    }

    [Benchmark]
    public void P2()
    {
        Matrix1 a = Program.a, b = Program.b, c = Program.c;
        for (int i = 0; i < Size; i++)
            for (int j = 0; j < Size; j++)
                c[i, j] = a[i, j] + b[i, j];
    }
    [Benchmark]
    public void P3()
    {
        Matrix1 a = Program.a;
        for (int i = 0; i < Size; i++)
            for (int j = 0; j < Size; j++)
                a[i, j] = i + j - j; 
                //could have written a.Array[i*size + j] = i + j
                //but it would have made no difference in terms of performance.
                //so leave it that way
    }


    public static Matrix1 a = new Matrix1(Size, Size);
    public static Matrix1 b = new Matrix1(Size, Size);
    public static Matrix1 c = new Matrix1(Size, Size);

    static void Main(string[] args)
    {
        for (int i = 0; i < Size; i++)
            for (int j = 0; j < Size; j++)
                a[i, j] = i;
        for (int i = 0; i < Size; i++)
            for (int j = 0; j < Size; j++)
                b[i, j] = j;
        for (int i = 0; i < Size; i++)  
            for (int j = 0; j < Size; j++)
                c[i, j] = 0;

        var summary = BenchmarkRunner.Run<Program>();
        a.Dispose();
        b.Dispose();
        c.Dispose();
    }
}     

我向您保证x[i,j] 不会影响性能。与使用x.Array[i*Size + j]相同

【问题讨论】:

  • P1 正在使用“new”,它正在调用一个类的构造函数,这会增加大量时间。
  • 尚未查看您的代码,但较大的尺寸可能会成为 SIMD 内存带宽的瓶颈。即在您遇到内存带宽瓶颈之前没有太多加速空间。但是,由于 L1d 缓存中的热数据可以跟上 SIMD ALU,您可以接近完整的 8 倍或任何超过标量的加速,例如用于 AVX 8x 32 位元素。
  • 不知道是不是和here一样的原因。
  • @GSerg:不,它正在使用BenchmarkRunner.Run&lt;Program&gt;(),这将阻止P1() 的不同调用之间的优化。它不能将实际工作提升或沉入基准重复循环之外,并且只能执行一次。
  • @jdweng 如果您查看深度汇编代码,新向量 不会创建对象。向量类是完全不同的。你应该搜索 c# simd。

标签: c# performance vectorization simd memory-bandwidth


【解决方案1】:

这可能不是全部:OP reports in comments 使用锯齿状数组将 P1 从 140 毫秒加速到 120 毫秒。

所以也许有一些额外的东西把它放在大箱子里。我会使用性能计数器来调查和检查ld_blocks_partial.address_alias(4k 别名-> 负载对商店的虚假依赖性)。和/或查看您从 C# 分配器获得的内存地址,也许看看它们是否接近但不完全相同相对于 4k 边界的对齐方式。

我认为在同一个集合中需要 3 个热缓存行不是问题; L1d 在任何 CPU 上都是 8 路关联的,可以通过 AVX 提供 > 4 倍的加速(即使用 256 位加载/存储和 ALU)。但是,如果您的所有数组相对于 4k 边界具有相同的对齐方式,那么当您访问相同的索引时,它们都会在 32kiB L1d 缓存中为相同的集合设置别名。

哦,这是一个理论:锯齿状数组错开页面行走,而不是所有 3 个流(2 个 src 1 个 dst)同时到达一个新页面并且都具有需要的 TLB 未命中散步。尝试确保您的代码使用 2M 大页面而不是仅 4k 以减少 TLB 未命中。 (例如,在 Linux 上,您将使用 madvise(buf, size, MADV_HUGEPAGE) 系统调用。)

检查dtlb_load_misses.miss_causes_a_walk 和/或dtlb_load_misses.stlb_hit 的性能计数器事件。存在 TLB 预取,因此将它们交错排列可以允许 TLB 预取同时处理一两个,而不是同时受到所有 3 个页面遍历的影响。


内存带宽的大尺寸瓶颈,不仅仅是 ALU

SIMD 不会增加可用内存带宽,只是增加数据输入/输出 缓存 的速度。它增加了您在大多数情况下实际使用的内存带宽。不过,用更少的指令执行相同的工作可以帮助 OoO exec 看得更远,更快地检测到 TLB 未命中。

大型数组的加速是有限的,因为标量已经接近主内存带宽的瓶颈。您的C[i] = A[i]+B[i] 访问模式是STREAM sum access pattern,一个 ALU 的最大内存访问手术。 (1D 与 2D 索引无关,您仍然只是读取/写入连续内存并执行纯垂直 SIMD float 加法。在 P1 情况下明确。)

使用小矩阵(10x10 = 100 float = 400 字节 * (2 个源 + 1 dst) = 1.2kB),您的数据可以在 L1d 缓存中保持热状态 所以缓存未命中不会成为 SIMD 循环的瓶颈。

如果您的 src + dst 在 L1d 高速缓存中处于热状态,您可以在每个向量 8 个 32 位元素的情况下接近标量 AVX 的 8 倍加速,假设 Haswell 或更高版本的 CPU 的峰值负载 + 存储吞吐量为 2x 32 -byte 向量加载 + 每个时钟周期 1x 32 字节向量存储。

实际上,对于小矩阵情况,您得到了 154.15 / 28.02 = ~5.5

实际的缓存限制显​​然排除了这一点,例如英特尔的优化手册列出了 Skylake 的 L1d 缓存的约 81 字节/时钟周期典型持续负载 + 存储带宽。但是对于 GP 整数加载 + 存储,对于 32 位操作数大小,Skylake 可以在每个周期维持 2 个加载 + 1 个存储,with the right loop. 因此,除了加载/存储 uop 吞吐量之外,还有某种微架构限制会减慢向量加载/储存一些。


你没有说你有什么硬件,但我猜是英特尔 Haswell 或更高版本。 “仅”5.5 倍加速可能是由于每次调用仅执行 12 或 13 次循环迭代的基准开销。

(100 个元素 / 8 elem/vec = 12.5。因此,如果您没有完成最后 4 个元素,则为 12,如果您因循环条件不是 i &lt; Size * Size - sz + 1 而超读了 4,则为 13)

Zen 的每个时钟 2 个 16 字节内存操作(最多其中一个可以是存储)将同样减慢标量和 AVX。但是,从 movss / addss xmm, mem / movss 的每个向量 1 个元素到同时执行 4 个元素的相同微指令,您仍然最多可以得到 4 倍的加速。在 Zen 1 上使用 256 位指令仅意味着每条指令 2 uop,每个时钟吞吐量限制相同 2 内存 uop。使用 2-uop 指令可以提高前端吞吐量,但这不是这里的瓶颈。 (假设编译器可以在 5 微秒或更短的时间内完成一个循环,它可以以每时钟 1 次迭代的速度发出,并且由于加载/存储端口的后端瓶颈,甚至无法运行那么快。)

这些结果在 Zen 2 上也有意义,我认为:256 位 SIMD 执行单元,我认为加载/存储端口也意味着当每条指令的工作量增加 8 倍时,您可以预期高达 8 倍的加速。

【讨论】:

  • 感谢您的详细解答。很高兴在这里见到一个合格的人。我有 kaby 湖 cpu i7 7700hq。当我将大小设置为 8(64 字节 / 8 = 每次调用 8 次循环迭代)时,性能增益约为 5.85。当 size = 10 时,约为 5.5。当 size = 32 时,约为 5.35。我希望我今天会详细阅读您写的答案。我现在不太能理解你的答案。但我理解内存瓶颈问题^^
  • @FarukNANE:我刚刚添加了一个关于锯齿状数组的猜测部分。
  • 说实话,我真的很感谢你的回答,但我是一个新的大二学生。我真的不知道您提到的一些术语,例如 4k 别名、alu、tbl 或缓存真正的作用。我觉得我的大脑要爆炸了^^。我昨天学了汇编语言(有点)^^。我一直在研究 1-2 周的优化。那是我很抱歉:D。但我想我在这里理解了这个概念
  • @FarukNANE:好的,那么是的,标量已经接近主存带宽,这对你来说是一张足够详细的图片。另请参阅How can cache be that fast? 了解一些 IvyBridge 读/写/复制带宽数字以了解情况。无论如何,如果您的问题的其他一些读者(现在和将来)至少有一些计算机架构方面的背景,希望他们能从我的回答中得到更多。
  • @FarukNANE:我试图让尽可能多的人在我的答案中理解关键点,并让我自己获得超级技术的乐趣:) 我认为在这种情况下,因为您确实了解内存带宽点。另外,感谢您检查 32x32 案例。我认为自 32 * 32 * 4 * 3 = ~12kiB 以来可能会看到更大的加速,它仍然适合最接近 CPU 内核的最小/最快数据缓存 (L1d)。
猜你喜欢
  • 2018-08-30
  • 2016-12-24
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-10-08
  • 2013-11-06
  • 1970-01-01
  • 2019-01-20
相关资源
最近更新 更多