【问题标题】:System.Numerics.Vector<T> on large data sets大型数据集上的 System.Numerics.Vector<T>
【发布时间】:2019-09-19 17:04:41
【问题描述】:

我正在尝试通过利用 System.Numericsfloat[] 数组执行 SIMD 操作来提高 .NET Core 库的性能。 System.Numerics 现在有点时髦,我很难看到它有什么好处。我知道,为了看到 SIMD 的性能提升,它必须通过大量计算进行分摊,但考虑到它目前的实现方式,我不知道如何实现这一点。

Vector&lt;float&gt; 需要 8 个 float 值 - 不多也不少。如果我想对一组小于 8 的值执行 SIMD 操作,我不得不将这些值复制到一个新数组并用零填充剩余部分。如果这组值大于 8,我需要复制这些值,用零填充以确保其长度与 8 的倍数对齐,然后循环它们。长度要求是有道理的,但适应这一点似乎是抵消任何性能提升的好方法。

我编写了一个测试包装类来处理填充和对齐:

public readonly struct VectorWrapper<T>
  where T : unmanaged
{

  #region Data Members

  public readonly int Length;
  private readonly T[] data_;

  #endregion

  #region Constructor

  public VectorWrapper( T[] data )
  {
    Length = data.Length;

    var stepSize = Vector<T>.Count;
    var bufferedLength = data.Length - ( data.Length % stepSize ) + stepSize;

    data_ = new T[ bufferedLength ];
    data.CopyTo( data_, 0 );
  }

  #endregion

  #region Public Methods

  public T[] ToArray()
  {
    var returnData = new T[ Length ];
    data_.AsSpan( 0, Length ).CopyTo( returnData );
    return returnData;
  }

  #endregion

  #region Operators

  public static VectorWrapper<T> operator +( VectorWrapper<T> l, VectorWrapper<T> r )
  {
    var resultLength = l.Length;
    var result = new VectorWrapper<T>( new T[ l.Length ] );

    var lSpan = l.data_.AsSpan();
    var rSpan = r.data_.AsSpan();

    var stepSize = Vector<T>.Count;
    for( var i = 0; i < resultLength; i += stepSize )
    {
      var lVec = new Vector<T>( lSpan.Slice( i ) );
      var rVec = new Vector<T>( rSpan.Slice( i ) );
      Vector.Add( lVec, rVec ).CopyTo( result.data_, i );
    }

    return result;
  }

  #endregion

}

这个包装器可以解决问题。计算似乎是正确的,Vector&lt;T&gt; 不会抱怨元素的输入计数。但是,它的速度是简单的基于范围的 for 循环的两倍。

这是基准:

  public class VectorWrapperBenchmarks
  {

    #region Data Members

    private static float[] arrayA;
    private static float[] arrayB;

    private static VectorWrapper<float> vecA;
    private static VectorWrapper<float> vecB;

    #endregion

    #region Constructor

    public VectorWrapperBenchmarks()
    {
      arrayA = new float[ 1024 ];
      arrayB = new float[ 1024 ];
      for( var i = 0; i < 1024; i++ )
        arrayA[ i ] = arrayB[ i ] = i;

      vecA = new VectorWrapper<float>( arrayA );
      vecB = new VectorWrapper<float>( arrayB );
    }

    #endregion

    [Benchmark]
    public void ForLoopSum()
    {
      var aA = arrayA;
      var aB = arrayB;
      var result = new float[ 1024 ];

      for( var i = 0; i < 1024; i++ )
        result[ i ] = aA[ i ] + aB[ i ];
    }

    [Benchmark]
    public void VectorSum()
    {
      var vA = vecA;
      var vB = vecB;
      var result = vA + vB;
    }

  }

结果:

|     Method |       Mean |    Error |   StdDev |
|----------- |-----------:|---------:|---------:|
| ForLoopSum |   757.6 ns | 15.67 ns | 17.41 ns |
|  VectorSum | 1,335.7 ns | 17.25 ns | 16.13 ns |

我的处理器 (i7-6700k) 确实支持 SIMD 硬件加速,它在 64 位发布模式下运行,并在 .NET Core 2.2 (Windows 10) 上启用了优化。

我意识到Array.CopyTo() 可能是影响性能的很大一部分,但似乎没有简单的方法来同时拥有不明确符合Vector&lt;T&gt; 的填充/对齐和数据集规格。

我对 SIMD 比较陌生,而且我知道 C# 实现仍处于早期阶段。但是,我看不出有什么明确的方法可以真正从中受益,尤其是考虑到它在扩展到更大的数据集时是最有益的。

有没有更好的方法来解决这个问题?

