次の方法で共有


チュートリアル: ref safety を使用してメモリ割り当てを削減する

多くの場合、.NET アプリケーションのパフォーマンス チューニングには 2 つの手法が必要です。 まず、ヒープ割り当ての数とサイズを削減します。 次に、データをコピーする頻度を削減します。 Visual Studio では、アプリケーションでメモリを使用する方法を分析するために役立つ優れた ツールが用意されています。 アプリで不要な割り当てが行われている場所を突き止めたら、それらの割り当てを最小限に抑えるための変更を行います。 class 型を struct 型に変更します。 ref safety 機能を使用してセマンティクスを保持し、余分なコピーを最小限に抑えます。

このチュートリアルで最適なエクスペリエンスを得るには、Visual Studio 17.5 を使用してください。 Visual Studio には、メモリ使用量の分析に使用する .NET オブジェクト割り当てツールが含まれています。 Visual Studio Code とコマンド ラインを使用して、アプリケーションを実行し、すべての変更を行うことができます。 ただし、変更の分析結果を表示することはできません。

使用するアプリケーションは、いくつかのセンサーを監視して、貴重品を集めた秘密のギャラリーに侵入者がいないかどうかを判断する IoT アプリケーションのシミュレーションです。 IoT センサーは、空気中の酸素 (O2) と二酸化炭素 (CO2) の混合を測定したデータを常時送信しています。 また、温度と相対湿度も報告します。 これらの各値は、常に多少変動しています。 ただし、人が部屋に入ると、変化の幅が多少大きくなります。方向は常に同じです。つまり、酸素は減少し、二酸化炭素は増加します。また、温度が上昇し、相対湿度も上昇します。 これらのセンサーが組み合わさって増加を示す場合は、侵入警報が始動します。

このチュートリアルでは、アプリケーションを実行し、メモリ割り当ての測定を行った後、割り当ての数を削減してパフォーマンスを向上します。 ソース コードは、サンプル ブラウザーで入手できます。

スターター アプリケーションを調べる

アプリケーションをダウンロードし、スターター サンプルを実行します。 スターター アプリケーションは正常に動作しますが、各測定サイクルで小さなオブジェクトをいくつも割り当てるため、そのパフォーマンスは、時間が経つにしたがって徐々に低下します。

Press <return> to start simulation

Debounced measurements:
    Temp:      67.332
    Humidity:  41.077%
    Oxygen:    21.097%
    CO2 (ppm): 404.906
Average measurements:
    Temp:      67.332
    Humidity:  41.077%
    Oxygen:    21.097%
    CO2 (ppm): 404.906

Debounced measurements:
    Temp:      67.349
    Humidity:  46.605%
    Oxygen:    20.998%
    CO2 (ppm): 408.707
Average measurements:
    Temp:      67.349
    Humidity:  46.605%
    Oxygen:    20.998%
    CO2 (ppm): 408.707

多数の行が削除されました。

Debounced measurements:
    Temp:      67.597
    Humidity:  46.543%
    Oxygen:    19.021%
    CO2 (ppm): 429.149
Average measurements:
    Temp:      67.568
    Humidity:  45.684%
    Oxygen:    19.631%
    CO2 (ppm): 423.498
Current intruders: 3
Calculated intruder risk: High

Debounced measurements:
    Temp:      67.602
    Humidity:  46.835%
    Oxygen:    19.003%
    CO2 (ppm): 429.393
Average measurements:
    Temp:      67.568
    Humidity:  45.684%
    Oxygen:    19.631%
    CO2 (ppm): 423.498
Current intruders: 3
Calculated intruder risk: High

コードを調べると、アプリケーションがどのように動作するかを確認できます。 メイン プログラムでは、シミュレーションを実行します。 <Enter> キーを押すと、部屋が作成され、いくつかの最初のベースライン データが収集されます。

Console.WriteLine("Press <return> to start simulation");
Console.ReadLine();
var room = new Room("gallery");
var r = new Random();

int counter = 0;

room.TakeMeasurements(
    m =>
    {
        Console.WriteLine(room.Debounce);
        Console.WriteLine(room.Average);
        Console.WriteLine();
        counter++;
        return counter < 20000;
    });

