.NETパフォーマンス最適化 - 構造体をクラスの代わりに使用する

.NETパフォーマンス最適化 - 構造体をクラスの代わりに使用する

C#とJavaの顕著な違いの1つは、C#が値型をカスタマイズできること、つまり今日の主役であるstructです。より便利なclassがあるのに、なぜMicrosoftはstructを追加したのでしょうか?

最終更新 2022/05/05 21:33
InCerry
読了目安 15 分
カテゴリ
.NET
タグ
.NET C# Java パフォーマンス最適化

作者:InCerry

出处:https://www.cnblogs.com/InCerry/archive/2022/05/05/Dotnet-Opt-Perf-Use-Struct-Instead-Of-Class.html

著作権:本作品は「表示-非営利-継承 4.0 国際」ライセンスの下で提供されています。

声明:本ブログの著作権は「InCerry」に帰属します。

1. はじめに

C# と Java の明らかな違いの一つに、C# では値型をカスタム定義できる点があります。それが今回の主役である struct です。既に便利な class があるのに、なぜマイクロソフトは struct を追加したのでしょうか? それが今日お話しするパフォーマンス最適化のヒント「構造体をクラスの代わりに使う」につながります。

では、構造体をクラスの代わりに使うとどんな利点があるのでしょうか? どのようなシナリオで構造体をクラスの代わりに使うべきなのでしょうか? 本日の記事で一つ一つお答えします。

注意:本記事はすべて x64 プラットフォームを例としています。

2. 実際の事例

実際のシステムの例を挙げましょう。皆さんは航空券の購入フローをご存知ですね。出発地と到着地の都市と空港(これが路線です)を選び、希望の日付と時間に合わせて好きなフライトと座席クラスを選び、支払いをします。

2.1 メモリ使用量

全国には約49の航空会社、8000以上の路線があり、平均して各路線に20のフライト、各フライトに平均10の座席クラス価格(エコノミー、ファーストクラス、各種割引・特典)があります。一般的にOTA(Online Travel Agency:オンライン旅行代理店)は1年先までの航空券を予約できます。つまり、プラットフォームは 8000*20*10*365=~5億 の価格データを持つ可能性があります(上記のデータはすべてネットからの引用で、実際のデータ量は開示できません)。

OTAプラットフォームは、あなたが希望のフライトをより速く検索できるように、人気のある路線の価格データをデータベースから取り出してメモリにキャッシュします(メモリは単独のネットワークやディスク転送よりもはるかに高速です。詳細は下図参照)。20%としても約1億のデータがメモリに存在することになります。

操作 速度
命令の実行 1/1,000,000,000 秒 = 1 ナノ秒
L1キャッシュからのデータ読み取り 0.5 ナノ秒
分岐予測ミス 5 ナノ秒
L2キャッシュからのデータ読み取り 7 ナノ秒
Mutex のロック/アンロック 25 ナノ秒
メインメモリ(RAM)からのデータ読み取り 100 ナノ秒
1Gbps ネットワークで2Kbyte のデータ送信 20,000 ナノ秒
メモリから1MB のデータ読み取り 250,000 ナノ秒
磁気ヘッドを新しい位置に移動(HDD) 8,000,000 ナノ秒
ディスクから1MB のデータ読み取り 20,000,000 ナノ秒
パケットを米国から欧州に往復送信 150 ミリ秒 = 150,000,000 ナノ秒

例えば、次のようなクラスがあり、以下のプロパティがあるとします(実際にはもっと複雑で、路線や日付など様々な次元で保存され、フライトごとに異なる販売ルールがあります。ここでは説明の便宜上無視します)。この1億のデータをメモリにキャッシュするにはどれくらいの容量が必要でしょうか?

public class FlightPriceClass
{
    /// <summary>
    /// 航空会社2レターコード 例:中国国際航空股份有限公司:CA
    /// </summary>
    public string Airline { get; set; }

    /// <summary>
    /// 出発空港3レターコード 例:上海虹橋国際空港:SHA
    /// </summary>
    public string Start { get; set; }

    /// <summary>
    /// 到着空港3レターコード 例:北京首都国際空港:PEK
    /// </summary>
    public string End { get; set; }

    /// <summary>
    /// フライト番号 例:CA0001
    /// </summary>
    public string FlightNo { get; set; }

