💡

C#におけるベクトル的データ構造のパフォーマンス比較②

2022/09/21に公開

第①回 の記事にて float 2次元のデータ構造のパフォーマンス比較を行った.

本記事では補足的状況での実験結果を付記する.
動作環境は第①回と同じで,ソースコードも同じく GitHub にて公開する.

返り値による最適化の影響

計算結果が以降で全く使われないと判断されるとコンパイラ最適化により計算が省略される場合がある.
よって計算時間を計測するには計算結果を返り値で使われるようにする必要がある.(これは公式ドキュメントの Good Practice にも記載されている.)
以下の6つのmethodについてベンチマークを行った結果を次の表に示す.

//...前略
// Iを受け取って int -> float にキャスト,比較して返す
[Benchmark]
public bool Vain() {
    float x = (float)I;
    return x == -1.0f;
}

// 何もしない
[Benchmark]
public void Void() {
}

[Benchmark]
public bool Vector2() {
    var x = SN::Vector2.UnitX * I;
    var y = SN::Vector2.UnitY * I;
    var z = SN::Vector2.One * I;
    return x + y == z;
}

// 結果を返さない
[Benchmark]
public void Vector2_NoReturen() {
    var x = SN::Vector2.UnitX * I;
    var y = SN::Vector2.UnitY * I;
    var z = SN::Vector2.One * I;
    var w = x + y == z;
}

// (x+y) = z の比較をせずにベクトルをそのまま返す
[Benchmark]
public (Vector2, Vector2) Vector2_NoCompare() {
    var x = SN::Vector2.UnitX * I;
    var y = SN::Vector2.UnitY * I;
    var z = SN::Vector2.One * I;
    return (x + y, z);
}

// x + y の和の計算をせずにベクトルをそのまま返す
[Benchmark]
public (Vector2, Vector2, Vector2) Vector2_NoAdd() {
    var x = SN::Vector2.UnitX * I;
    var y = SN::Vector2.UnitY * I;
    var z = SN::Vector2.One * I;
    return (x, y, z);
}
//...後略
method mean error
Vain 0.256 0.017
Void 0.001 0.002
Vector2 1.071 0.013
Vector2_NoReturen 0.004 0.004
Vector2_NoCompare 0.683 0.014
Vector2_NoAdd 0.731 0.011

(注:本編と別途ベンチマークを行ったためVector2のベンチマーク結果の値が変わっている.)

Vector2_NoReturn は全く何も行わないmethod Void と誤差の範囲で速度が一致しており,最適化によって計算が省かれていることがわかる.
比較演算だけ行う Vain method や,演算を途中までにしている Vector2_NoCompare, Vector2_NoAdd method は返り値があるため計算が行われている.
和の演算を行っている Vector2_NoCompare よりも行わない Vector2_NoAdd のほうが遅いのは,返り値が3つある分オーバーヘッドが大きくなっているためと思われる.

Vector< T >の検証

System.Numerics.Vector<T> を生成するには実行環境に応じた長さの配列を生成しなければならないため,生成のたびにこの操作を行っていると配列を生成するだけの時間がかかる.
この効果を調べるためいくつかのケースでベンチマークを行った.

//...前略
internal static class VectorTExtends
{
    //...中略
    // 環境変数を考慮して生成
    public static Vector<float> VecF2_UnitX() {
        var UnitX = new float[Vector<float>.Count];
        UnitX[0] = 1f;
        return new(UnitX);
    }
    //...中略
    // リテラルを用いて生成
    public static Vector<float> VecF2_UnitX_Solid() =>
        new(new float[] { 1f, 0f, 0f, 0f, 0f, 0f, 0f, 0f });
    //...中略
}
//---
//...前略
public class VectorFloat2Bench
{
    // リテラルを用いて生成した単位ベクトルから都度生成
    [Benchmark]
    public bool VectorT_Static() {
        var x = VectorTExtends.VecF2_UnitX() * I;
        var y = VectorTExtends.VecF2_UnitY() * I;
        var z = VectorTExtends.VecF2_One() * I;
        return x + y == z;
    }
    