ベースライン データが確立されると、部屋でシミュレーションが実行され、乱数ジェネレーターによって、侵入者がその部屋に入ったかどうかが判断されます。

counter = 0;
room.TakeMeasurements(
    m =>
    {
        Console.WriteLine(room.Debounce);
        Console.WriteLine(room.Average);
        room.Intruders += (room.Intruders, r.Next(5)) switch
        {
            ( > 0, 0) => -1,
            ( < 3, 1) => 1,
            _ => 0
        };

        Console.WriteLine($"Current intruders: {room.Intruders}");
        Console.WriteLine($"Calculated intruder risk: {room.RiskStatus}");
        Console.WriteLine();
        counter++;
        return counter < 200000;
    });

その他の種類としては、測定値、過去 50 回の測定値の平均であるデバウンス測定値、取得されたすべての測定値の平均があります。

次に、.NET オブジェクト割り当てツールを使用してアプリケーションを実行します。 Debug ビルドではなく、Release ビルドを使用していることを確認してください。 [デバッグ] メニューで、[パフォーマンス プロファイラー] を開きます。 [.NET 割り当てオブジェクト追跡] オプションをオンにしますが、それ以外はオンにしません。 アプリケーションを実行して完了します。 プロファイラーによって、オブジェクトの割り当てが測定され、割り当てとガベージ コレクションのサイクルについて報告されます。 次の画像のようなグラフが表示されます。

Allocation graph for running the intruder alert app before any optimizations.

前のグラフは、割り当てを最小限に抑えることによってパフォーマンスが向上することを示しています。 ライブ オブジェクト グラフにノコギリ波パターンが表示されます。 これは、多くのオブジェクトが作成されてすぐにガベージになることを示します。 オブジェクト差分グラフに示されているように、これらは、後で収集されます。 下向きの赤いバーは、ガベージ コレクション サイクルを示します。

次に、グラフの下にある [割り当て] タブを見てください。 この表は、最も多く割り当てられる型を示します。

Chart that shows which types are allocated most frequently.

割り当てが最も多いのは、System.String 型です。 最も重要なタスクは、文字列の割り当ての頻度を最小限に抑えることです。 このアプリケーションは、書式設定された多数の出力を常時コンソールに出力します。 このシミュレーションの場合、メッセージを保持する必要があるため、次の 2 行 (SensorMeasurement 型と IntruderRisk 型) に焦点を絞ります。

SensorMeasurement の行をダブルクリックします。 すべての割り当てが、static メソッド SensorMeasurement.TakeMeasurement で行われていることがわかります。 このメソッドを次のスニペットに示します。

public static SensorMeasurement TakeMeasurement(string room, int intruders)
{
    return new SensorMeasurement
    {
        CO2 = (CO2Concentration + intruders * 10) + (20 * generator.NextDouble() - 10.0),
        O2 = (O2Concentration - intruders * 0.01) + (0.005 * generator.NextDouble() - 0.0025),
        Temperature = (TemperatureSetting + intruders * 0.05) + (0.5 * generator.NextDouble() - 0.25),
        Humidity = (HumiditySetting + intruders * 0.005) + (0.20 * generator.NextDouble() - 0.10),
        Room = room,
        TimeRecorded = DateTime.Now
    };
}

すべての測定で、新しい SensorMeasurement オブジェクトが割り当てられます。これは class 型です。 SensorMeasurement が作成されるたびにヒープ割り当てが発生します。

クラスを構造体に変更する

次のコードは、SensorMeasurement の最初の宣言を示します。

public class SensorMeasurement
{
    private static readonly Random generator = new Random();

    public static SensorMeasurement TakeMeasurement(string room, int intruders)
    {
        return new SensorMeasurement
        {
            CO2 = (CO2Concentration + intruders * 10) + (20 * generator.NextDouble() - 10.0),
            O2 = (O2Concentration - intruders * 0.01) + (0.005 * generator.NextDouble() - 0.0025),
            Temperature = (TemperatureSetting + intruders * 0.05) + (0.5 * generator.NextDouble() - 0.25),
            Humidity = (HumiditySetting + intruders * 0.005) + (0.20 * generator.NextDouble() - 0.10),
            Room = room,
            TimeRecorded = DateTime.Now
        };
    }