    /// <summary>
    /// 座席クラスコード 例:Y
    /// </summary>
    public string Cabin { get; set; }

    /// <summary>
    /// 価格 単位:元
    /// </summary>
    public decimal Price { get; set; }

    /// <summary>
    /// 出発日 例:2017-01-01
    /// </summary>
    public DateOnly DepDate { get; set; }

    /// <summary>
    /// 出発時間 例:08:00
    /// </summary>
    public TimeOnly DepTime { get; set; }

    /// <summary>
    /// 到着日 例:2017-01-01
    /// </summary>
    public DateOnly ArrDate { get; set; }

    /// <summary>
    /// 到着時間 例:08:00
    /// </summary>
    public TimeOnly ArrTime { get; set; }
}

Benchmark を書いて、100万データにどれだけのスペースが必要かを調べ、そこから1億データを推測できます。

// ランダムに事前生成した100万データ。計算ロジックによる結果の不正確さを避けるため
public static readonly FlightPriceClass[] FlightPrices = Enumerable.Range(0,
        100_0000
    ).Select(index =>
        new FlightPriceClass
        {
            Airline = $"C{(char)(index % 26 + 'A')}",
            Start = $"SH{(char)(index % 26 + 'A')}",
            End = $"PE{(char)(index % 26 + 'A')}",
            FlightNo = $"{index % 1000:0000}",
            Cabin = $"{(char)(index % 26 + 'A')}",
            Price = index % 1000,
            DepDate = DateOnly.FromDateTime(BaseTime.AddHours(index)),
            DepTime = TimeOnly.FromDateTime(BaseTime.AddHours(index)),
            ArrDate = DateOnly.FromDateTime(BaseTime.AddHours(3 + index)),
            ArrTime = TimeOnly.FromDateTime(BaseTime.AddHours(3 + index)),
        }).ToArray();

// クラスを使用して保存
[Benchmakr]
public FlightPriceClass[] GetClassStore()
{
    var arrays = new FlightPriceClass[FlightPrices.Length];
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        var item = FlightPrices[i];
        arrays[i] = new FlightPriceClass
        {
            Airline = item.Airline,
            Start = item.Start,
            End = item.End,
            FlightNo = item.FlightNo,
            Cabin = item.Cabin,
            Price = item.Price,
            DepDate = item.DepDate,
            DepTime = item.DepTime,
            ArrDate = item.ArrDate,
            ArrTime = item.ArrTime
        };
    }
    return arrays;
}

最終結果を見てみましょう。下図の通りです。

上の図から、100万データで約107MBのメモリが必要であることがわかります。つまり、1つのオブジェクトは約112バイトです。すると、1億オブジェクトは約10.4GBになります。このサイズはかなり大きいです。メモリ使用量を減らす方法は他にもあるでしょうか? いくつか案を挙げてみます。

  • 文字列をintで番号付けする
  • longでタイムスタンプを保存する
  • zipのようなアルゴリズムで圧縮する
  • など

今回はこれらの方法は使わず、記事のタイトルに沿って、皆さんお気づきの方法、つまり構造体をクラスの代わりに使ってみましょう。以下のような同じ構造体を定義します。

[StructLayout(LayoutKind.Auto)]
public struct FlightPriceStruct
{
    // プロパティはクラスと同じ
    ......
}

Unsafe.SizeOf を使用して値型が必要とするメモリサイズを確認できます。以下のようにします。

この構造体はわずか88バイトで、クラスの112バイトより27%も少ないことがわかります。実際にどれだけメモリを節約できるか見てみましょう。

結果は良いですね。計算通りメモリが27%削減され、さらに代入速度が57%向上し、何よりGCの発生回数も減りました。

では、なぜ構造体はそんなにメモリを節約できるのでしょうか? ここで、構造体とクラスのデータ保存の違いについて説明します。下図はクラス配列の保存形式です。

文章配图-类.drawio

