Bill Chiles (Roslyn コンパイラーのプログラム マネージャー) が「パフォーマンスの重要な事実と .NET Framework のヒント」という記事を執筆し、有名なブロガーである Hanjiang Dudiao がその記事を抜粋し、パフォーマンスの最適化に関するいくつかの提案と考え方を共有しました。最適化は時期尚早であること、優れたツールが重要であること、パフォーマンスの鍵はメモリの割り当てにあることなどを挙げ、開発者は根拠なく盲目的に最適化をすべきではなく、まずパフォーマンスの問題の原因を特定して見つけることが最も重要であると指摘しました。
全文は次のとおりです:
この記事では、マネージド コードを使用して C# および VB コンパイラを書き換えた経験から得たパフォーマンスの最適化に関する提案をいくつか提供し、C# コンパイラを作成する際の実際のシナリオをいくつか使用します。これらの最適化エクスペリエンスを例として示します。 .NET プラットフォーム用のアプリケーションの開発は非常に生産的です。 .NET プラットフォーム上の強力で安全なプログラミング言語と豊富なクラス ライブラリにより、アプリケーション開発が効率化されます。しかし、大きな力には大きな責任が伴います。 .NET Framework の強力な機能を使用する必要がありますが、同時に、ファイルやデータベースなどの大量のデータを処理する必要がある場合はコードを調整する準備をしておく必要があります。
新しいコンパイラーからのパフォーマンス最適化の教訓がアプリケーションにも当てはまる理由
Microsoft はマネージ コードを使用するように C# および Visual Basic コンパイラーを書き換え、コード モデリングと分析のための一連の新しい API を提供し、Visual を有効にするコンパイル ツールの開発を提供していますStudio では、コードを意識したより豊かなプログラミング エクスペリエンスが得られます。コンパイラーを書き直し、新しいコンパイラーで Visual Studio を開発した経験により、非常に有益なパフォーマンス最適化の経験を得ることができました。これは、大規模な .NET アプリケーションや、大量のデータを処理する必要がある一部の APP にも使用できます。 C# コンパイラの例からこれらの洞察を引き出すには、コンパイラについて何も知る必要はありません。
Visual Studio はコンパイラ API を使用して、コードのキーワードの色付け、構文の充填リスト、エラー波線プロンプト、パラメーター プロンプト、コードの問題と変更の提案などの強力な Intellisense 機能を実装します。これらの機能は開発者に人気があります。開発者がコードを入力または変更すると、Visual Studio はコードを動的にコンパイルして、コード分析とプロンプトを取得します。
ユーザーがアプリを操作するとき、通常はソフトウェアの応答性を望みます。コマンドの入力または実行中にアプリケーション インターフェイスがブロックされるべきではありません。ヘルプまたはプロンプトは、すぐに表示したり、ユーザーが入力を続けている間停止したりできます。現在のアプリでは、長期的な計算を実行するときに UI スレッドがブロックされ、プログラムが十分にスムーズではないとユーザーに感じさせないようにする必要があります。
新しいコンパイラの詳細については、.NET Compiler Platform ("Roslyn") にアクセスしてください
基本的な事項
.NET のパフォーマンスをチューニングし、応答性の高いアプリケーションを開発する場合は、次の基本的な事項を考慮してください:
ヒント1: 時期尚早に最適化しないでください
コードの作成は想像よりも複雑で、パフォーマンスのためにコードを保守、デバッグ、最適化する必要があります。 経験豊富なプログラマは通常、問題の解決策を自然に思いつき、効率的なコードを作成します。 しかし、場合によっては、コードを時期尚早に最適化してしまうという問題に陥ることがあります。たとえば、単純な配列で十分な場合もありますが、ハッシュ テーブルを使用するように最適化する必要がある場合もあります。また、単純な再計算で十分な場合もありますが、メモリ リークを引き起こす可能性がある複雑なキャッシュを使用する必要があります。問題が見つかった場合は、まずパフォーマンスの問題をテストしてから、コードを分析する必要があります。
ヒント 2: 評価はせず、ただ推測するだけです
分析と測定は嘘をつきません。測定により、CPU がフル稼働しているか、ディスク I/O がブロックされているかがわかります。プロファイルは、アプリケーションが割り当てるメモリの種類と量、および CPU がガベージ コレクションに多くの時間を費やしているかどうかを示します。
主要なユーザーエクスペリエンスやシナリオに対してパフォーマンス目標を設定し、パフォーマンスを測定するためのテストを作成する必要があります。科学的手法を使用して標準以下のパフォーマンスの理由を分析する手順は次のとおりです。評価レポートを指針として使用し、考えられる状況を仮説化し、実験コードを作成するかコードを変更して仮説や修正を検証します。基本的なパフォーマンス指標を設定し、頻繁にテストを行うと、パフォーマンスの低下を引き起こす一部の変更を回避でき、不必要な変更に時間を浪費することがなくなります。
キーポイント 3: 優れたツールは重要です
優れたツールを使用すると、パフォーマンスに影響を与える最大の要因 (CPU、メモリ、ディスク) をすばやく特定でき、これらのボトルネックを生成するコードを特定するのに役立ちます。 Microsoft は、Visual Studio Profiler、Windows Phone Analysis Tool、PerfView など、多くのパフォーマンス テスト ツールをリリースしています。
PerfView は、パフォーマンスに影響を与えるいくつかの根深い問題 (ディスク I/O、 GC イベント、メモリなど)、この例は後で示します。パフォーマンス関連の Event Tracing for Windows (ETW) イベントをキャプチャし、この情報をアプリケーション、プロセス、スタック、スレッドのスケールで表示できます。 PerfView は、アプリケーションによって割り当てられたメモリの量と内容、およびメモリ割り当てに対するアプリケーション内の関数とコール スタックの寄与を表示できます。これらの側面の詳細については、ツールのダウンロードとともにリリースされた PerfView に関する非常に詳細なヘルプ、デモ、およびビデオ チュートリアル (Channel9 のビデオ チュートリアルなど) を参照できます
ヒント 4: すべてはメモリ割り当てに関連しています
応答性の高い .NET ベースのアプリケーションを作成する鍵は、バブル ソートの代わりにクイック ソートを使用するなど、適切なアルゴリズムを使用することだと考えられていますが、そうではありません。応答性の高いアプリを作成する際の最大の要素は、特にアプリが非常に大きい場合や大量のデータを処理する場合に、メモリの割り当てです。
新しいコンパイラー API を使用して応答性の高い IDE を開発する実践では、作業のほとんどは、メモリ割り当てを回避し、キャッシュ戦略を管理する方法に費やされます。 PerfView トレースは、新しい C# および VB コンパイラのパフォーマンスが CPU パフォーマンスのボトルネックから本質的に独立していることを示しています。コンパイラーは、数百、数千、さらには数万行のコードを読み取り、メタデータを読み取り、コンパイルされたコードを生成します。これらの操作は、実際には I/O バウンドを大量に発生させます。 UI スレッドの遅延は、ほぼ完全にガベージ コレクションによって引き起こされます。 .NET Framework は、ガベージ コレクションのパフォーマンスを高度に最適化しています。アプリケーション コードの実行時に、ほとんどのガベージ コレクション操作を並行して実行できます。ただし、単一のメモリ割り当て操作が高価なガベージ コレクション操作をトリガーする可能性があるため、GC はガベージ コレクション (第 2 世代タイプのガベージ コレクションなど) のためにすべてのスレッドを一時的に停止します
一般的なメモリ割り当てと例
この部分 例ですがメモリ割り当てに関してはほとんど何もありません。ただし、大規模なアプリケーションがメモリ割り当てを引き起こすこれらの小さな式を十分に実行すると、これらの式によって数百メガバイト、場合によってはギガバイトのメモリ割り当てが発生する可能性があります。たとえば、パフォーマンス テスト チームが入力シナリオの問題を特定する前に、開発者がコンパイラでコードを作成することをシミュレートする 1 分間のテストにより、数ギガバイトのメモリが割り当てられます。
ボックス化
ボックス化は、通常、スレッドスタック上またはデータ構造内に割り当てられる値の型、または一時的な値をオブジェクトにラップする必要がある場合に発生します(データを格納するためにオブジェクトを割り当て、ポインタを返すなど)。 Object オブジェクトが与えられた場合)。 .NET Framework は、メソッドのシグネチャまたは型の割り当て場所により、値の型を自動的にボックス化する場合があります。値型を参照型にラップすると、メモリ割り当てが発生します。 .NET フレームワークと言語は不必要なボックス化を回避しようとしますが、気付かないうちにボックス化操作が発生する場合があります。過剰なボックス化操作により、アプリケーションに M on G メモリが割り当てられるため、ガベージ コレクションの頻度が高くなり、時間がかかります。
PerfView でボックス化操作を表示するには、トレース (トレース) を有効にし、割り当てフェーズにある場合は、アプリケーション名の下にある GC ヒープ割り当て項目を表示します (PerfView はすべてのプロセスのリソース割り当てをレポートすることに注意してください)。 System.Int32 や System.Char などの一部の値の型を参照すると、ボックス化が発生します。タイプを選択すると、呼び出しスタックとボックス化操作が発生した関数が表示されます。 例 1 文字列メソッドとその値型パラメーター 次のコード例は、大規模システムにおける潜在的に不必要なボックス化と頻繁なボックス化操作を示しています。public class Logger { public static void WriteLine(string s) { /*...*/ } } public class BoxingExample { public void Log(int id, int size) { var s = string.Format("{0}:{1}", id, size); Logger.WriteLine(s); } }
String.Format Method (String, Object, Object)
このオーバーロードされたメソッドは、.NET Framework が int 型をオブジェクト型にボックス化する必要がある次に、それをメソッド呼び出しに渡します。この問題を解決するには、id.ToString() メソッドと size.ToString() メソッドを呼び出して、それらを string.Format メソッドに渡します。ToString() メソッドを呼び出すと、実際に次のものが割り当てられます。文字列ですが、Format メソッド内で何が起こっても、文字列型の割り当てが行われます。
var s = id.ToString() + ':' + size.ToString();
実際、上記のコード行もボックス化を引き起こします。ステートメントはコンパイル中に呼び出されます:
string.Concat(Object, Object, Object);
このメソッド、.NET Framework は Concat メソッドを呼び出すために文字定数をボックス化する必要があります。
解決策: この問題を完全に修正するのは非常に簡単です。文字列型はすでに参照型であるため、上記の一重引用符を二重引用符に置き換えます。つまり、文字定数を文字列定数に置き換えてボックス化を回避します。var s = id.ToString() + ":" + size.ToString();
例 2 列挙型のボックス化 次の例は、新しい C# および VB コンパイラーで列挙型が頻繁に使用されることによって発生します。メモリ。
public enum Color { Red, Green, Blue } public class BoxingExample { private string name; private Color color; public override int GetHashCode() { return name.GetHashCode() ^ color.GetHashCode(); } }
解決策:
このボックス化操作は、GetHashCode を呼び出すときに列挙の基になる表現をキャストすることで回避できます。
((int)color).GetHashCode()
列挙型を使用するときによく発生するもう 1 つのボックス化操作は、enum.HasFlag です。 HasFlag に渡されるパラメータはボックス化する必要があります。ほとんどの場合、HasFlag を繰り返し呼び出してビット操作をテストするのは非常に簡単で、メモリ割り当ては必要ありません。
最初の基本的なポイントを思い出してください。時期尚早に最適化しないでください。また、すべてのコードの書き直しを時期尚早に開始しないでください。 これらのボックス化のコストに注意を払い、ツールを使用して主な問題を見つけて特定した後にのみコードを変更する必要があります。
String
文字列操作はメモリ割り当ての最大の原因の 1 つであり、通常、PerfView でのメモリ割り当ての上位 5 つの原因を占めます。アプリケーションは、JSON と REST を表す文字列をシリアル化に使用します。列挙型をサポートしていなくても、文字列を使用して他のシステムと対話できます。文字列操作がパフォーマンスに重大な影響を与えることがわかった場合は、文字列クラスの Format()、Concat()、Split()、Join()、Substring() などのメソッドに注意を払う必要があります。 StringBuilder を使用すると、複数の文字列を結合するときに複数の新しい文字列を作成するオーバーヘッドを回避できますが、パフォーマンスのボトルネックの可能性を回避するために StringBuilder の作成を適切に制御する必要もあります。
例3 字符串操作
在C#编译器中有如下方法来输出方法前面的xml格式的注释。
public void WriteFormattedDocComment(string text) { string[] lines = text.Split(new[] {"\r\n", "\r", "\n"}, StringSplitOptions.None); int numLines = lines.Length; bool skipSpace = true; if (lines[0].TrimStart().StartsWith("///")) { for (int i = 0; i < numLines; i++) { string trimmed = lines[i].TrimStart(); if (trimmed.Length < 4 || !char.IsWhiteSpace(trimmed[3])) { skipSpace = false; break; } } int substringStart = skipSpace ? 4 : 3; for (int i = 0; i < numLines; i++) Console.WriteLine(lines[i].TrimStart().Substring(substringStart)); } else { /* ... */ } }
可以看到,在这片代码中包含有很多字符串操作。代码中使用类库方法来将行分割为字符串,来去除空格,来检查参数text是否是XML文档格式的注释,然后从行中取出字符串处理。
在WriteFormattedDocComment方法每次被调用时,第一行代码调用Split()就会分配三个元素的字符串数组。编译器也需要产生代码来分配这个数组。因为编译器并不知道,如果Splite()存储了这一数组,那么其他部分的代码有可能会改变这个数组,这样就会影响到后面对WriteFormattedDocComment方法的调用。每次调用Splite()方法也会为参数text分配一个string,然后在分配其他内存来执行splite操作。
WriteFormattedDocComment方法中调用了三次TrimStart()方法,在内存环中调用了两次,这些都是重复的工作和内存分配。更糟糕的是,TrimStart()的无参重载方法的签名如下:
namespace System { public class String { public string TrimStart(params char[] trimChars); } }
该方法签名意味着,每次对TrimStart()的调用都回分配一个空的数组以及返回一个string类型的结果。
最后,调用了一次Substring()方法,这个方法通常会导致在内存中分配新的字符串。
解决方法:
和前面的只需要小小的修改即可解决内存分配的问题不同。在这个例子中,我们需要从头看,查看问题然后采用不同的方法解决。比如,可以意识到WriteFormattedDocComment()方法的参数是一个字符串,它包含了方法中需要的所有信息,因此,代码只需要做更多的index操作,而不是分配那么多小的string片段。
下面的方法并没有完全解,但是可以看到如何使用类似的技巧来解决本例中存在的问题。C#编译器使用如下的方式来消除所有的额外内存分配。
private int IndexOfFirstNonWhiteSpaceChar(string text, int start) { while (start < text.Length && char.IsWhiteSpace(text[start])) start++; return start; } private bool TrimmedStringStartsWith(string text, int start, string prefix) { start = IndexOfFirstNonWhiteSpaceChar(text, start); int len = text.Length - start; if (len < prefix.Length) return false; for (int i = 0; i < len; i++) { if (prefix[i] != text[start + i]) return false; } return true; }
WriteFormattedDocComment() 方法的第一个版本分配了一个数组,几个子字符串,一个trim后的子字符串,以及一个空的params数组。也检查了”///”。修改后的代码仅使用了index操作,没有任何额外的内存分配。它查找第一个非空格的字符串,然后逐个字符串比较来查看是否以”///”开头。和使用TrimStart()不同,修改后的代码使用IndexOfFirstNonWhiteSpaceChar方法来返回第一个非空格的开始位置,通过使用这种方法,可以移除WriteFormattedDocComment()方法中的所有额外内存分配。
例4 StringBuilder
本例中使用StringBuilder。下面的函数用来产生泛型类型的全名:
public class Example { // Constructs a name like "SomeType<T1, T2, T3>" public string GenerateFullTypeName(string name, int arity) { StringBuilder sb = new StringBuilder(); sb.Append(name); if (arity != 0) { sb.Append("<"); for (int i = 1; i < arity; i++) { sb.Append("T"); sb.Append(i.ToString()); sb.Append(", "); } sb.Append("T"); sb.Append(i.ToString()); sb.Append(">"); } return sb.ToString(); } }
注意力集中到StringBuilder实例的创建上来。代码中调用sb.ToString()会导致一次内存分配。在StringBuilder中的内部实现也会导致内部内存分配,但是我们如果想要获取到string类型的结果化,这些分配无法避免。
解决方法:
要解决StringBuilder对象的分配就使用缓存。即使缓存一个可能被随时丢弃的单个实例对象也能够显著的提高程序性能。下面是该函数的新的实现。除了下面两行代码,其他代码均相同
// Constructs a name like "Foo<T1, T2, T3>" public string GenerateFullTypeName(string name, int arity) { StringBuilder sb = AcquireBuilder(); /* Use sb as before */ return GetStringAndReleaseBuilder(sb); }
关键部分在于新的 AcquireBuilder()和GetStringAndReleaseBuilder()方法:
[ThreadStatic] private static StringBuilder cachedStringBuilder; private static StringBuilder AcquireBuilder() { StringBuilder result = cachedStringBuilder; if (result == null) { return new StringBuilder(); } result.Clear(); cachedStringBuilder = null; return result; } private static string GetStringAndReleaseBuilder(StringBuilder sb) { string result = sb.ToString(); cachedStringBuilder = sb; return result; }
上面方法实现中使用了 thread-static字段来缓存StringBuilder对象,这是由于新的编译器使用了多线程的原因。很可能会忘掉这个ThreadStatic声明。Thread-static字符为每个执行这部分的代码的线程保留一个唯一的实例。
如果已经有了一个实例,那么AcquireBuilder()方法直接返回该缓存的实例,在清空后,将该字段或者缓存设置为null。否则AcquireBuilder()创建一个新的实例并返回,然后将字段和cache设置为null 。
StringBuilderの処理が終了したら、GetStringAndReleaseBuilder()メソッドを呼び出して文字列の結果を取得します。次に、StringBuilder をフィールドに保存するかキャッシュし、結果を返します。このコードが繰り返し実行されて、複数の StringBuilder オブジェクトが作成される可能性がありますが、そのようなケースはほとんどありません。最後にリリースされた StringBuilder オブジェクトのみが、後で使用できるようにコードに保存されます。新しいコンパイラでは、このシンプルなキャッシュ戦略により、不必要なメモリ割り当てが大幅に削減されます。 .NET Framework および MSBuild の一部のモジュールも同様の手法を使用してパフォーマンスを向上させます。
シンプルなキャッシュ戦略にはサイズ制限の上限があるため、適切なキャッシュ設計に従う必要があります。キャッシュを使用すると、以前よりも多くのコードが必要になり、より多くのメンテナンスが必要になる場合があります。これが問題であることがわかった後でのみ、キャッシュ戦略を採用する必要があります。 PerfView は、StringBuilder がメモリ割り当てに大きく貢献していることを示しました。