    // 環境変数を考慮して生成した単位ベクトルから都度生成
    [Benchmark]
    public bool VectorT_Solid() {
        var x = VectorTExtends.VecF2_UnitX_Solid() * I;
        var y = VectorTExtends.VecF2_UnitY_Solid() * I;
        var z = VectorTExtends.VecF2_One_Solid() * I;
        return x + y == z;
    }

    
    float[] floatArray_UnitX, floatArray_UnitY, floatArray_One;
    Vector<float> VectorT_UnitX, VectorT_UnitY, VectorT_One;
    public VectorFloat2Bench() {
        floatArray_UnitX = new float[Vector<float>.Count];
        floatArray_UnitX[0] = 1f;
        floatArray_UnitY = new float[Vector<float>.Count];
        floatArray_UnitY[1] = 1f;
        floatArray_One = new float[Vector<float>.Count];
        floatArray_One[0] = 1f;
        floatArray_One[1] = 1f;

        VectorT_UnitX = new Vector<float>(floatArray_UnitX);
        VectorT_UnitY = new Vector<float>(floatArray_UnitY);
        VectorT_One = new Vector<float>(floatArray_One);
    }

    // 予め生成した単位ベクトルを表す配列からコンストラクタを使用して都度生成
    [Benchmark]
    public bool VectorT_Cotr() {
        var x = new Vector<float>(floatArray_UnitX) * I;
        var y = new Vector<float>(floatArray_UnitY) * I;
        var z = new Vector<float>(floatArray_One) * I;
        return x + y == z;
    }