クラス配列は、配列の参照要素へのポインタのみを格納し、データは直接保存しません。また、参照型の各インスタンスには次のものがあります。

  • オブジェクトヘッダ:サイズ8バイト。CoreCLRでは「オブジェクトにロードする必要のあるすべての追加情報を格納する」と説明されており、例えばオブジェクトのロック値やHashCodeのキャッシュ値が格納されます。
  • メソッドテーブルポインタ:サイズ8バイト。型の記述データ(よく言及されるMethod Table)を指します。MTにはGCInfo、フィールド、メソッド定義などが格納されます。
  • オブジェクトプレースホルダ:サイズ8バイト。現在のGCでは、すべてのオブジェクトに最低1つの現在のポインタサイズのフィールドが必要です。空クラスの場合、オブジェクトヘッダとメソッドテーブルポインタに加えて、さらに8バイト消費します。空でない場合は、最初のフィールドが格納されます。

つまり、何も定義しない空のクラスでも、少なくとも24バイトのスペースが必要です。8バイト(オブジェクトヘッダ)+ 8バイト(メソッドテーブルポインタ)+ 8バイト(オブジェクトプレースホルダ)です。

今回の例に戻ると、空クラスではないため、各オブジェクトはデータ保存とは別に16バイト(オブジェクトヘッダとメソッドテーブル)を余分に必要とし、さらに配列がオブジェクトへのポインタを格納するために8バイト必要です。つまり、配列に格納された1つのオブジェクトは、合計24バイトの余分なスペースを消費します。次に、値型(構造体)を見てみましょう。

文章配图-结构体.drawio

上の図からわかるように、値型の配列の場合、データは配列に直接格納され、参照は不要です。そのため、同じデータを保存する場合、各構造体は24バイト(オブジェクトヘッダ、メソッドテーブルポインタ、インスタンスへのポインタ不要)を節約できます。

また、構造体の配列自体(配列は参照型)は24バイトのデータを持ち、そのオブジェクトプレースホルダは配列型の最初のフィールドである配列サイズを格納するために使われます。

ObjectLayoutInspector NuGetパッケージを使用して、オブジェクトのレイアウト情報を印刷できます。クラス定義のレイアウト情報は以下の通りで、データ保存に必要な88バイトに加えて16バイトの余分なスペースがあることがわかります。

構造体定義のレイアウト情報は以下の通りで、各構造体は実際のデータ保存のみで、余分な消費は含まれません。

さらにメモリを節約できるでしょうか? 64ビットプラットフォームでは参照(ポインタ)は8バイトですが、C#のデフォルトの文字列はUnicode-16(つまり2バイトで1文字)を使用します。航空会社コードや出発/到着空港コードなどの4文字未満のものは、char配列を使うことでポインタよりも少ないメモリで済みます。そこでコードを修正してみましょう。

// ローカル変数の初期化をスキップ
[SkipLocalsInit]
// レイアウトをExplicitに変更してカスタムレイアウト
[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
public struct FlightPriceStructExplicit
{
    // オフセットを手動で指定する必要がある
    [FieldOffset(0)]
    // 航空会社は2文字で保存
    public unsafe fixed char Airline[2];

    // 航空会社が4バイト使用するので、出発空港は4バイトオフセット
    [FieldOffset(4)]
    public unsafe fixed char Start[3];

    // 同様に出発空港が6バイトなので、10バイトオフセット
    [FieldOffset(10)]
    public unsafe fixed char End[3];

    [FieldOffset(16)]
    public unsafe fixed char FlightNo[4];

    [FieldOffset(24)]
    public unsafe fixed char Cabin[2];

    // decimal 16バイト
    [FieldOffset(28)]
    public decimal Price;

    // DateOnly 4バイト
    [FieldOffset(44)]
    public DateOnly DepDate;

    // TimeOnly 8バイト
    [FieldOffset(48)]
    public TimeOnly DepTime;
    [FieldOffset(56)]
    public DateOnly ArrDate;
    [FieldOffset(60)]
    public TimeOnly ArrTime;

}

この新しい構造体オブジェクトのレイアウト情報を見てみましょう。

今ではわずか68バイトになりました。最後の4バイトはアドレスアライメントのためです(CPUのワード長は64ビットなので気にしなくて構いません)。計算上、88バイトから29%のスペース節約になります。もちろん unsafe fixed char を使用すると直接代入はできず、データコピーが必要です。コードは以下の通りです。

// string値を設定する拡張メソッド
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static unsafe void SetTo(this string str, char* dest)
{
    fixed (char* ptr = str)
    {
        Unsafe.CopyBlock(dest, ptr, (uint)(Unsafe.SizeOf<char>() * str.Length));
    }
}

// Benchmarkのメソッド
public static unsafe FlightPriceStructExplicit[] GetStructStoreStructExplicit()
{
    var arrays = new FlightPriceStructExplicit[FlightPrices.Length];
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        ref var item = ref FlightPrices[i];
        arrays[i] = new FlightPriceStructExplicit
        {
            Price = item.Price,
            DepDate = item.DepDate,
            DepTime = item.DepTime,
            ArrDate = item.ArrDate,
            ArrTime = item.ArrTime
        };
        ref var val = ref arrays[i];
        // まずfixedしてから代入
        fixed (char* airline = val.Airline)
        fixed (char* start = val.Start)
        fixed (char* end = val.End)
        fixed (char* flightNo = val.FlightNo)
        fixed (char* cabin = val.Cabin)
        {
            item.Airline.SetTo(airline);
            item.Start.SetTo(start);
            item.End.SetTo(end);
            item.FlightNo.SetTo(flightNo);
            item.Cabin.SetTo(cabin);
        }
    }
    return arrays;
}