    private const double CO2Concentration = 409.8; // increases with people.
    private const double O2Concentration = 0.2100; // decreases
    private const double TemperatureSetting = 67.5; // increases
    private const double HumiditySetting = 0.4500; // increases

    public required double CO2 { get; init; }
    public required double O2 { get; init; }
    public required double Temperature { get; init; }
    public required double Humidity { get; init; }
    public required string Room { get; init; }
    public required DateTime TimeRecorded { get; init; }

    public override string ToString() => $"""
            Room: {Room} at {TimeRecorded}:
                Temp:      {Temperature:F3}
                Humidity:  {Humidity:P3}
                Oxygen:    {O2:P3}
                CO2 (ppm): {CO2:F3}
            """;
}

この型には、多数の class 測定値が含まれるため、最初は double として作成されました。 これは、ホット パスでコピーするよりも大きくなります。 しかし、その決定は、割り当ての数が多くなることを意味します。 型を class から struct に変更します。

class から struct に変更すると、いくつかのコンパイラ エラーが発生します。これは、元のコードのいくつかの場所で null 参照チェックが使用されていたためです。 1 つ目は、DebounceMeasurement クラスの AddMeasurement メソッド内にあります。

public void AddMeasurement(SensorMeasurement datum)
{
    int index = totalMeasurements % debounceSize;
    recentMeasurements[index] = datum;
    totalMeasurements++;
    double sumCO2 = 0;
    double sumO2 = 0;
    double sumTemp = 0;
    double sumHumidity = 0;
    for (int i = 0; i < debounceSize; i++)
    {
        if (recentMeasurements[i] is not null)
        {
            sumCO2 += recentMeasurements[i].CO2;
            sumO2+= recentMeasurements[i].O2;
            sumTemp+= recentMeasurements[i].Temperature;
            sumHumidity += recentMeasurements[i].Humidity;
        }
    }
    O2 = sumO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    CO2 = sumCO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Temperature = sumTemp / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Humidity = sumHumidity / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
}

DebounceMeasurement 型には、50 個の測定の配列が含まれます。 センサーの読み取り値は、過去 50 下位の測定値の平均として報告されます。 これにより、読み取り値のノイズが削減されます。 50 個すべての読み取りが完了するまで、これらの値は null です。 コードでは、システムの起動時に平均を正しく報告するために、null 参照をチェックします。 SensorMeasurement 型を構造体に変更したら、別のテストを使用する必要があります。 SensorMeasurement 型には、部屋の識別子を表す string が含まれるため、代わりにそのテストを使用できます。

if (recentMeasurements[i].Room is not null)

他の 3 つのコンパイラ エラーはすべて、部屋で繰り返し測定を行うメソッド内にあります。

public void TakeMeasurements(Func<SensorMeasurement, bool> MeasurementHandler)
{
    SensorMeasurement? measure = default;
    do {
        measure = SensorMeasurement.TakeMeasurement(Name, Intruders);
        Average.AddMeasurement(measure);
        Debounce.AddMeasurement(measure);
    } while (MeasurementHandler(measure));
}

スターター メソッドでは、SensorMeasurement のローカル変数は "Null 許容参照型" です。

SensorMeasurement? measure = default;

SensorMeasurementclass ではなく struct になったため、Null 許容は "Null 許容参照型" になります。 宣言を値の型に変更すると、残りのコンパイラ エラーを修正できます。

SensorMeasurement measure = default;

コンパイラ エラーが解決されたので、コードを調べてセマンティクスが変更されていないことを確認する必要があります。 struct 型は値で渡されるため、メソッド パラメーターに加えられた変更は、メソッドが返された後は表示されません。

重要

型を class から struct に変更すると、プログラムのセマンティクスが変更される可能性があります。 class 型がメソッドに渡される場合、メソッドで行われるすべての変更は、引数に対して行われます。 struct 型がメソッドに渡される場合、メソッドで行われる変更は、引数の "コピー" に対して行われます。 これは、仕様によって引数を変更するメソッドはすべて、ref から class に変更した引数の型に対して struct 修飾子を使用するように更新する必要があります。

