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

Posted

技术标签:

【中文标题】使用 SIMD (System.Numerics) 编写向量求和函数并使其比 for 循环更快【英文标题】:Writing a vector sum function with SIMD (System.Numerics) and making it faster than a for loop 【发布时间】:2021-08-08 20:15:02 【问题描述】:

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

在我的计算机上,Vector<double>.Count 是 4,这意味着我可以创建一个包含 4 个值的累加器,然后遍历数组,按组将元素相加。

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

//     | loop                  | remainder
acc[0] = vector[0] + vector[4] + vector[8]
acc[1] = vector[1] + vector[5] + vector[9]
acc[2] = vector[2] + vector[6] 
acc[3] = vector[3] + vector[7] 

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

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

public static double SumSimd(this Span<double> a)

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

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


基线

基线代码如下所示

public static double LinAlgSum(this ReadOnlySpan<double> span)

    double sum = 0;
    for (int i = 0; i < span.Length; i++)
    
        sum += span[i];
    
    return sum;

在对 SIMD 代码进行基准测试时,size=7 慢 5 倍,size=144 慢 2.5 倍,size=770 几乎相同。

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

[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class LinearAlgebraBench

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

    [IterationSetup]
    public void SetupData()
    
        A = new LinearAlgebra.Vector(Size, (iter) => 2 * Size - iter).ToArray();
        B = new LinearAlgebra.Vector(Size, (iter) => Size/2 + 2* iter).ToArray();
    

    public double[] A  get; set; 
    public double[] B  get; set; 

    [BenchmarkCategory("Sum"), Benchmark(Baseline = true)]
    public double BenchLinAlgSum()
    
        return LinearAlgebra.LinearAlgebra.Sum(A.AsSpan().AsReadOnly());
    
    [BenchmarkCategory("Sum"), Benchmark]
    public double BenchSimdSum()
    
        return LinearAlgebra.LinearAlgebra.SumSimd(A);
    

【问题讨论】:

您正在调用一个方法,并且在进行调用时会产生开销。 @jdweng - 我的 SIMD 向量添加函数比 for 循环更快,但仍然有开销。我认为在这里求和有些奇怪。 你是在调试还是发布模式下运行? 谢谢,是的,我想到了,但这很简单。如果我们不必自己制作所有东西,这将节省我/我们的时间。总之,重要的是如何衡量评估时间? @aepot - 该场景涉及数十亿次迭代,其中包含 【参考方案1】:

根据@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;
    

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
Method Size Mean Error StdDev Median Ratio RatiosD
BenchScalarSum 7 53.34 us 0.056 us 0.050 us 53.30 us 1.00 0.00
BenchNumericsSum 7 48.95 us 2.262 us 6.671 us 44.95 us 0.95 0.10
BenchIntrinsicsSum 7 55.85 us 2.089 us 6.128 us 51.90 us 1.07 0.10
BenchScalarSum 35 258.46 us 2.319 us 3.541 us 257.00 us 1.00 0.00
BenchNumericsSum 35 94.14 us 1.989 us 5.705 us 91.00 us 0.36 0.02
BenchIntrinsicsSum 35 90.82 us 2.465 us 7.073 us 92.10 us 0.35 0.03
BenchScalarSum 77 541.18 us 10.401 us 11.129 us 536.95 us 1.00 0.00
BenchNumericsSum 77 161.05 us 3.171 us 7.475 us 159.30 us 0.30 0.01
BenchIntrinsicsSum 77 153.19 us 3.063 us 7.906 us 150.50 us 0.29 0.02
BenchScalarSum 144 1,166.72 us 6.945 us 5.422 us 1,166.10 us 1.00 0.00
BenchNumericsSum 144 294.72 us 5.675 us 10.520 us 292.50 us 0.26 0.01
BenchIntrinsicsSum 144 287.18 us 5.661 us 13.671 us 284.20 us 0.25 0.01
BenchScalarSum 195 1,671.83 us 32.634 us 34.918 us 1,663.30 us 1.00 0.00
BenchNumericsSum 195 443.19 us 7.916 us 11.354 us 443.10 us 0.26 0.01
BenchIntrinsicsSum 195 444.21 us 8.876 us 7.868 us 443.55 us 0.27 0.01
BenchScalarSum 311 2,742.78 us 35.797 us 29.892 us 2,745.70 us 1.00 0.00
BenchNumericsSum 311 778.00 us 34.173 us 100.759 us 719.20 us 0.30 0.04
BenchIntrinsicsSum 311 776.30 us 29.304 us 86.404 us 727.45 us 0.29 0.03
BenchScalarSum 722 6,607.72 us 79.263 us 74.143 us 6,601.20 us 1.00 0.00
BenchNumericsSum 722 1,870.81 us 43.390 us 127.936 us 1,850.30 us 0.28 0.02
BenchIntrinsicsSum 722 1,867.57 us 39.718 us 117.110 us 1,851.50 us 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

在这里您可以看到 JIT 为 Vector&lt;T&gt; 生成的代码与为 Vector256&lt;T&gt; 生成的代码几乎相同。

【讨论】:

是的,微基准测试很难。特别是对于非常短的循环,特别是如果您想真实地测量分支预测效果,而不让分支预测器简单地学习重复执行相同大小的模式。 (除非那现实的)。但是,是的,如果问题中的结果只是未能进行热身跑等,我一点也不感到惊讶(Idiomatic way of performance evaluation?)。 对,对不起,我在这里啰嗦了太久,模糊了我的重点。是的,2nd Sse3.HorizontalAdd 适合快速一次性使用,尽管查找 unpckhpd / addpd 的内在函数并不难。我试图说明的一点是,1st Sse3.HorizontalAdd 应该是 Sse2.Add,不需要对周围的代码进行其他更改。您正在将 2 个双精度数的两个 2 向量减少为 1 个向量,不需要水平的东西。 (但如果你现在改变它,你必须重新运行基准测试,所以也许只是更新评论。) 嘿,只是在查看 asm 和 Vector.Dot(acc, Vector&lt;double&gt;.One); 导致实际的乘法,然后是随机/添加 hsum 模式,其中涉及一个 vaddpd 和一个 vhaddpd 就像我说你应该与内在函数有关。 (除了 Dot 首先执行 256 位 vhaddpd,在 Zen1 和更早的 AMD 上浪费 uops,而不是首先减少到 128 位。)同样,紧凑的源代码,但不如手动跳过乘法高效。 (如果有一种很好的方法可以让 System.Numerics 通过随机播放进行水平求和,而不是存储/重新加载。) 我喜欢你的 Vector.Dot(acc, Vector&lt;double&gt;.One) 总结数据的想法。 @aepot - 我不会忘记的。我仍在评估答案。【参考方案2】:

我建议你看看这篇探索SIMD performance in .Net的文章。

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

while (i < lastBlockIndex)

    vresult += new Vector<int>(source.Slice(i));
    i += Vector<int>.Count;

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

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

SumUnrolled - 8,979.690 ns SumVectorT - 6,689.829 ns SumIntrinstics - 2,200.996 ns

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

【讨论】:

很好的参考。我确实看到了与我的实现和矢量化版本的相似之处。但我没有看到他们所做的性能提升。这给出了一些现在需要调查的东西。【参考方案3】:

试试这个版本。它使用四个独立的累加器试图隐藏vaddpd 指令的延迟,在现代 AVX CPU 上是 3-4 个周期。未经测试。

public static double vectorSum( this ReadOnlySpan<double> span )

    int vs = Vector<double>.Count;
    int end = span.Length;
    int endVectors = ( end / vs ) * vs;

    // Using 4 independent accumulators because on modern CPUs the latency of `vaddpd` is 3-4 cycles.
    // One batch consumes 4 vectors.
    int endBatches = ( endVectors / 4 ) * 4;

    Vector<double> a0 = Vector<double>.Zero;
    Vector<double> a1 = Vector<double>.Zero;
    Vector<double> a2 = Vector<double>.Zero;
    Vector<double> a3 = Vector<double>.Zero;

    // Handle majority of data unrolling by 4 vectors (e.g. 16 scalars with AVX)
    int i;
    for( i = 0; i < endBatches; i += vs * 4 )
    
        a0 += new Vector<double>( span.Slice( i, vs ) );
        a1 += new Vector<double>( span.Slice( i + vs, vs ) );
        a2 += new Vector<double>( span.Slice( i + vs * 2, vs ) );
        a3 += new Vector<double>( span.Slice( i + vs * 3, vs ) );
    

    // Handle a few remaining complete vectors
    for( ; i < endVectors; i += vs )
        a0 += new Vector<double>( span.Slice( i, vs ) );

    // Add the accumulators together
    a0 += a1;
    a2 += a3;
    a0 += a2;

    // Compute horizontal sum of a0
    double sum = 0;
    for( int j = 0; j < vs; j++ )
        sum += a0[ j ];

    // Add the remaining few scalars
    for( ; i < end; i++ )
        sum += span[ i ];
    return sum;

尚未进行基准测试,但查看了 sharplab.io 上的反汇编。虽然不如等效的 C++(循环中的标量代码过多),但它看起来并不糟糕:使用 AVX,在主循环中没有函数调用或不需要的加载/存储。

【讨论】:

这个问题似乎对非常小的数组的性能感兴趣,比如 7 或 10 个元素。 (那时,与随机/添加相比,天真的 hsum 可能是一个问题。) 上面的向量化展开循环确实从一开始就表现得非常好,但是有一个错误阻止它在小数组中执行。我猜假设数组 > 16 个元素(4 个累加器乘以 4 个打包值)。

以上是关于使用 SIMD (System.Numerics) 编写向量求和函数并使其比 for 循环更快的主要内容,如果未能解决你的问题,请参考以下文章

使用 System.Numerics.Vectors 旋转二维点

System.Numerics.Vectors IsHardwareAccelerated 返回 false

C# 中带 SIMD 的 2x2 矩阵向量积

在通用 Windows 平台中将 Vector<T> 用于 SIMD

如何添加对 System.Numerics.dll 的引用

c# 使用 system.numerics 将数组元素相乘