値型の性能

実際のところ、値型と参照型でどのくらいの差が出るのかについても触れておきましょう。 値型が有利に働くような計算を、あえて構造体とクラスの両方で実装してみて、差を見てみましょう。

値型と参照型の利点」で説明した通り、 小さいデータ構造ほど値型が有利なんですが、別項で説明する参照渡しと組み合わせることで、多少大き目のデータでも値型の方が有利になったりします。

ここでは、ベクトルの加算を例にとってみます。多少大き目のデータの例を出したいので、8元ベクトル(double型(8バイト)が8つで64バイト)で考えましょう。 以下のような構造体になります。

public struct Vector
{
    public double A, B, C, D, E, F, G, H;

    public Vector(double a, double b, double c, double d, double e, double f, double g, double h)
    {
        A = a;
        // 以下略。B~H
    }

    public void Add(ref Vector v)
    {
        A += v.A;
        // 以下略。B~H
    }
}

説明のために不自然なデータ構造(8元ベクトルは実用途であまり使う機会はない)を使いましたが、 同程度以上の個数の数値が詰まった型を作りたいことは結構あります。 例えば、物理シミュレーションだと、3次元の座標で位置・速度・加速度の合計3×3 = 9個の値をまとめておきたいことがよくあります。 他だと、3Dグラフィックスの分野だと4×4行列をよく使いますが、これだと4×4 = 16個の値で1つの型になったりします。

ちなみに、この8元ベクトルのコードでは、ベクトルの加算を、自分自身を上書きする形で実装しています。 一番パフォーマンスがよくなるのはこういう書き方です。

これに対して、以下のように、新しい値を作って返す実装も考えられます。

public struct Vector
{
    public readonly double A, B, C, D, E, F, G, H;

    public Vector(double a, double b, double c, double d, double e, double f, double g, double h)
    {
        A = a;
        // 以下略。B~H
    }

    public Vector Add(Vector v) => new Vector(A + v.A, B + v.B, C + v.C, D + v.D, E + v.E, F + v.F, G + v.G, H + v.H);
}

新しい値を作って帰すところで、32バイトのデータのコピーが必要になるのでそれなりの負担が発生します。

さらに、これら2つの実装を、あえてクラス(参照型)にしてみたものも用意しましょう。

public class Vector
{
    public double A, B, C, D, E, F, G, H;

    public Vector() { }

    public Vector(double a, double b, double c, double d, double e, double f, double g, double h)
    {
        A = a;
        // 以下略。B~H
    }

    public void Add(Vector v)
    {
        A += v.A;
        // 以下略。B~H
    }
}
public class Vector
{
    public readonly double A, B, C, D, E, F, G, H;

    public Vector() { }

    public Vector(double a, double b, double c, double d, double e, double f, double g, double h)
    {
        A = a;
        // 以下略。B~H
    }

    public Vector Add(Vector v) => new Vector(A + v.A, B + v.B, C + v.C, D + v.D, E + v.E, F + v.F, G + v.G, H + v.H);
}

これらに対して、以下のような、ランダムな配列データの作成と、総和の計算を行います(これは「値型かつ自己書き換え」向けの実装です。他はちょっとずつコードが違います)。

// ランダムに配列データの生成
public Vector[] GetSeries(Random r, int count) => Enumerable.Range(0, count).Select(_ => GetRandom(r)).ToArray();
private static Vector GetRandom(Random r) => Get(() => r.NextDouble(-1, 1));
private static Vector Get(Func<double> f) => new Vector(f(), f(), f(), f(), f(), f(), f(), f());

// 生成した配列の総和を求める
public Vector SeriesSum(Vector[] seq)
{
    var sum = new Vector();
    for (int i = 0; i < seq.Length; i++)
        sum.Add(ref seq[i]);
    return sum;
}

これで、5百万要素の配列生成・総和計算をしてみたところ、 手元の環境(Core i7のデスクトップPC)での計測では、実行時間は以下のようになりました。

配列データの作成(秒) 総和の計算(秒)
構造体・自己書き換え 1.1381 0.0291
構造体・書き換え不能 1.1818 0.0957
クラス・自己書き換え 2.2254 0.0312
クラス・書き換え不能 2.0816 0.0716

計測のたびに数%程度の差は出ますが、傾向は同じです。 簡単に結果をまとめると以下の通りです。

  • 参照型はインスタンス生成がかなり遅い
    • 「クラス・書き換え不能」で計算も遅いのは、Addの戻り値でもインスタンス生成があるせいです
  • 値型は値のコピーが遅い
    • 「構造体・書き換え不能」の計算が遅いのは、Addの戻り値を返すときにコピーが発生するせいです

この例のように、大量の数値データに対する計算処理では、構造体(値型)と参照渡しを上手く使うことでパフォーマンス向上が期待できることが多いです。

更新履歴

ブログ