SensorMeasurement 型には状態を変更するメソッドは含まれないため、このサンプルでは問題ありません。 SensorMeasurement 構造体に readonly 修飾子を追加すると、それを証明できます。

public readonly struct SensorMeasurement

コンパイラでは、SensorMeasurement 構造体の readonly の性質が強制されます。 コードの検査で、状態を変更したメソッドが見つからなかった場合、コンパイラによって通知されます。 アプリは引き続きエラーなしでビルドされるため、この型は readonly です。 型を class から struct に変更する場合、readonly 修飾子を追加すると、struct の状態を変更するメンバーを見つけやすくなります。

コピーの作成を回避する

大量の不要な割り当てをアプリから削除しました。 SensorMeasurement 型は表にまったく表示されなくなりました。

現在、SensorMeasurement 構造体がパラメーターまたは戻り値として使用されるたびに、その構造体をコピーする余分な作業が行われています。 SensorMeasurement 構造体には、4 つの double (DateTimestring) が含まれています。 その構造体は、参照よりもかなり大きくなります。 SensorMeasurement 型が使用されている場所に ref または in 修飾子を追加してみましょう。

次の手順として、測定値を返すか、または測定値を引数として受け取るメソッドと、可能な場合は参照を使用するメソッドを見つけます。 SensorMeasurement 構造体から始めます。 静的 TakeMeasurement メソッドは、新しい SensorMeasurement を作成して返します。

public static SensorMeasurement TakeMeasurement(string room, int intruders)
{
    return new SensorMeasurement
    {
        CO2 = (CO2Concentration + intruders * 10) + (20 * generator.NextDouble() - 10.0),
        O2 = (O2Concentration - intruders * 0.01) + (0.005 * generator.NextDouble() - 0.0025),
        Temperature = (TemperatureSetting + intruders * 0.05) + (0.5 * generator.NextDouble() - 0.25),
        Humidity = (HumiditySetting + intruders * 0.005) + (0.20 * generator.NextDouble() - 0.10),
        Room = room,
        TimeRecorded = DateTime.Now
    };
}

これはそのままにして、値で返します。 ref で返そうとすると、コンパイラ エラーが発生します。 メソッドでローカルに作成された新しい構造体に ref を返すことはできません。 不変構造体の仕様は、作成時に測定値の値のみを設定できることを意味します。 このメソッドは、新しい構造体を作成する必要があります。

もう一度 DebounceMeasurement.AddMeasurement を見てみましょう。 measurement パラメーターに in 修飾子を追加する必要があります。

public void AddMeasurement(in SensorMeasurement datum)
{
    int index = totalMeasurements % debounceSize;
    recentMeasurements[index] = datum;
    totalMeasurements++;
    double sumCO2 = 0;
    double sumO2 = 0;
    double sumTemp = 0;
    double sumHumidity = 0;
    for (int i = 0; i < debounceSize; i++)
    {
        if (recentMeasurements[i].Room is not null)
        {
            sumCO2 += recentMeasurements[i].CO2;
            sumO2+= recentMeasurements[i].O2;
            sumTemp+= recentMeasurements[i].Temperature;
            sumHumidity += recentMeasurements[i].Humidity;
        }
    }
    O2 = sumO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    CO2 = sumCO2 / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Temperature = sumTemp / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
    Humidity = sumHumidity / ((totalMeasurements > debounceSize) ? debounceSize : totalMeasurements);
}

これにより、1 つのコピー操作が保存されます。 in パラメーターは、呼び出し元によって既に作成されているコピーへの参照です。 また、Room 型の TakeMeasurement メソッドを使用してコピーを保存することもできます。 このメソッドは、引数を ref で渡すときにコンパイラによって安全性が提供される方法を示しています。 Room 型の最初の TakeMeasurement メソッドは、Func<SensorMeasurement, bool> の引数を受け取ります。 その宣言に in または ref 修飾子を追加しようとすると、コンパイラによってエラーが報告されます。 ラムダ式に ref 引数を渡すことはできません。 コンパイラでは、呼び出された式で参照がコピーされないことを保証できません。 ラムダ式で参照を "キャプチャ" する場合、参照の有効期限が、参照先の値よりも長い可能性があります。 "ref セーフ コンテキスト" の外部でこれにアクセスすると、メモリが破損します。 これは、ref 安全規則では許可されていません。 詳細については、ref safety 機能の概要を参照してください。

