チュートリアル: 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 割り当てオブジェクト追跡] オプションをオンにしますが、それ以外はオンにしません。 アプリケーションを実行して完了します。 プロファイラーによって、オブジェクトの割り当てが測定され、割り当てとガベージ コレクションのサイクルについて報告されます。 次の画像のようなグラフが表示されます。
前のグラフは、割り当てを最小限に抑えることによってパフォーマンスが向上することを示しています。 ライブ オブジェクト グラフにノコギリ波パターンが表示されます。 これは、多くのオブジェクトが作成されてすぐにガベージになることを示します。 オブジェクト差分グラフに示されているように、これらは、後で収集されます。 下向きの赤いバーは、ガベージ コレクション サイクルを示します。
次に、グラフの下にある [割り当て] タブを見てください。 この表は、最も多く割り当てられる型を示します。
割り当てが最も多いのは、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;
SensorMeasurement
が class
ではなく 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 (DateTime と string
) が含まれています。 その構造体は、参照よりもかなり大きくなります。 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());
プロファイラーを使用してアプリを実行し、割り当ての更新されたテーブルを見てみましょう。
多数の割り当てを削除し、アプリのパフォーマンスを向上しました。
アプリケーションでの ref safety の使用
これらの手法は、低レベルのパフォーマンス チューニングです。 これらは、ホット パスに適用した場合、変更前後の影響を測定すると、アプリケーションのパフォーマンスが向上している可能性があります。 ほとんどの場合に従うサイクルは、次のとおりです。
- "割り当てを測定する": 最も多く割り当てられている型と、いつヒープ割り当てを削減できるかを判断します。
- "クラスを構造体に変換する": 多くの場合、型は
class
からstruct
に変換できます。 アプリでは、ヒープ割り当てを行う代わりにスタック領域を使用します。 - "セマンティクスを保持する":
class
をstruct
に変換すると、パラメーターと戻り値のセマンティクスに影響を与える可能性があります。 パラメーターを変更するすべてのメソッドでは、そのパラメーターをref
修飾子でマークする必要があります。 それにより、変更が適正なオブジェクトに対して行われることが保証されます。 同様に、プロパティまたはメソッドの戻り値を呼び出し元で変更する必要がある場合、その戻り値をref
修飾子でマークする必要があります。 - "コピーを回避する": 大きな構造体をパラメーターとして渡す場合、そのパラメーターを
in
修飾子でマークできます。 参照を数バイトで渡すことができ、メソッドによって元の値が変更されないことが保証されます。 また、readonly ref
で値を返して、変更できない参照を返すこともできます。
これらの手法を使用すると、コードのホット パスのパフォーマンスを向上できます。
.NET