    // 予め生成した単位ベクトルを利用
    [Benchmark]
    public bool VectorT_Init() {
        var x = VectorT_UnitX * I;
        var y = VectorT_UnitY * I;
        var z = VectorT_One * I;
        return x + y == z;
    }
//...後略
method mean error
VectorT_Static 37.866 0.174
VectorT_Solid 38.205 0.186
VectorT_Cotr 1.254 0.031
VectorT_Init 0.687 0.012

上にベンチマーク結果を示す.
動的に生成する(VectorT_Static)にせよハードコーディングする(VectorT_Solid)にせよ,毎回配列の生成を行うコードでは 30 ns 以上かかっており,配列の生成を含まない場合は高速になっていることがわかる.

ValueTuple の検証

ValueTuple による演算は適切に最適化されれば高速だが,場合によってはclassを用いた場合以上に遅くなることがある.
以下で定義する8種類の実装でパフォーマンスの変化を確かめた.

// ベンチマーク関数定義
//...前略
// 拡張methodによる演算
[Benchmark]
public bool ValueTuple_Extend() {
    var x = ValueTupleExtensions.VecF2_UnitX().Multiple(I);
    var y = ValueTupleExtensions.VecF2_UnitY().Multiple(I);
    var z = ValueTupleExtensions.VecF2_One().Multiple(I);
    return x.Add(y) == z;
}

// ラッパーstructでオーバーロードされた演算子による演算
[Benchmark]
public bool ValueTuple_Wrap() {
    var x = WrapValueTupleVectorF2.UnitX * I;
    var y = WrapValueTupleVectorF2.UnitY * I;
    var z = WrapValueTupleVectorF2.One * I;
    return x + y == z;
}

// ローカルに定義したmethodによる演算
[Benchmark]
public bool ValueTuple_LocalMethod() {
    (float, float) x = Multiple((1f, 0f), I);
    (float, float) y = Multiple((0f, 1f), I);
    (float, float) z = Multiple((1f, 1f), I);
    return Add(x, y) == z;
}

public static (float, float) Add((float, float) left, (float, float) right) =>
    (left.Item1 + right.Item1, left.Item2 + right.Item2);

public static (float, float) Multiple((float, float) left, float right) =>
    (left.Item1 * right, left.Item2 * right);

// 単位ベクトルをリテラルで書いた場合
[Benchmark]
public bool ValueTuple_Literal() {
    (float X, float Y) x = (1f, 0f).Multiple(I);
    (float X, float Y) y = (0f, 1f).Multiple(I);
    (float X, float Y) z = (1f, 1f).Multiple(I);
    return x.Add(y) == z;
}

// メソッドを使わず直接計算した場合
[Benchmark]
public bool ValueTuple_Raw() {
    (float X, float Y) x = (1f, 0f);
    x.X *= I;
    x.Y *= I;
    (float X, float Y) y = (0f, 1f);
    y.X *= I;
    y.Y *= I;
    (float X, float Y) z = (1f, 1f);
    z.X *= I;
    z.Y *= I;
    return (x.X + y.X, x.Y + y.Y) == z;
}

// doubleからfloatへのキャストを行った場合
[Benchmark]
public bool ValueTuple_Cast() {
    (float, float) x = ((float, float))(1d, 0d);
    x = Multiple(x, I);
    (float, float) y = ((float, float))(0d, 1d);
    y = Multiple(y, I);
    (float, float) z = ((float, float))(1d, 1d);
    z = Multiple(z, I);
    return Add(x, y) == z;
}

// 単位ベクトルをリテラルで書いた場合
[Benchmark]
public bool ValueTuple_Literal() {
    var x = (1f, 0f).Multiple(I);
    var y = (0f, 1f).Multiple(I);
    var z = (1f, 1f).Multiple(I);
    return x.Add(y) == z;
}

// X, Y を呼び出す際にItems1, Items2ではなくgetterによる呼び出しを介した場合
[Benchmark]
public bool ValueTuple_Property() {
    var x = ValueTupleExtensions.VecF2_UnitX().Multiple_Method(I);
    var y = ValueTupleExtensions.VecF2_UnitY().Multiple_Method(I);
    var z = ValueTupleExtensions.VecF2_One().Multiple_Method(I);
    return x.Add_Method(y) == z;
}
//...後略

// 拡張メソッド定義
//...前略
public static float X(this VecF2 @this) => @this.Item1;
public static float Y(this VecF2 @this) => @this.Item2;

public static VecF2 Add(this VecF2 left, VecF2 right) =>
    (left.Item1 + right.Item1, left.Item2 + right.Item2);

public static VecF2 Multiple(this VecF2 left, float right) =>
    (left.Item1 * right, left.Item2 * right);

public static VecF2 Add_Property(this VecF2 left, VecF2 right) =>
    (left.X() + right.X(), left.Y() + right.Y());

public static VecF2 Multiple_Property(this VecF2 left, float right) =>
    (left.X() * right, left.Y() * right);
//...後略
method mean error
ValueTuple_Extend 0.435 0.011
ValueTuple_Wrap 38.742 0.168
ValueTuple_LocalMethod 0.431 0.010
ValueTuple_Literal 0.419 0.010
ValueTuple_Raw 3.735 0.020
ValueTuple_Cast 0.430 0.012
ValueTuple_Property 39.191 0.161

Extend, LocalMethod, Literal, Cast の各 method ではパフォーマンスにほぼ差がないのに対し,Wrap と Property では大幅に遅くなっている.
これらは各成分の呼び出しを直接タプルの Item1, Item2 にアクセスするのではなく property の getter (に準ずる形)でアクセスしているという共通点がある.Rawでも名前付きタプルの名前でアクセスしており,パフォーマンスが低下している.
これらのことから,ValueTuple はフィールド変数である Item1, Item2 に直接ではない方法でアクセスするとパフォーマンスが低下するのではないかと考えられる.

まとめ

BenchmarkDotNet によるベンチマークではデッドコードの最適化が行われないよう計算結果を返すのが良い.

System.Numerics.Vector<T> は演算は高速だが,生成に配列を用いるため初期化に気を配る必要がある.

ValueTupleの値にアクセスする際は,フィールド変数を直接参照せずにアクセサを自分で定義するとパフォーマンスが落ちるため注意が必要である.

Discussion