セマンティクスを保持する

型はホット パスで作成されないため、最終的な一連の変更は、このアプリケーションのパフォーマンスに大きな影響を与えません。 これらの変更は、パフォーマンス チューニングで使用する他の手法の一部を示しています。 最初の Room クラスを見てみましょう。

public class Room
{
    public AverageMeasurement Average { get; } = new ();
    public DebounceMeasurement Debounce { get; } = new ();
    public string Name { get; }

    public IntruderRisk RiskStatus
    {
        get
        {
            var CO2Variance = (Debounce.CO2 - Average.CO2) > 10.0 / 4;
            var O2Variance = (Average.O2 - Debounce.O2) > 0.005 / 4.0;
            var TempVariance = (Debounce.Temperature - Average.Temperature) > 0.05 / 4.0;
            var HumidityVariance = (Debounce.Humidity - Average.Humidity) > 0.20 / 4;
            IntruderRisk risk = IntruderRisk.None;
            if (CO2Variance) { risk++; }
            if (O2Variance) { risk++; }
            if (TempVariance) { risk++; }
            if (HumidityVariance) { risk++; }
            return risk;
        }
    }

    public int Intruders { get; set; }

    
    public Room(string name)
    {
        Name = name;
    }

    public void TakeMeasurements(Func<SensorMeasurement, bool> MeasurementHandler)
    {
        SensorMeasurement? measure = default;
        do {
            measure = SensorMeasurement.TakeMeasurement(Name, Intruders);
            Average.AddMeasurement(measure);
            Debounce.AddMeasurement(measure);
        } while (MeasurementHandler(measure));
    }
}

この型には、いくつかのプロパティが含まれています。 一部は、class 型です。 Room オブジェクトを作成するには、複数の割り当て (Room 自体に 1 つ、それに含まれる class 型のメンバーごとに 1 つずつ) が必要です。 これらのプロパティのうちの 2 つ (DebounceMeasurement 型と AverageMeasurement 型) を class 型から struct 型に変換できます。 両方の型を使用してその変換を行ってみましょう。

DebounceMeasurement 型を class から struct に変更します。 これにより、コンパイラ エラー CS8983: A 'struct' with field initializers must include an explicitly declared constructor が発生します。 これは、パラメーターなしの空のコンストラクターを追加することで修正できます。

public DebounceMeasurement() { }

この要件の詳細については、構造体に関する言語リファレンスの記事を参照してください。

Object.ToString() のオーバーライドでは、構造体の値は変更されません。 そのメソッド宣言に readonly 修飾子を追加できます。 DebounceMeasurement 型は "変更可能" であるため、変更が破棄されたコピーに影響しないように注意する必要があります。 AddMeasurement メソッドによって、オブジェクトの状態が変更されます。 これは、Room クラスの TakeMeasurements メソッド内から呼び出されます。 このメソッドを呼び出した後も、これらの変更を保持する必要があります。 DebounceMeasurement 型の単一インスタンスへの "参照" を返すように Room.Debounce プロパティを変更できます。

private DebounceMeasurement debounce = new();
public ref readonly DebounceMeasurement Debounce { get { return ref debounce; } }

前の例では、いくつかの変更があります。 まず、"プロパティ" は、この部屋で所有されるインスタンスへの読み取り専用参照を返す読み取り専用プロパティです。 これは、Room オブジェクトがインスタンス化されるときに初期化される宣言済みフィールドによってサポートされるようになりました。 これらの変更を行った後、AddMeasurement メソッドの実装を更新します。 読み取り専用プロパティ Debounce ではなく、プライベート バッキング フィールド debounce が使用されます。 こうすることで、変更は、初期化中に作成された単一のインスタンスで行われます。

Average プロパティでも同じ手法が機能します。 まず、AverageMeasurement 型を class から struct に変更し、ToString メソッドに readonly 修飾子を追加します。