もう一度実行して、この保存方法で29%のスペース節約になるか確認してみましょう。

はい、84MBから65MBへ約29%のメモリ節約となり、期待通りです。

しかし、Gen0、Gen1、Gen2のGCが何度も発生していることに気づきました。実際にはこれらはすべてマネージドメモリを使用しているため、GCが回収を行う際にこの65MBのメモリをスキャンする必要があり、STW時間が長くなる可能性があります。これらのデータはキャッシュであり、しばらくは回収も変更もされないのであれば、GCにこれらをスキャンさせないようにできないでしょうか? 可能です。アンマネージドメモリを直接使用すればよいのです。Marshalクラスを使用してアンマネージドメモリを割り当て、管理できます。これはC言語で malloc 関数を使うのと同様の効果が得られます。

// アンマネージドメモリを割り当てる
// 引数は必要なバイト数
// 戻り値はメモリへのポインタ
IntPtr Marshal.AllocHGlobal(int cb);

// 割り当てたアンマネージドメモリを解放する
// 引数はMarshalによって割り当てられたメモリのポインタアドレス
void Marshal.FreeHGlobal(IntPtr hglobal);

Benchmarkのコードを修正して、アンマネージドメモリを使用するようにします。

// out ptrパラメータを定義してポインタを返す
public static unsafe int GetStructStoreUnManageMemory(out IntPtr ptr)
{
    // AllocHGlobalでメモリを割り当てる。サイズはSizeOfで構造体のサイズ×必要な数を計算
    var unManagerPtr = Marshal.AllocHGlobal(Unsafe.SizeOf<FlightPriceStructExplicit>() * FlightPrices.Length);
    ptr = unManagerPtr;
    // メモリ空間をFlightPriceStructExplicit配列に割り当てる
    var arrays = new Span<FlightPriceStructExplicit>(unManagerPtr.ToPointer(), FlightPrices.Length);
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        ref var item = ref FlightPrices[i];
        arrays[i] = new FlightPriceStructExplicit
        {
            Price = item.Price,
            DepDate = item.DepDate,
            DepTime = item.DepTime,
            ArrDate = item.ArrDate,
            ArrTime = item.ArrTime
        };
        ref var val = ref arrays[i];
        fixed (char* airline = val.Airline)
        fixed (char* start = val.Start)
        fixed (char* end = val.End)
        fixed (char* flightNo = val.FlightNo)
        fixed (char* cabin = val.Cabin)
        {
            item.Airline.SetTo(airline);
            item.Start.SetTo(start);
            item.End.SetTo(end);
            item.FlightNo.SetTo(flightNo);
            item.Cabin.SetTo(cabin);
        }
    }
    // 長さを返す
    return arrays.Length;
}

// 忘れずに、アンマネージドメモリを使用しないときは手動で解放する
[Benchmark]
public void GetStructStoreUnManageMemory()
{
    _ = FlightPriceCreate.GetStructStoreUnManageMemory(out var ptr);
    // アンマネージドメモリを解放
    Marshal.FreeHGlobal(ptr);
}

Benchmarkの結果を見てみましょう。