【问题讨论】:

  • 我不明白你为什么要把这变成一个非此即彼的场景。对源数组的向量大小的切片使用向量指令,然后在标准循环中一个一个地处理最后几个剩余的元素。不需要复制或填充。顺便说一句,如果您专门针对 Core,您可能还会对更新、更灵活的 System.Runtime.Intrinsics 命名空间感兴趣。
  • 我无法给出完整的答案。但是,如果您需要为每个向量创建一个跨度或数据副本以进行处理,那么由于开销(如您所知),这是错误的方法。有些代码模式不需要它。 + 的每个循环迭代只是两次内存读取,一次添加,一次存储。就像在本机代码中一样。必须使用第二个循环逐元素处理数组的尾部。没有填充。

标签: c# .net .net-core simd system.numerics


【解决方案1】:

我不确定您所说的“时髦”是什么意思,但它现在完全可以使用(尽管它可能会更高效)。 使用您的案例(求和浮点数)我得到以下结果,超过 10003 个项目与一个老年 Haswell CPU:

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.17134.706 (1803/April2018Update/Redstone4)
Intel Core i7-4500U CPU 1.80GHz (Haswell), 1 CPU, 4 logical and 2 physical cores
Frequency=1753753 Hz, Resolution=570.2057 ns, Timer=TSC
.NET Core SDK=2.1.602
  [Host]     : .NET Core 2.1.9 (CoreCLR 4.6.27414.06, CoreFX 4.6.27415.01), 64bit RyuJIT
  DefaultJob : .NET Core 2.1.9 (CoreCLR 4.6.27414.06, CoreFX 4.6.27415.01), 64bit RyuJIT


|   Method |      Mean |     Error |    StdDev |
|--------- |----------:|----------:|----------:|
| ScalarOp | 12.974 us | 0.2579 us | 0.2533 us |
| VectorOp |  3.956 us | 0.0570 us | 0.0505 us |
| CopyData |  1.455 us | 0.0273 us | 0.0228 us |

将数据从向量复制回数组是(相对)缓慢的,因为它几乎占用了一半的时间。但仍然:矢量化操作的总时间不到标量的 1/3...

查看反汇编(BenchmarkDotNet 会生成它)似乎内存复制操作使用了(较慢的)未对齐操作。 .Net Core 的未来版本可能会对此进行研究。

您可以完全避免复制操作,方法是使用Span&lt;T&gt;MemoryMarshal.Cast 将生成的向量直接放入 Span。它减少了大约求和的时间。三分之一与复制相比(下面未显示)。

作为参考,基准代码是(floatSlots = Vector&lt;float&gt;.Count;数组是在基准运行之前创建并填充数据),不一定是最佳解决方案:

        [Benchmark]
        public void ScalarOp()
        {            
            for (int i = 0; i < data1.Length; i++)
            {
                sums[i] = data1[i] + data2[i];
            }            
        }

        [Benchmark]
        public void VectorOp()
        {                      
            int ceiling = data1.Length / floatSlots * floatSlots;
            int leftOver = data1.Length % floatSlots;
            for (int i = 0; i < ceiling; i += floatSlots)
            {                
                Vector<float> v1 = new Vector<float>(data1, i);                
                Vector<float> v2 = new Vector<float>(data2, i);                
                (v1 + v2).CopyTo(sums, i); 

            }
            for (int i = ceiling; i < data1.Length; i++)
            {
                sums[i] = data1[i] + data2[i];
            }
        }

        [Benchmark]
        public void CopyData()
        {                        
            Vector<float> v1 = new Vector<float>(8);
            int ceiling = data1.Length / floatSlots * floatSlots;
            int leftOver = data1.Length % floatSlots;
            for (int i = 0; i < ceiling; i += floatSlots)
            {                               
                (v1).CopyTo(sums, i);
            }
            for(int i = ceiling; i < data1.Length; i++)
            {
                sums[i] = 8;
            }                
        }

编辑:更正标量基准,因为它与矢量相同,添加了对SpanMemoryMarshal.Cast 的提及。

【讨论】:

  • Haswell 不是那么“老”;它支持 AVX2 + FMA 并具有 256 位宽的加载/存储执行单元。对于大多数 FP 操作,Skylake 对 Haswell 的改变并不大。但是较新的系统将具有更快的 RAM,因此这对于遍历将在缓存中丢失的大数组非常重要。
  • 你能发布完整的基准测试吗?您确定差异与内存/缓存性能无关吗?
  • @HaymoKutschbach 我不确定你的意思。缓存性能始终是一个因素,但主要与数据集大小和内存访问模式有关。当内部循环对数据进行多个操作而没有中间 fecthes/stores(而不是仅 fetch - sum - store)时,矢量化操作就会大放异彩
  • 对。但这正是示例中的样子,不是吗?
猜你喜欢
  • 2017-03-02
  • 2021-02-20
  • 2016-08-01
  • 2016-06-01
  • 2015-05-19
  • 2020-08-12
  • 2020-11-28
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多