🏃‍♂️

Intrinsicsを使ってdouble配列の合計値を計算を高速化

2021/10/30に公開

今更感があふれているのですが、System.Runtime.IntrinsicsのAVX命令を使ってdouble[]の合計値計算処理を高速化してみます。

仕事では何かと数値計算処理を実装する機会が多いのですが、ふと「そういえばHardware Intrinsics機能がずいぶん前に追加されてたなー あれ効果あるのかなー」と気になったので、ちょっと軽くベンチを取ってみました。
とりあえずdouble[]の合計値を計算するというお題でやってみます。

早速調べてみたところ、Avx2クラスがVector構造体を基本にして様々な計算処理を提供している模様。
Vector構造体もビット長に応じて64、128、256と3種類が提供されているようです。double型はサイズが大きいので、当然使う構造体はVector256になります。

ヘルパーの作成

何はともあれ、doubleの配列を処理するのですからまずは配列からVector構造体を生成するヘルパーを定義します。参考にした記事によるとどうやらunsafeじゃないといけないということで、以下のようにサクッと作ります。

public static class VectorFactory
{
  public static unsafe Vector256<double> FromArray(double[] array, int index)
  {
    fixed (double* p = array)
    {
      return Avx2.LoadVector256(p + index);
    }
  }
}

合計値計算処理を実装

そして早速合計値を計算する処理を実装してみましょう。普通に配列をfor文で回したときと、Intrinsicsを使ったときの2パターンを実装します。

public static class Computer
{
  public static double SumArray(double[] array)
  {
    var sum = 0d;
    for (int i = 0; i < array.Length; i++)
    {
      sum += array[i];
    }
    return sum;
  }

  public static double SumAvx(double[] array)
  {
    var accum = Vector256<double>.Zero;
    for (int i = 0; i < array.Length; i += 4)
    {
      var v = VectorFactory.FromArray(array, i);
      accum = Avx2.Add(accum, v);
    }
    var v0 = accum.GetElement(0);
    var v1 = accum.GetElement(1);
    var v2 = accum.GetElement(2);
    var v3 = accum.GetElement(3);
    return v0 + v1 + v2 + v3;
  }
}

ベンチマークコードの実装

ベンチでは1024*1024のランダムな配列の合計値を計算させてみます。一応おまけでLINQのSumオペレーターを使った結果もベンチに含めています。
さて… どれくらいの差が出るのでしょうか…?

public class Bench
{
  private readonly double[] a;

  public Bench()
  {
    var rand = new Random();
    a = Enumerable.Range(0, 1024 * 1024)
      .Select(x => 100 * rand.NextDouble())
      .ToArray();
  }

  [Benchmark(Baseline = true)]
  public double SumArray()
  {
    return Computer.SumArray(a);
  }

  [Benchmark]
  public double SumLinq()
  {
    return a.Sum();
  }

  [Benchmark]
  public double SumAvx()
  {
    return Computer.SumAvx(a);
  }
}

結果

というわけで結果は以下の通りです。

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
11th Gen Intel Core i5-11400F 2.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK=5.0.402
  [Host]     : .NET 5.0.11 (5.0.1121.47308), X64 RyuJIT
  DefaultJob : .NET 5.0.11 (5.0.1121.47308), X64 RyuJIT


|   Method |       Mean |    Error |   StdDev | Ratio | RatioSD |
|--------- |-----------:|---------:|---------:|------:|--------:|
| SumArray | 1,043.0 us | 20.54 us | 23.65 us |  1.00 |    0.00 |
|  SumLinq | 3,250.8 us | 26.23 us | 21.90 us |  3.13 |    0.07 |
|   SumAvx |   325.1 us |  6.14 us | 11.68 us |  0.31 |    0.01 |

いいですね。3倍以上に高速化されました。
実際の業務では合計値だけでなく様々な統計量や畳み込み処理を行うので、ここまで簡単な実装で使えるわけではないですが、何か適用できるところがないか探してみたいですね。

Discussion