結果は非常に素晴らしいです。マネージドメモリにスペースを割り当てず、代入速度も以前より格段に速くなり、GCが発生するときもこのメモリ領域をスキャンする必要がなくなり、GCの負荷が軽減されました。この結果にほぼ満足できます。

これで1億のデータを保存するのに約6.3GBです。前述の他の方法(例えば以下のように、文字列を列挙型に置き換え、金額は「分」で保存し、タイムスタンプのみ保存するなど)を使用すれば、さらに削減できる可能性があります。

[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
[SkipLocalsInit]
public struct FlightPriceStructExplicit
{
	// byteで航空会社を識別(byte範囲0~255)
	[FieldOffset(0)]
	public byte Airline;

	// 符号なし整数で出発/到着空港とフライト番号を表す(2^16通り)
	[FieldOffset(1)]
	public UInt16 Start;

	[FieldOffset(3)]
	public UInt16 End;

	[FieldOffset(5)]
	public UInt16 FlightNo;

	[FieldOffset(7)]
	public byte Cabin;

	// decimalは使わず、価格は「分」単位で保存
	[FieldOffset(8)]
	public long PriceFen;

	// タイムスタンプで代用
	[FieldOffset(16)]
	public long DepTime;

	[FieldOffset(24)]
	public long ArrTime;
}

最終結果では、各データはわずか32バイトのスペースで保存でき、1億のデータでも3GB未満です。

本記事ではこれらの方法についてはこれ以上議論しません。

2.2 計算速度

では、構造体を使うと何か問題はあるでしょうか? 計算を見てみましょう。単純な条件に一致する路線をフィルタリングする処理です。まずクラスと構造体に以下のメソッドを定義します。Explicit構造体は特殊なので、Spanを使用して比較します。

// クラスと構造体で定義するメソッド(実際のフィルタリングはもっと複雑かもしれません)
// 航空会社を比較
public bool EqulasAirline(string airline)
{
    return Airline == airline;
}
// 出発空港を比較
public bool EqualsStart(string start)
{
    return Start == start;
}
// 到着空港を比較
public bool EqualsEnd(string end)
{
    return End == end;
}
// フライト番号を比較
public bool EqualsFlightNo(string flightNo)
{
    return FlightNo == flightNo;
}
// 価格が指定値より小さいか
public bool IsPriceLess(decimal min)
{
    return Price < min;
}
// Explicit構造体の場合、EqualsSpanメソッドを定義
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static unsafe bool SpanEquals(this string str, char* dest, int length)
{
    // Spanを使って2つの配列を比較
    return new Span<char>(dest, length).SequenceEqual(str.AsSpan());
}

// 実装メソッドは以下の通り
public static unsafe bool EqualsAirline(FlightPriceStructExplicit item, string airline)
{
    // 比較する長さを渡す
    return airline.SpanEquals(item.Airline, 2);
}
// 以下の方法も同様なので省略
public static unsafe bool EqualsStart(FlightPriceStructExplicit item, string start)
{
    return start.SpanEquals(item.Start, 3);
}
public static unsafe bool EqualsEnd(FlightPriceStructExplicit item, string end)
{
    return end.SpanEquals(item.End, 3);
}
public static unsafe bool EqualsFlightNo(FlightPriceStructExplicit item, string flightNo)
{
    return flightNo.SpanEquals(item.FlightNo, 4);
}
public static unsafe bool EqualsCabin(FlightPriceStructExplicit item, string cabin)
{
    return cabin.SpanEquals(item.Cabin, 2);
}
public static bool IsPriceLess(FlightPriceStructExplicit item, decimal min)
{
    return item.Price < min;
}

最後にBenchmarkのコードは以下の通りです。各保存構造に対して同じロジックを使用します。100万データだとすぐに終わってしまうため、各保存方式のデータ量は150万とします。

// 必要なデータを初期化しておく(テストへの影響を避けるため)
private static readonly FlightPriceClass[] FlightPrices = FlightPriceCreate.GetClassStore();
private static readonly FlightPriceStruct[] FlightPricesStruct = FlightPriceCreate.GetStructStore();
private static readonly FlightPriceStructUninitialized[] FlightPricesStructUninitialized =
    FlightPriceCreate.GetStructStoreUninitializedArray();
private static readonly FlightPriceStructExplicit[] FlightPricesStructExplicit =
    FlightPriceCreate.GetStructStoreStructExplicit();
// アンマネージドメモリは特殊で、ポインタアドレスのみ保存すればよい
private static IntPtr _unManagerPtr;
private static readonly int FlightPricesStructExplicitUnManageMemoryLength =
    FlightPriceCreate.GetStructStoreUnManageMemory(out _unManagerPtr);
[Benchmark(Baseline = true)]
public int GetClassStore()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        // 簡単なフィルタリング
        var item = FlightPrices[i];
        if (item.EqualsAirline("CA"))caAirline++;
        if (item.EqualsStart("SHA"))shaStart++;
        if (item.EqualsEnd("PEA"))peaStart++;
        if (item.EqualsFlightNo("0001"))ca0001FlightNo++;
        if (item.IsPriceLess(500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public int GetStructStore()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    for (int i = 0; i < FlightPricesStruct.Length; i++)
    {
        var item = FlightPricesStruct[i];
        if (item.EqualsAirline("CA"))caAirline++;
        if (item.EqualsStart("SHA"))shaStart++;
        if (item.EqualsEnd("PEA"))peaStart++;
        if (item.EqualsFlightNo("0001"))ca0001FlightNo++;
        if (item.IsPriceLess(500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public int GetFlightPricesStructExplicit()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    for (int i = 0; i < FlightPricesStructExplicit.Length; i++)
    {
        var item = FlightPricesStructExplicit[i];
        if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;
        if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;
        if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;
        if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;
        if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public unsafe int GetFlightPricesStructExplicitUnManageMemory()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);
    for (int i = 0; i < arrays.Length; i++)
    {
        var item = arrays[i];
        if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;
        if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;
        if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;
        if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;
        if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}

Benchmarkの結果は以下の通りです。

構造体単体ではクラスよりも少し遅いことがわかります。しかし、Explicitレイアウトやアンマネージドメモリを使用したものは大幅に遅くなり、倍以上の差があります。魚と熊掌は本当に兼ねられないのでしょうか?

後者2つの方法が遅い理由を分析してみましょう。原因は値のコピーです。C#では参照型はデフォルトで参照渡し、値型は値渡しであることはご存知の通りです。

  • 参照型はメソッド呼び出し時に1回だけコピーされ、長さはCPUのワード長(32ビットシステムでは4バイト、64ビットでは8バイト)です。
  • 値型は値渡しであり、値が4バイトなら4バイトコピーします。CPUワード長以下の場合は有利ですが、より大きい場合は不利になります。

私たちの構造体はCPUワード長64ビットの8バイトをはるかに超えています。そして後者のコード実装では複数回の値コピーが発生し、全体の速度が低下しています。

値コピーを回避する方法はないのでしょうか? もちろんあります。C#では値型も参照渡しが可能で、ref キーワードを使用します。値コピーが発生する箇所に ref を追加するだけです。コードは以下の通りです。

// 比較メソッドを改造して参照渡しに対応
// refを追加
public static unsafe bool EqualsAirlineRef(ref FlightPriceStructExplicit item, string airline)
{
    // 参照を渡すので、fixedでポインタを取得する必要がある
    fixed(char* ptr = item.Airline)
    {
        return airline.SpanEquals(ptr, 2);
    }
}

// Benchmark内部も参照渡しに変更
[Benchmark]
public unsafe int GetStructStoreUnManageMemoryRef()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);
    for (int i = 0; i < arrays.Length; i++)
    {
        // 配列から直接参照を取得
        ref var item = ref arrays[i];
        // 引数も直接参照を渡す
        if (FlightPriceStructExplicit.EqualsAirlineRef(ref item,"CA"))caAirline++;
        if (FlightPriceStructExplicit.EqualsStartRef(ref item,"SHA"))shaStart++;
        if (FlightPriceStructExplicit.EqualsEndRef(ref item,"PEA"))peaStart++;
        if (FlightPriceStructExplicit.EqualsFlightNoRef(ref item,"0001"))ca0001FlightNo++;
        if (FlightPriceStructExplicit.IsPriceLessRef(ref item,500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}

もう一度実行してみましょう。Explicit構造体が大きくリードし、クラスより33%も高速です。前回のラウンドでアンマネージドメモリを使用したものも好成績で2位につけました。

同じ参照渡しなのに、なぜクラスの方が遅いのでしょうか? これはさらに低レイヤーのCPU関連の知識に戻ります。CPUには基本的な計算ユニットの他に、L1、L2、L3などのデータキャッシュがあります。下図を参照してください。

これはCPUの性能に直接関係します。記事の冒頭の図を覚えていますか? CPU内部のキャッシュは最速です。そのため、最初の理由として、構造体配列のデータは連続したアドレス空間に格納されるため、CPUキャッシュに非常に有利です。一方、クラスオブジェクトは参照型であり、ポインタを介してアクセスする必要があるため、CPUキャッシュにはあまり有利ではありません。

2つ目の理由は、参照型にアクセスする際には、逆参照操作(ポインタを使用して対応するメモリ内のデータを見つける)が必要であり、構造体ではその必要がないことです。

では、この主張をどのように検証するのでしょうか? 実は BenchmarkDotNet はそのような指標を表示する機能を提供しています。BenchmarkDotNet.Diagnostics.Windows NuGetパッケージを導入し、評価対象のクラスに以下のコードを追加するだけです。

[HardwareCounters(
    HardwareCounter.LlcMisses, // キャッシュミス回数
    HardwareCounter.LlcReference)]  // 逆参照回数
public class SpeedBench : IDisposable
{
    ......
}

結果は以下の通りです。Windows ETW情報の追加統計が必要なため、少し遅くなります。

上の図から、参照型ではキャッシュミス回数が最も多く、逆参照回数も多く、これらがパフォーマンスを低下させていることがわかります。

下図に示すように、連続して格納された構造体の方が、飛び飛びの参照型のメモリアクセスよりも効率的です。また、オブジェクトのサイズが小さいほど、キャッシュに対して有利です。

文章配图-类Cache.drawio

文章配图-结构体Cache.drawio

3. まとめ

本記事では、構造体をクラスの代わりに使用することで、大量のメモリ使用量を削減し、計算性能をほぼ半減させる方法について議論しました。また、.NETにおけるアンマネージドメモリの簡単な使用についても触れました。構造体は非常に効率的な保存構造と優れた性能を持つ、私が大好きなものです。しかし、すべてのクラスを構造体に変換すべきではありません。それぞれに適したシナリオが異なります。

では、いつ構造体を使い、いつクラスを使うべきでしょうか? マイクロソフト公式から答えが出ています。

✔️ 型のインスタンスが比較的小さく、通常ライフサイクルが短いか、他のオブジェクトに埋め込まれていることが多い場合は、クラスではなく構造体を定義することを検討してください。

❌ 以下のすべての特徴を備えている場合を除き、構造体の定義は避けてください。

  • 論理的に単一の値を表し、プリミティブ型(int、doubleなど)に類似していること – 今回のキャッシュデータのように、ほとんどがプリミティブ型です。
  • インスタンスサイズが16バイト未満であること – 値コピーのコストは莫大ですが、現在ではrefを使うことでより多くの適用シナリオが可能になりました。
  • 不変であること – 今回の例では、キャッシュデータは変更されないため、この特徴を持ちます。
  • 頻繁にボックス化される必要がないこと – 頻繁なボックス化/アンボックス化はパフォーマンスに大きな損失を与えます。今回のシナリオでは、すべての関数をref対応にしているため、この状況は発生しません。

それ以外のすべての場合、型はクラスとして定義すべきです。

これらの方法からもわかるように、C#は入門は簡単ですが上限が非常に高い言語です。普段はC#の構文特性を活用して素早く要件を実現できます。そして、もしパフォーマンスのボトルネックが発生した場合、まるでCコードを書くかのようにC#コードを書くことで、Cに匹敵するパフォーマンスを得ることができます。

4. 付録

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2024/03/14

C#とJava

動的で進化し続けるソフトウェア開発の世界では、JavaとC#は二大巨頭であり、それぞれに独自の強み、哲学、エコシステムがあります。この記事では、JavaとC#を詳細に比較し、歴史的背景、言語機能、パフォーマンス指標、クロスプラットフォーム機能などを考察します。

続きを読む
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む