namespace IntruderAlert;

public struct AverageMeasurement
{
    private double sumCO2 = 0;
    private double sumO2 = 0;
    private double sumTemperature = 0;
    private double sumHumidity = 0;
    private int totalMeasurements = 0;

    public AverageMeasurement() { }

    public readonly double CO2 => sumCO2 / totalMeasurements;
    public readonly double O2 => sumO2 / totalMeasurements;
    public readonly double Temperature => sumTemperature / totalMeasurements;
    public readonly double Humidity => sumHumidity / totalMeasurements;

    public void AddMeasurement(in SensorMeasurement datum)
    {
        totalMeasurements++;
        sumCO2 += datum.CO2;
        sumO2 += datum.O2;
        sumTemperature += datum.Temperature;
        sumHumidity+= datum.Humidity;
    }

    public readonly override string ToString() => $"""
        Average measurements:
            Temp:      {Temperature:F3}
            Humidity:  {Humidity:P3}
            Oxygen:    {O2:P3}
            CO2 (ppm): {CO2:F3}
        """;
}

次に、Debounce プロパティに使用した同じ手法に従って Room クラスを変更します。 Average プロパティによって、平均測定値のプライベート フィールドに readonly ref が返されます。 AddMeasurement メソッドによって、内部フィールドが変更されます。

private AverageMeasurement average = new();
public  ref readonly AverageMeasurement Average { get { return ref average; } }

ボックス化を回避する

パフォーマンスを向上するための最後の変更が 1 つあります。 メイン プログラムでは、リスク評価など、部屋の統計が出力されています。

Console.WriteLine($"Current intruders: {room.Intruders}");
Console.WriteLine($"Calculated intruder risk: {room.RiskStatus}");

生成された ToString を呼び出すと、列挙値がボックス化されます。 これは、推定されたリスクの値に基づいて文字列を書式設定するオーバーライドを Room クラスに記述することによって回避できます。

public override string ToString() =>
    $"Calculated intruder risk: {RiskStatus switch
    {
        IntruderRisk.None => "None",
        IntruderRisk.Low => "Low",
        IntruderRisk.Medium => "Medium",
        IntruderRisk.High => "High",
        IntruderRisk.Extreme => "Extreme",
        _ => "Error!"
    }}, Current intruders: {Intruders.ToString()}";

次に、この新しい ToString メソッドを呼び出すようにメイン プログラムのコードを変更します。

Console.WriteLine(room.ToString());

プロファイラーを使用してアプリを実行し、割り当ての更新されたテーブルを見てみましょう。

Allocation graph for running the intruder alert app after modifications.

多数の割り当てを削除し、アプリのパフォーマンスを向上しました。

アプリケーションでの ref safety の使用

これらの手法は、低レベルのパフォーマンス チューニングです。 これらは、ホット パスに適用した場合、変更前後の影響を測定すると、アプリケーションのパフォーマンスが向上している可能性があります。 ほとんどの場合に従うサイクルは、次のとおりです。

  • "割り当てを測定する": 最も多く割り当てられている型と、いつヒープ割り当てを削減できるかを判断します。
  • "クラスを構造体に変換する": 多くの場合、型は class から struct に変換できます。 アプリでは、ヒープ割り当てを行う代わりにスタック領域を使用します。
  • "セマンティクスを保持する": classstruct に変換すると、パラメーターと戻り値のセマンティクスに影響を与える可能性があります。 パラメーターを変更するすべてのメソッドでは、そのパラメーターを ref 修飾子でマークする必要があります。 それにより、変更が適正なオブジェクトに対して行われることが保証されます。 同様に、プロパティまたはメソッドの戻り値を呼び出し元で変更する必要がある場合、その戻り値を ref 修飾子でマークする必要があります。
  • "コピーを回避する": 大きな構造体をパラメーターとして渡す場合、そのパラメーターを in 修飾子でマークできます。 参照を数バイトで渡すことができ、メソッドによって元の値が変更されないことが保証されます。 また、readonly ref で値を返して、変更できない参照を返すこともできます。

これらの手法を使用すると、コードのホット パスのパフォーマンスを向上できます。