使用 SIMD (System.Numerics) 编写向量和函数并使其比 for 循环更快

JAl*_*lex 5 c# arrays performance simd avx

double[]我编写了一个函数来使用 SIMD 将数组的所有元素相加(System.Numerics.Vector,性能比 na\xc3\xafve 方法差。

\n

在我的电脑上Vector<double>.Count是 4,这意味着我可以创建一个包含 4 个值的累加器,并运行数组,按组将元素相加。

\n

例如,一个 10 元素数组,带有 4 元素累加器和 2 个剩余元素,我会得到

\n
//     | loop                  | remainder\nacc[0] = vector[0] + vector[4] + vector[8]\nacc[1] = vector[1] + vector[5] + vector[9]\nacc[2] = vector[2] + vector[6] \nacc[3] = vector[3] + vector[7] \n
Run Code Online (Sandbox Code Playgroud)\n

和结果sum = acc[0]+acc[1]+acc[2]+acc[3]

\n

下面的代码产生了正确的结果,但与仅将值相加的循环相比,速度不高

\n
public static double SumSimd(this Span<double> a)\n{\n    var n = System.Numerics.Vector<double>.Count;\n    var count = a.Length;\n    // divide array into n=4 element groups\n    // Example, 57 = 14*4 + 3\n    var groups = Math.DivRem(count, n, out int remain);\n    var buffer = new double[n];\n    // Create buffer with remaining elements (not in groups)\n    a.Slice(groups*n, remain).CopyTo(buffer);\n    // Scan through all groups and accumulate\n    var accumulator = new System.Numerics.Vector<double>(buffer);\n    for (int i = 0; i < groups; i++)\n    {\n        //var next = new System.Numerics.Vector<double>(a, n * i);\n        var next = new System.Numerics.Vector<double>(a.Slice(n * i, n));\n        accumulator += next;\n    }\n    var sum = 0.0;\n    // Add up the elements of the accumulator vs\n    for (int j = 0; j < n; j++)\n    {\n        sum += accumulator[j];\n    }\n    return sum;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

所以我的问题是为什么我没有意识到 SIMD 的任何好处?

\n
\n

基线

\n

基线代码如下所示

\n
public static double LinAlgSum(this ReadOnlySpan<double> span)\n{\n    double sum = 0;\n    for (int i = 0; i < span.Length; i++)\n    {\n        sum += span[i];\n    }\n    return sum;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

在对 SIMD 代码进行基准测试时,与上述代码相比,SIMD 代码慢 5\xc3\x97 size=7, 慢 2.5\xc3\x97 size=144, 大约相同size=770。

\n

我正在使用 运行发布模式BenchmarkDotNet。这是驱动程序类

\n
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]\n[CategoriesColumn]\npublic class LinearAlgebraBench\n{\n    [Params(7, 35, 77, 144, 195, 311, 722)]\n    public int Size { get; set; }\n\n    [IterationSetup]\n    public void SetupData()\n    {\n        A = new LinearAlgebra.Vector(Size, (iter) => 2 * Size - iter).ToArray();\n        B = new LinearAlgebra.Vector(Size, (iter) => Size/2 + 2* iter).ToArray();\n    }\n\n    public double[] A { get; set; }\n    public double[] B { get; set; }\n\n    [BenchmarkCategory("Sum"), Benchmark(Baseline = true)]\n    public double BenchLinAlgSum()\n    {\n        return LinearAlgebra.LinearAlgebra.Sum(A.AsSpan().AsReadOnly());\n    }\n    [BenchmarkCategory("Sum"), Benchmark]\n    public double BenchSimdSum()\n    {\n        return LinearAlgebra.LinearAlgebra.SumSimd(A);\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n

Jon*_*asH 6

我建议您看一下这篇文章,探讨.Net 中的 SIMD 性能。

使用常规向量化进行求和的整体算法看起来相同。一个区别是,在对数组进行切片时可以避免乘法:

while (i < lastBlockIndex)
{
    vresult += new Vector<int>(source.Slice(i));
    i += Vector<int>.Count;
}
Run Code Online (Sandbox Code Playgroud)

一次乘法对性能的影响应该相当小,但对于这种代码来说,它可能是相关的。

还值得注意的是,编译器似乎无法使用通用 API 生成非常高效的 SIMD 代码。对 32768 项求和的性能:

  • 展开总和 - 8,979.690 ns
  • SumVectorT - 6,689.829 纳秒
  • SumIntrinstics - 2,200.996 ns

因此,SIMD 的通用版本仅获得约 30% 的性能,而本征版本获得约 400% 的性能,接近理论最大值。


aep*_*pot 6

根据@JonasH 的回答

还值得注意的是,编译器似乎无法使用通用 API 生成非常高效的 SIMD 代码。

我不同意。唯一值得确保该方法得到正确实施。在某些情况下 - 是的,直接使用Intrinsics而不是Numerics矢量会带来很大的提升,但并非总是如此。

这里的问题是测量非常小的迭代。一般情况下Benchmark.NET做不到。可能的解决方案是将目标方法包装在循环中。

对我来说,编写一个有代表性的基准测试是一项艰苦的工作,而且我可能还不够擅长。但我会尝试。

public class SumTest
{
    [Params(7, 35, 77, 144, 195, 311, 722)]
    public int Size { get; set; }

    [IterationSetup]
    public void SetupData()
    {
        A = Enumerable.Range(0, Size).Select(x => 1.1).ToArray();
    }

    public double[] A { get; set; }


    [BenchmarkCategory("Sum"), Benchmark(Baseline = true)]
    public double BenchScalarSum()
    {
        double result = 0;
        for (int i = 0; i < 10000; i++)
            result = SumScalar(A);
        return result;
    }

    [BenchmarkCategory("Sum"), Benchmark]
    public double BenchNumericsSum()
    {
        double result = 0;
        for (int i = 0; i < 10000; i++)
            result = SumNumerics(A);
        return result;
    }

    [BenchmarkCategory("Sum"), Benchmark]
    public double BenchIntrinsicsSum()
    {
        double result = 0;
        for (int i = 0; i < 10000; i++)
            result = SumIntrinsics(A);
        return result;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public double SumScalar(ReadOnlySpan<double> numbers)
    {
        double result = 0;
        for (int i = 0; i < numbers.Length; i++)
        {
            result += numbers[i];
        }
        return result;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public double SumNumerics(ReadOnlySpan<double> numbers)
    {
        ReadOnlySpan<Vector<double>> vectors = MemoryMarshal.Cast<double, Vector<double>>(numbers);
        Vector<double> acc = Vector<double>.Zero;
        for (int i = 0; i < vectors.Length; i++)
        {
            acc += vectors[i];
        }
        double result = Vector.Dot(acc, Vector<double>.One);
        for (int i = vectors.Length * Vector<double>.Count; i < numbers.Length; i++)
            result += numbers[i];
        return result;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public double SumIntrinsics(ReadOnlySpan<double> numbers)
    {
        ReadOnlySpan<Vector256<double>> vectors = MemoryMarshal.Cast<double, Vector256<double>>(numbers);
        Vector256<double> acc = Vector256<double>.Zero;
        for (int i = 0; i < vectors.Length; i++)
        {
            acc = Avx.Add(acc, vectors[i]);
        }
        Vector128<double> r = Sse2.Add(acc.GetUpper(), acc.GetLower());
        double result = Sse3.HorizontalAdd(r, r).GetElement(0); // I'm aware that VHADDPD probably not enough efficient but leaving it for simplicity here
        for (int i = vectors.Length * Vector256<double>.Count; i < numbers.Length; i++)
            result += numbers[i];
        return result;
    }
}
Run Code Online (Sandbox Code Playgroud)
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i7-4700HQ CPU 2.40GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.203
  [Host]     : .NET Core 5.0.6 (CoreCLR 5.0.621.22011, CoreFX 5.0.621.22011), X64 RyuJIT
  Job-NQCIIR : .NET Core 5.0.6 (CoreCLR 5.0.621.22011, CoreFX 5.0.621.22011), X64 RyuJIT
Run Code Online (Sandbox Code Playgroud)
方法 尺寸 意思是 错误 标准差 中位数 比率 比率SD
基准标量和 7 53.34 我们 0.056 微秒 0.050我们 53.30 我们 1.00 0.00
基准数值总和 7 48.95 我们 2.262 我们 6.671 我们 44.95 我们 0.95 0.10
工作台内在总和 7 55.85 我们 2.089 我们 6.128 我们 51.90 我们 1.07 0.10
基准标量和 35 258.46 我们 2.319 我们 3.541 我们 257.00 我们 1.00 0.00
基准数值总和 35 94.14 我们 1.989 我们 5.705 我们 91.00 我们 0.36 0.02
工作台内在总和 35 90.82 我们 2.465 我们 7.073 我们 92.10 我们 0.35 0.03
基准标量和 77 541.18 我们 10.401 我们 11.129 我们 536.95 我们 1.00 0.00
基准数值总和 77 161.05 我们 3.171 我们 7.475 我们 159.30 我们 0.30 0.01
工作台内在总和 77 153.19 我们 3.063 我们 7.906 我们 150.50 我们 0.29 0.02
基准标量和 144 1,166.72 美元 6.945 我们 5.422 我们 1,166.10 我们 1.00 0.00
基准数值总和 144 294.72 我们 5.675 我们 10.520 我们 292.50 我们 0.26 0.01
工作台内在总和 144 287.18 我们 5.661 我们 13.671 我们 284.20 我们 0.25 0.01
基准标量和 195 1,671.83 美元 32.634 我们 34.918 我们 1,663.30 我们 1.00 0.00
基准数值总和 195 443.19 我们 7.916 我们 11.354 我们 443.10 我们 0.26 0.01
工作台内在总和 195 444.21 我们 8.876 我们 7.868 我们 443.55 我们 0.27 0.01
基准标量和 311 2,742.78 美元 35.797 我们 29.892 我们 2,745.70 我们 1.00 0.00
基准数值总和 311 778.00 我们 34.173 我们 100.759 我们 719.20 我们 0.30 0.04
工作台内在总和 311 776.30 我们 29.304 我们 86.404 我们 727.45 我们 0.29 0.03
基准标量和 第722章 6,607.72 美元 79.263 我们 74.143 我们 6,601.20 我们 1.00 0.00
基准数值总和 第722章 1,870.81 美元 43.390 我们 127.936 我们 1,850.30 我们 0.28 0.02
工作台内在总和 第722章 1,867.57 美元 39.718 我们 117.110 我们 1,851.50 我们 0.28 0.02

看起来使用向量至少不比基线方法效率低。


作为奖励,让我们使用https://sharplab.io/ (x64)查看输出汇编代码

SumTest.SumScalar(System.ReadOnlySpan`1<Double>)
    L0000: vzeroupper
    L0003: mov rax, [rdx]
    L0006: mov edx, [rdx+8]
    L0009: vxorps xmm0, xmm0, xmm0
    L000d: xor ecx, ecx
    L000f: test edx, edx
    L0011: jle short L0022
    L0013: movsxd r8, ecx
    L0016: vaddsd xmm0, xmm0, [rax+r8*8]
    L001c: inc ecx
    L001e: cmp ecx, edx
    L0020: jl short L0013
    L0022: ret

SumTest.SumNumerics(System.ReadOnlySpan`1<Double>)
    L0000: sub rsp, 0x28
    L0004: vzeroupper
    L0007: mov rax, [rdx]
    L000a: mov edx, [rdx+8]
    L000d: mov ecx, edx
    L000f: shl rcx, 3
    L0013: shr rcx, 5
    L0017: cmp rcx, 0x7fffffff
    L001e: ja short L0078
    L0020: vxorps ymm0, ymm0, ymm0
    L0024: xor r8d, r8d
    L0027: test ecx, ecx
    L0029: jle short L0040
    L002b: movsxd r9, r8d
    L002e: shl r9, 5
    L0032: vaddpd ymm0, ymm0, [rax+r9]
    L0038: inc r8d
    L003b: cmp r8d, ecx
    L003e: jl short L002b
    L0040: vmulpd ymm0, ymm0, [SumTest.SumNumerics(System.ReadOnlySpan`1<Double>)]
    L0048: vhaddpd ymm0, ymm0, ymm0
    L004c: vextractf128 xmm1, ymm0, 1
    L0052: vaddpd xmm0, xmm0, xmm1
    L0056: shl ecx, 2
    L0059: cmp ecx, edx
    L005b: jge short L0070
    L005d: cmp ecx, edx
    L005f: jae short L007e
    L0061: movsxd r8, ecx
    L0064: vaddsd xmm0, xmm0, [rax+r8*8]
    L006a: inc ecx
    L006c: cmp ecx, edx
    L006e: jl short L005d
    L0070: vzeroupper
    L0073: add rsp, 0x28
    L0077: ret
    L0078: call 0x00007ffc9de2b710
    L007d: int3
    L007e: call 0x00007ffc9de2bc70
    L0083: int3

SumTest.SumIntrinsics(System.ReadOnlySpan`1<Double>)
    L0000: sub rsp, 0x28
    L0004: vzeroupper
    L0007: mov rax, [rdx]
    L000a: mov edx, [rdx+8]
    L000d: mov ecx, edx
    L000f: shl rcx, 3
    L0013: shr rcx, 5
    L0017: cmp rcx, 0x7fffffff
    L001e: ja short L0070
    L0020: vxorps ymm0, ymm0, ymm0
    L0024: xor r8d, r8d
    L0027: test ecx, ecx
    L0029: jle short L0040
    L002b: movsxd r9, r8d
    L002e: shl r9, 5
    L0032: vaddpd ymm0, ymm0, [rax+r9]
    L0038: inc r8d
    L003b: cmp r8d, ecx
    L003e: jl short L002b
    L0040: vextractf128 xmm1, ymm0, 1
    L0046: vaddpd xmm0, xmm1, xmm0
    L004a: vhaddpd xmm0, xmm0, xmm0
    L004e: shl ecx, 2
    L0051: cmp ecx, edx
    L0053: jge short L0068
    L0055: cmp ecx, edx
    L0057: jae short L0076
    L0059: movsxd r8, ecx
    L005c: vaddsd xmm0, xmm0, [rax+r8*8]
    L0062: inc ecx
    L0064: cmp ecx, edx
    L0066: jl short L0055
    L0068: vzeroupper
    L006b: add rsp, 0x28
    L006f: ret
    L0070: call 0x00007ffc9de2b710
    L0075: int3
    L0076: call 0x00007ffc9de2bc70
    L007b: int3
Run Code Online (Sandbox Code Playgroud)

在这里您可以看到 JIT 为 生成几乎相同的Vector<T>代码Vector256<T>。