次の方法で共有


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

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

このチュートリアルで最適なエクスペリエンスを得るための Visual Studio 17.5 を使用します。 メモリ使用量の分析に使用される .NET オブジェクト割り当てツールは、Visual Studio の一部です。 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 オブジェクト割り当てツールを使用してアプリケーションを実行します。 Release ビルドではなく、Debug ビルドを使用していることを確認します。 [ デバッグ ] メニューで、 パフォーマンス プロファイラーを開きます。 .NET オブジェクト割り当て追跡オプションをオンにしますが、それ以外は何も行いません。 アプリケーションを実行して完了します。 プロファイラーは、オブジェクトの割り当てを測定し、割り当てとガベージ コレクション サイクルに関するレポートを行います。 次の図のようなグラフが表示されます。

最適化の前に侵入者アラート アプリを実行するための割り当てグラフ。

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

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

最も頻繁に割り当てられる型を示すグラフ。

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;

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

SensorMeasurement measure = default;

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

Von Bedeutung

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

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

public readonly struct SensorMeasurement

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

コピーの作成を避ける

アプリから大量の不要な割り当てが削除されました。 SensorMeasurement型は、テーブル内のどこにも表示されません。

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

次の手順では、測定値を返すメソッドを見つけるか、または測定を引数として取得し、可能な場合は参照を使用します。 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をもう一度見てみましょう。 in パラメーターに measurement 修飾子を追加する必要があります。

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

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

最終的な変更セットは、ホット パスで作成されないため、このアプリケーションのパフォーマンスに大きな影響はありません。 これらの変更は、パフォーマンス チューニングで使用するその他の手法の一部を示しています。 最初の 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 オブジェクトを作成するには、複数の割り当てが必要です。 1 つは Room 自体用で、1 つは含まれている class 型のメンバーごとに 1 つです。 これらのプロパティの 2 つを class 型から struct 型 ( DebounceMeasurement 型と AverageMeasurement 型) に変換できます。 両方の型を使用して、その変換を実行してみましょう。

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

public DebounceMeasurement() { }

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

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

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

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

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

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}
        """;
}

次に、Room プロパティに使用したのと同じ手法に従って、Debounce クラスを変更します。 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());

プロファイラーを使用してアプリを実行し、更新されたテーブルで割り当てを確認します。

変更後に侵入者アラート アプリを実行するための割り当てグラフ。

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

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

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

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

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