高速なマネージド コードを書く: 何にコストがかかるのかを知る

 

Jan Gray
Microsoft CLR パフォーマンス チーム

2003 年 6 月

適用対象:
   Microsoft® .NET Framework

概要: この記事では、測定された操作時間に基づいて、マネージド コードの実行時間の低レベルのコスト モデルを示します。これにより、開発者はより適切な情報に基づいたコーディング決定を行い、より高速なコードを記述できます。 (30ページ印刷)

CLR プロファイラーをダウンロードします。 (330 KB)

内容

概要 (および誓約)
マネージド コードのコスト モデルに向けて
マネージド コードのコスト
まとめ
リソース

概要 (および誓約)

計算を実装する方法は無数にあり、他の方法よりもはるかに優れているものがあります。シンプルで、よりクリーンで、保守が容易です。 いくつかの方法は驚くほど速く、いくつかは驚くほど遅いです。

世界で遅くて太ったコードを実行しないでください。 あなたはそのようなコードを軽蔑しませんか? で実行され、開始されるコードは適合しますか? 一度に何秒間も UI をロックするコード? CPU をペグするか、ディスクをスラッシュするコードですか?

これは行わないでください。 代わりに、私と一緒に立ち上がって誓います:

"私は遅いコードを出荷しないことを約束します。 スピードは私が気にする機能です。 毎日、コードのパフォーマンスに注意を払います。 私は定期的かつメソッド的にその速度とサイズを 測定 します。 これを行うために必要なツールを学習、ビルド、または購入します。 それは私の責任です。

(実際には。)では、約束しましたか? よくできた。

では、最も速く、最もタイトなコードを日中どのように記述 しますか ? それは、贅沢な、肥大化した方法、何度も何度も、そして結果を通して考えることの問題に優先して、倹約的な方法を意識的に選択する問題です。 コードの特定のページは、このような小さな決定の数十をキャプチャします。

しかし、コストがわからない場合は、代替案の中でスマートな選択を行うことはできません。コストがわからない 場合は、効率的なコードを記述することはできません。

古き良き時代の方が簡単でした。 良いCプログラマは知っていました。 C の各演算子と演算は、代入、整数または浮動小数点演算、逆参照、または関数呼び出しのいずれかであり、多かれ少なかれ 1 対 1 のプリミティブ コンピューター操作にマップされます。 確かに、右側のレジスタに右オペランドを配置するために複数のマシン命令が必要な場合もあれば、1 つの命令で複数の C 演算 (有名な *dest++ = *src++;) をキャプチャすることもできますが、通常は C コードの行を記述 (または読み取り) し、時間がどこに向かっているかを把握できます。 コードとデータの両方について、C コンパイラは WYWIWYG でした。"何を記述するかは、取得したもの" です。 (例外は関数呼び出しであり、 は です。関数のコストがわからない場合は、正確にはわかりません)。

1990 年代に、データ抽象化、オブジェクト指向プログラミング、コード再利用の多くのソフトウェア エンジニアリングと生産性の利点を享受するために、PC ソフトウェア業界は C から C++ に移行しました。

C++ は C のスーパーセットであり、"従量課金制" であり、新機能を使用しないとコストはかかりません。そのため、内部化されたコスト モデルを含む C プログラミングの専門知識が直接適用されます。 動作する C コードを C++ 用に再コンパイルした場合、実行時間と領域のオーバーヘッドはあまり変わるべきではありません。

一方、C++ では、コンストラクター、デストラクター、新規、削除、単一、複数、および仮想継承、キャスト、メンバー関数、仮想関数、オーバーロードされた演算子、メンバーへのポインター、オブジェクト配列、例外処理、および同じのコンポジションなど、多くの新しい言語機能が導入されており、非自明な隠しコストが発生します。 たとえば、仮想関数は呼び出しごとに 2 つの余分な間接参照を要し、各インスタンスに非表示の vtable ポインター フィールドを追加します。 または、この無害なコードを考えてみましょう。

{ complex a, b, c, d; … a = b + c * d; }

は、約 13 個の暗黙的なメンバー関数呼び出し (できればインライン化) にコンパイルされます。

9 年前に、私の記事 C++: Under the Hood でこのテーマを調べていました。 私は書いた:

「プログラミング言語がどのように実装されているかを理解することが重要です。 このような知識は、「コンパイラがここで何をしているのか」という恐怖と不思議を払拭します。は、新しい機能を使用する自信を与えます。と は、他の言語機能をデバッグおよび学習する際の分析情報を提供します。 また、最も効率的なコードを日々記述するために必要なさまざまなコーディングの選択肢の相対的なコストを感じることができます。

次に、マネージド コードも同様に見てみましょう。 この記事では、マネージド実行の 低レベル の時間コストとスペース コストについて説明します。そのため、今日のコーディングで、よりスマートなトレードオフを行 うことができます

そして約束を守る。

マネージド コードの理由

ネイティブ コード開発者の大半にとって、マネージド コードは、ソフトウェアを実行するためのより優れた生産性の高いプラットフォームです。 ヒープの破損や配列インデックスの範囲外のエラーなど、バグのカテゴリ全体が削除されるため、深夜のデバッグ セッションにストレスを感じることがよくあります。 安全なモバイル コード (コード アクセス セキュリティ経由) や XML Web サービスなどの最新の要件がサポートされており、古くなった Win32/COM/ATL/MFC/VB と比較すると、.NET Frameworkはスレート設計クリーン更新され、より少ない労力でより多くの作業を行うことができます。

ユーザー コミュニティの場合、マネージド コードを使用すると、より豊富で堅牢なアプリケーションが可能になり、より優れたソフトウェアを使用した生活が向上します。

より高速なマネージド コードを記述するための秘密は何ですか?

少ない労力でより多くの作業を行うことができるからといって、賢明にコーディングする責任を取り除くライセンスではありません。 まず、「私は初心者です」と自分に認める必要があります。あなたは初心者です。 私も初心者です。 私たちは皆、マネージド コードランドの女の子です。 私たちは皆、コストを含め、ロープを学んでいます。

リッチで便利な.NET Frameworkに関しては、お菓子屋さんの子供のような感じです。 "うわー、私はこれらすべての面倒なことを strncpy する必要はありません、私はちょうど一緒に文字列を'+'することができます! うわー、私はコードの数行でXMLのメガバイトを読み込むことができます! うわーほー!

それはすべてとても簡単です。 本当に簡単です。 したがって、XMLインフォセットを解析するRAMのメガバイトを書き込むだけで、いくつかの要素を引き出すことができます。 C または C++ では、2 回考えるほど苦痛でした。SAX のような API でステート マシンを構築する可能性があります。 .NET Frameworkでは、情報セット全体を 1 つの gulp に読み込むだけです。 たぶん、あなたは何度も何度もそれを行います。 その後、アプリケーションがそれほど速く見えなくなったように見えるかもしれません。 おそらく、それは多くのメガバイトのワーキングセットを持っています。 おそらく、これらの簡単な方法のコストについて 2 回考える必要があります...

残念ながら、私の意見では、現在の.NET Frameworkドキュメントでは、フレームワークの型とメソッドのパフォーマンスへの影響について十分に詳しく説明していません。新しいオブジェクトを作成する可能性のあるメソッドも指定されていません。 パフォーマンス モデリングは、カバーまたはドキュメントの対象としては簡単ではありません。しかし、それでも「知らない」と、情報に基づいた意思決定を行うのがはるかに困難になります。

ここではすべての新人がいて、コストが何か分からないので、コストが明確に文書化されていないため、何をすればよいでしょうか。

測定します。 秘密は、 それを測定 し、 警戒することです。 私たちは皆、物事のコストを測定する習慣を身に付ける必要があります。 コストを測定する手間が掛かったとしても、コストを 想定 した 10 倍のコストがかかる、うっかり新しい方法を誤って呼び出すことはありません。

(ところで、BCL (基底クラス ライブラリ) または CLR 自体のパフォーマンスの基盤となるパフォーマンスに関する詳細な分析情報を得るには、 共有ソース CLI (ローターなど) を確認することを検討してください。 ローター コードは、.NET Frameworkと CLR と血線を共有します。 それは全体で同じコードではありませんが、それでも、ローターの思慮深い研究によって、CLRの内部で起こっていることに関する新しい洞察が得られると約束します。 ただし、最初に SSCLI ライセンスを確認してください。

知識

ロンドンでタクシー運転手を目指す場合は、まず ザ・ナレッジを獲得する必要があります。 学生はロンドンの何千もの小さな通りを記憶し、場所から場所への最良のルートを学ぶために何ヶ月も勉強します。 そして、彼らは周りをスカウトし、彼らの本の学習を強化するためにスクーターに毎日出かける。

同様に、高パフォーマンスのマネージド コード開発者になる場合 は、マネージド コードナレッジを取得する必要があります。 各低レベルの操作コストを学習する必要があります。 デリゲートやコード アクセス セキュリティ コストなどの機能について学習する必要があります。 使用している型とメソッドのコストと、作成しているコストを学習する必要があります。 また、どの方法がアプリケーションに対してコストがかかりすぎるかを検出しても問題はないので、避けてください。

知識は、悲しいかな、どの本にも含まれません。 スクーターに乗って探索する必要があります。つまり、csc、ildasm、VS.NET デバッガー、CLR Profiler、プロファイラー、いくつかのパフォーマンス タイマーなどをクランクアップし、コードの時間と空間のコストを確認する必要があります。

マネージド コードのコスト モデルに向けて

Preliminaries は別として、マネージド コードのコスト モデルを考えてみましょう。 これにより、リーフ メソッドを見て、よりコストの高い式とステートメントを一目で確認できます。新しいコードを記述すると、よりスマートな選択を行うことができます。

(これは、.NET Frameworkのメソッドまたはメソッドを呼び出す推移的なコストには対処しません。それは別の日に別の記事を待つ必要があります。)

以前は、ほとんどの C コスト モデルが C++ シナリオで引き続き適用されると述めていました。 同様に、C/C++ コスト モデルの多くはマネージド コードにも適用されます。

その方法は何ですか? CLR 実行モデルがわかっている。 コードは、複数の言語のいずれかで記述します。 アセンブリにパッケージ化された CIL (共通中間言語) 形式にコンパイルします。 メイン アプリケーション アセンブリを実行すると、CIL の実行が開始されます。 しかし、それは古いバイトコードインタープリターのように、桁違いに遅くないですか?

Just-In-Time コンパイラ

いいえそうじゃないです。 CLR では、JIT (Just-In-Time) コンパイラを使用して CIL の各メソッドをネイティブ x86 コードにコンパイルし、ネイティブ コードを実行します。 最初に呼び出される各メソッドの JIT コンパイルにはわずかな遅延がありますが、 と呼ばれるすべてのメソッドは、解釈オーバーヘッドなしで純粋なネイティブ コードを実行します。

従来のオフライン C++ コンパイル プロセスとは異なり、JIT コンパイラで費やされる時間は、各ユーザーの顔における "壁時計時間" の遅延であるため、JIT コンパイラには徹底的な最適化パスの贅沢はありません。 それでも、JIT コンパイラが実行する最適化の一覧は印象的です。

  • 定数の折りたたみ
  • 定数とコピーの伝達
  • 共通部分式の削除
  • ループインバリアントのコードモーション
  • デッド ストアとデッド コードの削除
  • 割り当てを登録する
  • メソッドのインライン化
  • ループアンロール (小さなボディを持つ小さなループ)

結果は、少なくとも同じ球場の従来のネイティブ コードに匹敵します。

データについては、値型と参照型の組み合わせを使用します。 整数型、浮動小数点型、列挙型、構造体などの値型は、通常、スタック上に存在します。 これらは、ローカルと構造体が C/C++ にあるのと同じくらい小さく、高速です。 C/C++ と同様に、コピーのオーバーヘッドが非常に高くなる可能性があるため、大きな構造体をメソッド引数または戻り値として渡さないようにする必要があります。

参照型とボックス化された値型はヒープ内に格納されます。 これらは、C/C++ のオブジェクト ポインターと同じように、単にマシン ポインターであるオブジェクト参照によってアドレス指定されます。

そのため、jitted マネージド コードは高速になる可能性があります。 以下で説明するいくつかの例外を除いて、ネイティブ C コードで何らかの式のコストを感じる場合は、マネージド コードで同等のコストとしてコストをはるかに間違ってモデル化することはありません。

また、"事前に" CIL をネイティブ コード アセンブリにコンパイルするツールである NGEN もメンションする必要があります。 現在、NGEN を使用してアセンブリを作成しても、実行時間に大きな影響 (良いまたは悪い) はありませんが、多くの AppDomains およびプロセスに読み込まれる共有アセンブリのワーキング セット全体を減らすことができます。 (OS は、すべてのクライアントで NGEN コードの 1 つのコピーを共有できます。一方、jitted コードは通常、AppDomains またはプロセス間で共有されていません。ただし、「」も LoaderOptimizationAttribute.MultiDomain参照してください)。

Automatic Memory Management

マネージド コードの最も重要な逸脱 (ネイティブからの) は、自動メモリ管理です。 新しいオブジェクトを割り当てますが、CLR ガベージ コレクター (GC) は到達不能になったときに自動的に解放します。 GC は何度も繰り返し実行され、多くの場合、通常はアプリケーションを 1 ミリ秒または 2 ミリ秒だけ停止します。場合によっては長くなります。

他のいくつかの記事では、ガベージ コレクターのパフォーマンスへの影響について説明しています。ここでは、それらを要約しません。 アプリケーションがこれらの他の記事の推奨事項に従う場合、ガベージ コレクションの全体的なコストは重要でなく、実行時間の数%、従来の C++ オブジェクト newdelete比べ、または より優れている可能性があります。 オブジェクトを作成して後で自動的に回収する償却コストは十分に低く、1 秒あたり何千万もの小さなオブジェクトを作成できます。

ただし、オブジェクトの割り当てはまだ 無料ではありません。 オブジェクトはスペースを占有します。 オブジェクトの割り当てが急増すると、ガベージ コレクション サイクルの頻度が高くなります。

さらに悪いことに、不必要に役に立たないオブジェクトグラフへの参照を保持すると、それらを生き続けます。 100 MB 以上のワーキング セットが嘆かわしい控えめなプログラムが見えることがあり、その作成者は記述可能性を否定し、代わりにパフォーマンスの低下をマネージド コード自体に関する神秘的で不明な (したがって難解な) 問題に起因します。 それは悲惨です。 しかし、CLR Profiler を使用して 1 時間の調査を行い、数行のコードに変更すると、ヒープの使用量が 10 倍以上削減されます。 大規模なワーキング セットの問題が発生している場合は、最初の手順として、ミラーを調べます。

そのため、オブジェクトを不必要に作成しないでください。 自動メモリ管理は、オブジェクトの割り当てと解放の多くの複雑さ、手間、バグを払拭するからといって、非常に高速で便利なので、木の上で成長するかのように、より多くのオブジェクトを作成する傾向があります。 本当に高速なマネージ コードを記述する場合は、オブジェクトを慎重かつ適切に作成します。

これは、API の設計にも適用されます。 型とそのメソッドを設計して、クライアントが野生の破棄を使用して新しいオブジェクトを作成する 必要 がある場合があります。 そうしないでください。

マネージド コードのコスト

次に、さまざまな低レベルのマネージド コード操作の時間コストを考えてみましょう。

表 1 は、一連の単純なタイミング ループを使用して収集された、Windows XP および .NET Framework v1.1 ("Everett") を実行する静止 1.1 GHz Pentium-III PC での、さまざまな低レベルマネージド コード操作の概算コストをナノ秒単位で示しています。

テスト ドライバーは各テスト メソッドを呼び出し、実行する反復回数を指定し、必要に応じて 218 から 230 回の反復を繰り返すように自動的にスケーリングし、各テストを 50 ミリ秒以上実行します。 一般に、これは、オブジェクトの割り当てが激しいテストでジェネレーション 0 ガベージ コレクションのいくつかのサイクルを観察するのに十分な長さです。 次の表は、10 回を超える試行の平均結果と、各テスト対象の最適な (最小時間) 試用版を示しています。

テスト ループのオーバーヘッドを減らすには、必要に応じて各テスト ループが 4 回から 64 回アンロールされます。 テストごとに生成されたネイティブ コードを調べて、JIT コンパイラによってテストが最適化されていないことを確認しました。たとえば、テスト ループ中とテストループ後に中間結果を維持するようにテストを変更しました。 同様に、いくつかのテストで一般的な部分式の除去を排除するように変更しました。

表 1 プリミティブ時間 (平均と最小) (ns)

Avg Min プリミティブ Avg Min プリミティブ Avg Min プリミティブ
0.0 0.0 コントロール 2.6 2.6 新しい valtype L1 0.8 0.8 isinst up 1
1.0 1.0 Int add 4.6 4.6 新しい valtype L2 0.8 0.8 isinst down 0
1.0 1.0 Int sub 6.4 6.4 新しい valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 新しい valtype L4 10.7 10.6 isinst (上 2) ダウン 1
35.9 35.7 Int div 23.0 22.9 新しい valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Int shift 22.0 20.3 新しい reftype L1 6.1 6.1 isinst down 3
2.1 2.1 long add 26.1 23.9 新しい reftype L2 1.0 1.0 get フィールド
2.1 2.1 long sub 30.2 27.5 新しい reftype L3 1.2 1.2 get prop
34.2 34.1 long mul 34.1 30.8 新しい reftype L4 1.2 1.2 set フィールド
50.1 50.0 long div 39.1 34.4 新しい reftype L5 1.2 1.2 set prop
5.1 5.1 長いシフト 22.3 20.3 new reftype empty ctor L1 0.9 0.9 このフィールドを取得する
1.3 1.3 float add 26.5 23.9 new reftype empty ctor L2 0.9 0.9 この小道具を手に入れる
1.4 1.4 float sub 38.1 34.7 new reftype empty ctor L3 1.2 1.2 このフィールドを設定する
2.0 2.0 float mul 34.7 30.7 new reftype empty ctor L4 1.2 1.2 このプロップを設定する
27.7 27.6 float div 38.5 34.3 new reftype empty ctor L5 6.4 6.3 仮想プロップを取得する
1.5 1.5 double add 22.9 20.7 new reftype ctor L1 6.4 6.3 仮想プロップを設定する
1.5 1.5 double sub 27.8 25.4 new reftype ctor L2 6.4 6.4 書き込みバリア
2.1 2.0 double mul 32.7 29.9 新しい reftype ctor L3 1.9 1.9 load int array elem
27.7 27.6 double div 37.7 34.1 new reftype ctor L4 1.9 1.9 store int array elem
0.2 0.2 インライン静的呼び出し 43.2 39.1 new reftype ctor L5 2.5 2.5 obj 配列の elem を読み込む
6.1 6.1 静的呼び出し 28.6 26.7 new reftype ctor no-inl L1 16.0 16.0 store obj array elem
1.1 1.0 インライン インスタンス呼び出し 38.9 36.5 new reftype ctor no-inl L2 29.0 21.6 box int
6.8 6.8 インスタンス呼び出し 50.6 47.7 new reftype ctor no-inl L3 3.0 3.0 unbox int
0.2 0.2 インライン化されたこの最初の呼び出し 61.8 58.2 new reftype ctor no-inl L4 41.1 40.9 デリゲート呼び出し
6.2 6.2 このインスタンス呼び出し 72.6 68.5 new reftype ctor no-inl L5 2.7 2.7 sum 配列 1000
5.4 5.4 仮想呼び出し 0.4 0.4 キャストアップ 1 2.8 2.8 sum 配列 10000
5.4 5.4 この仮想呼び出し 0.3 0.3 キャストダウン 0 2.9 2.8 sum 配列 100000
6.6 6.5 インターフェイス呼び出し 8.9 8.8 キャストダウン 1 5.6 5.6 sum 配列 1000000
1.1 1.0 inst itf インスタンス呼び出し 9.8 9.7 キャスト (上 2) ダウン 1 3.5 3.5 sum list 1000
0.2 0.2 この itf インスタンス呼び出し 8.9 8.8 キャストダウン 2 6.1 6.1 sum list 10000
5.4 5.4 inst itf 仮想呼び出し 8.7 8.6 キャストダウン 3 22.0 22.0 sum list 100000
5.4 5.4 この itf 仮想呼び出し       21.5 21.4 sum list 1000000

免責事項: このデータを文字通り取りすぎないでください。 時間テストは、予期しない 2 次効果の危険を伴います。 偶然にも、キャッシュ行にまたがる、他の何かに干渉する、または自分が持っているものが含まれるように、jitted コードまたはいくつかの重要なデータが配置される可能性があります。 これは、不確定性の原則に少し似ています:1ナノ秒ほどの時間と時間の違いは、観測可能な限界にあります。

もう 1 つの免責事項: このデータは、完全にキャッシュに収まる小さなコードとデータのシナリオにのみ関連します。 アプリケーションの "ホット" 部分がオンチップ キャッシュに収まらない場合は、パフォーマンスの問題のセットが異なる可能性があります。 紙の終わりに近いキャッシュについては、もっと多くのことを言う必要があります。

さらにもう 1 つの免責事項: コンポーネントとアプリケーションを CIL のアセンブリとして出荷する優れた利点の 1 つは、プログラムが自動的に 1 秒おきに速くなり、毎年より速くなるということです。ランタイムは (理論的には) JIT コンパイルされたコードをプログラムの実行時に再調整できるため、"1 秒おきに高速" になります。ランタイムの新しいリリースのたびに、より優れた、よりスマートで高速なアルゴリズムがコードの最適化に新たな安定を取ることができるので、"年が早く"。 したがって、これらのタイミングの一部が .NET 1.1 で最適でないと思われる場合は、製品の後続のリリースで改善する必要があることを心に留めておく必要があります。 この記事で報告された特定のコード ネイティブ コード シーケンスは、.NET Frameworkの今後のリリースで変更される可能性があります。

免責事項はさておき、データはさまざまなプリミティブの現在のパフォーマンスに対して合理的な腸の感覚を提供します。 数字は理にかなっており、コンパイルされたネイティブ コードと同様に、ほとんどの jitted マネージド コードが "マシンの近く" で実行されるというアサーションを実証します。 プリミティブ整数と浮動小数点演算は高速で、さまざまな種類のメソッド呼び出しは少なくなりますが、(信頼してください)ネイティブのC / C++に匹敵します。しかし、ネイティブコード(キャスト、配列、フィールドストア、関数ポインター(デリゲート))では通常安価ないくつかの操作がより高価になっていることがわかります。 なぜでしょうか。 見てみましょう。

算術演算

表 2 算術演算時間 (ns)

Avg Min プリミティブ Avg Min プリミティブ
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 float sub
2.7 2.7 int mul 2.0 2.0 float mul
35.9 35.7 int div 27.7 27.6 float div
2.1 2.1 int shift      
2.1 2.1 long add 1.5 1.5 double add
2.1 2.1 long sub 1.5 1.5 double sub
34.2 34.1 long mul 2.1 2.0 double mul
50.1 50.0 long div 27.7 27.6 double div
5.1 5.1 長いシフト      

昔は、浮動小数点演算は整数演算よりも桁違いに遅かった可能性があります。 表 2 に示すように、最新のパイプライン浮動小数点ユニットでは、ほとんど、またはまったく違いがないようです。 平均的なノートブックPCが(キャッシュに収まる問題のために)今ギガflopクラスのマシンだと思うと驚くべきことです。

整数と浮動小数点の追加テストから、jitted コードの行を見てみましょう。

逆アセンブリ 1 Int add と float add

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

ここでは、jitted コードが最適に近いことがわかります。 この場合、 int add コンパイラは 5 つのローカル変数を登録しました。 浮動小数点加算の場合、私はクラス静的を通じてh変数aを作成し、一般的な部分式の排除を打ち破る義務を負いました。

メソッド呼び出し

このセクションでは、メソッド呼び出しのコストと実装について説明します。 テスト対象は、さまざまな種類のメソッドを持つインターフェイスIを実装するクラスTです。 リスト 1 を参照してください。

リスト 1 メソッド呼び出しテスト メソッド

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

表 3 を検討してください。 最初の近似では、メソッドはインライン化されているか (抽象化コストは何もかからず)、そうでないように 見えます (抽象化コストは >5X 整数演算です)。 静的呼び出し、インスタンス呼び出し、仮想呼び出し、またはインターフェイス呼び出しの生コストに大きな違いはないように見えます。

表 3 メソッドの呼び出し時間 (ns)

Avg Min プリミティブ Callee Avg Min プリミティブ Callee
0.2 0.2 インライン静的呼び出し inl_s1 5.4 5.4 仮想呼び出し v1
6.1 6.1 静的呼び出し s1 5.4 5.4 この仮想呼び出し v1
1.1 1.0 インライン インスタンス呼び出し inl_i1 6.6 6.5 インターフェイス呼び出し itf1
6.8 6.8 インスタンス呼び出し i1 1.1 1.0 inst itf インスタンス呼び出し itf1
0.2 0.2 この最初の呼び出しをインライン化しました inl_i1 0.2 0.2 この itf インスタンス呼び出し itf1
6.2 6.2 このインスタンス呼び出し i1 5.4 5.4 inst itf 仮想呼び出し itf5
        5.4 5.4 この itf 仮想呼び出し itf5

しかし、これらの結果は、数百万回のタイトなタイミングループを実行する効果、表現できない 最良のケースです。 これらのテスト ケースでは、仮想メソッドとインターフェイス メソッドの呼び出しサイトはモノモーフィックです (たとえば、呼び出しサイトごと、ターゲット メソッドは時間の経過と伴って変化しません)。そのため、仮想メソッドとインターフェイス メソッドディスパッチ メカニズム (メソッド テーブルとインターフェイス マップ ポインターとエントリ) をキャッシュし、ブランチ予測を見事に提供することで、プロセッサは、これらによって非現実的に効果的なジョブ呼び出しを実行できます。予測が困難です。 データ依存ブランチ。 実際には、ディスパッチ メカニズム データのデータ キャッシュ ミス、またはブランチの誤った解釈 (強制容量ミスやポリモーフィックな呼び出しサイトなど) では、仮想呼び出しとインターフェイス呼び出しが数十サイクル遅くなる可能性があります。

これらの各メソッドの呼び出し時間を詳しく見てみましょう。

最初のケースでは、 インライン静的呼び出しでは、 一連の空の静的メソッドなどを呼び出します s1_inl() 。コンパイラはすべての呼び出しを完全にインライン化するため、空のループのタイミングが発生します。

静的メソッド呼び出しのおおよそのコストを測定するために、静的メソッドs1()などを非常に大きくして、呼び出し元にインライン化することはできません。

明示的な false 述語変数 を使用する必要があることを確認します falsePred。 私たちが書いた場合

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

JIT コンパイラでは、以前と同様に、メソッド本体全体 (現在は空) の dummy 呼び出しが行われなくなり、インライン化されます。 ところで、ここでは、呼び出し時間の 6.1 ns の一部を (false) 述語テストに属性付けし、呼び出された静的メソッド s1内でジャンプする必要があります。 (ところで、インライン化を無効にするより良い方法は 属性 CompilerServices.MethodImpl(MethodImplOptions.NoInlining) です)。

インライン インスタンス呼び出しと通常のインスタンス呼び出しのタイミングにも同じ方法が使用されました。 ただし、C# 言語仕様では null オブジェクト参照の呼び出しで NullReferenceException がスローされることが保証されるため、すべての呼び出しサイトでインスタンスが null にならないようにする必要があります。 これは、インスタンス参照を逆参照することによって行われます。null の場合は 、この例外に変換されるエラーが生成されます。

逆アセンブリ 2 では、静的変数 t をインスタンスとして使用します。これは、ローカル変数を使用したときに

    T t = new T();

コンパイラは、null インスタンスチェックループから抜け出しました。

逆アセンブリ 2 インスタンス メソッド呼び出しサイトと null インスタンス "チェック"

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

インライン化されたこのインスタンス呼び出しこのインスタンス呼び出しのケースは、インスタンスが thisである点を除いて同じです。ここでは、null チェックが除外されています。

逆アセンブリ 3 このインスタンス メソッド呼び出しサイト

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

仮想メソッド呼び出し は、従来の C++ 実装と同様に機能します。 新しく導入された各仮想メソッドのアドレスは、型のメソッド テーブルの新しいスロット内に格納されます。 各派生型のメソッド テーブルは、その基本型の に準拠し、拡張されます。また、仮想メソッドのオーバーライドによって、基本型の仮想メソッド アドレスが、派生型のメソッド テーブル内の対応するスロット内の派生型の仮想メソッド アドレスに置き換えられます。

呼び出しサイトでは、仮想メソッド呼び出しでは、インスタンス呼び出しと比較して 2 つの追加の読み込みが発生します。もう 1 つはメソッド テーブルのアドレス (常に にあります *(this+0)) をフェッチし、もう 1 つはメソッド テーブルから適切な仮想メソッド アドレスをフェッチして呼び出します。 「逆アセンブル 4」を参照してください。

逆アセンブリ 4 仮想メソッド呼び出しサイト

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

最後に、 インターフェイス メソッド呼び出し (逆アセンブル 5) に進みます。 これらは、C++ ではまったく同等の機能を持っていません。 任意の型で任意の数のインターフェイスを実装でき、各インターフェイスには独自のメソッド テーブルが論理的に必要です。 インターフェイス メソッドでディスパッチするには、メソッド テーブル、そのインターフェイス マップ、そのマップ内のインターフェイスのエントリを検索し、メソッド テーブルのインターフェイスのセクションで適切なエントリを介して間接を呼び出します。

逆アセンブリ 5 インターフェイス メソッドの呼び出しサイト

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

プリミティブ タイミングの残りの部分では、 itf インスタンス呼び出しこの itf インスタンス呼び出しitf 仮想呼び出しのインストこの itf 仮想呼び出し は、派生型のメソッドがインターフェイス メソッドを実装するたびに、インスタンス メソッド呼び出しサイトを介して呼び出し可能なままであるという考えを強調しています。

たとえば、 この itf インスタンス呼び出し (インターフェイスではない) 参照を介したインターフェイス メソッド実装の呼び出しをテストする場合、インターフェイス メソッドは正常にインライン化され、コストは 0 ns になります。 インターフェイス メソッドの実装でも、インスタンス メソッドとして呼び出すときにインライン化できる可能性があります。

まだ Jitted されていないメソッドの呼び出し

静的メソッド呼び出しとインスタンス メソッド呼び出し (仮想メソッドとインターフェイス メソッド呼び出しではなく) の場合、JIT コンパイラは現在、呼び出しサイトが jitted されるまでにターゲット メソッドが既に jitted されているかどうかに応じて、異なるメソッド呼び出しシーケンスを生成します。

呼び出し先 (ターゲット メソッド) がまだ jitted されていない場合、コンパイラは、最初に "prejit スタブ" で初期化されるポインターを介して間接呼び出しを出力します。 ターゲット メソッドに対する最初の呼び出しがスタブに到着します。これにより、メソッドの JIT コンパイルがトリガーされ、ネイティブ コードが生成され、新しいネイティブ コードに対処するためのポインターが更新されます。

呼び出し先が既に jitted されている場合、そのネイティブ コード アドレスが認識されるため、コンパイラは直接呼び出しを出力します。

新しいオブジェクトの作成

新しいオブジェクトの作成は、オブジェクトの割り当てとオブジェクトの初期化という 2 つのフェーズで構成されます。

参照型の場合、オブジェクトはガベージ コレクションヒープに割り当てられます。 値型の場合、スタック常駐型か、別の参照型または値型内に埋め込まれているかにかかわらず、値型オブジェクトは、外側の構造体からの定数オフセットにあります。割り当ては必要ありません。

一般的な小さい参照型オブジェクトの場合、ヒープの割り当ては非常に高速です。 ピン留めされたオブジェクトが存在する場合を除き、各ガベージ コレクションの後、ジェネレーション 0 ヒープのライブ オブジェクトは圧縮され、ジェネレーション 1 に昇格されるため、メモリ アロケーターには、操作できる大きな連続した空きメモリ アリーナが備わっています。 ほとんどのオブジェクト割り当てでは、ポインターの増分と境界チェックのみが発生します。これは、一般的な C/C++ フリー リスト アロケーター (malloc/operator new) よりも安価です。 ガベージ コレクターでは、マシンのキャッシュ サイズも考慮して、gen 0 オブジェクトをキャッシュ/メモリ階層の高速スイート スポットに保持しようとします。

優先されるマネージド コード スタイルは、ほとんどのオブジェクトを短い有効期間で割り当てて、それらを迅速に再利用することです。また、これらの新しいオブジェクトのガベージ コレクションの償却コスト (時間コスト) も含めます。

ガベージ コレクターは、死んだオブジェクトを追いかける時間を費やさないことに注意してください。 オブジェクトが死んでいる場合、GC はそれを見ない、それを歩かない、ナノ秒の思考を与えない。 GCは、生活の福祉にのみ関係しています。

(例外: 終了可能なデッド オブジェクトは特殊なケースです。GC はそれらを追跡し、特に死んだファイナライズ可能なオブジェクトを次世代の保留中のファイナライズに昇格します。これはコストが高く、最悪の場合、大きなデッド オブジェクト グラフを推移的に昇格させることができます。したがって、厳密に必要な場合を除き、オブジェクトをファイナライズ可能にしないでください。必要に応じて、可能な場合は を呼び出してGC.SuppressFinalizer、Dispose パターンの使用を検討してください)。メソッドで必要な場合をFinalize除き、finalizable オブジェクトから他のオブジェクトへの参照を保持しないでください。

もちろん、有効期間の短い大きなオブジェクトの償却 GC コストは、有効期間の短い小さいオブジェクトのコストよりも大きくなります。 各オブジェクトの割り当てにより、次のガベージ コレクション サイクルに非常に近いものになります。大きなオブジェクトは、その小さなものをはるかに早く行います。 遅かれ早かれ、謄本の瞬間が来ます。 GC サイクル (特に第 0 世代のコレクション) は非常に高速ですが、新しいオブジェクトの大部分が停止している場合でも無料ではありません。ライブ オブジェクトを見つける (マークする) には、まずスレッドを一時停止してからスタックやその他のデータ構造をウォークして、ルート オブジェクト参照をヒープに収集する必要があります。

(おそらく、より大きなオブジェクトが小さいオブジェクトと同じ量のキャッシュに収まるオブジェクトが少なくなります。キャッシュ ミス効果は、コード パスの長さの効果を簡単に支配する可能性があります)。

オブジェクトの領域が割り当てられると、そのオブジェクトの初期化 (構築) が維持されます。 CLR は、すべてのオブジェクト参照が null に事前初期化され、すべてのプリミティブ スカラー型が 0、0.0、false などに初期化されることを保証します。(したがって、ユーザー定義のコンストラクターで冗長に行う必要はありません。もちろん、自由に感じてください。ただし、JIT コンパイラでは現在、冗長ストアが最適化されるとは限らないことに注意してください)。

インスタンス フィールドをゼロにするだけでなく、CLR はオブジェクトの内部実装フィールド (メソッド テーブル ポインターとメソッド テーブル ポインターの前にあるオブジェクト ヘッダー ワード) を初期化します (参照型のみ)。 配列は Length フィールドも取得し、オブジェクト配列は Length フィールドと要素型フィールドを取得します。

その後、CLR はオブジェクトのコンストラクター (存在する場合) を呼び出します。 各型のコンストラクターは、ユーザー定義かコンパイラが生成したかに関係なく、最初に基本型のコンストラクターを呼び出し、次にユーザー定義の初期化 (存在する場合) を実行します。

理論的には、これは深い継承シナリオでは高価になる可能性があります。 E 拡張 D 拡張 C 拡張 B 拡張 A (拡張 System.Object) の場合、E を初期化すると常に 5 つのメソッド呼び出しが発生します。 実際には、コンパイラは空の基本型コンストラクターに対してインライン化 (無し) 呼び出しを行うため、それほど悪くはありません。

表 4 の最初の列を参照して、約 8 int-add-times の 4 つの int フィールドを持つ構造体 D を作成して初期化できることを確認します。 逆アセンブリ 6 は、A、C、E を作成する 3 つの異なるタイミング ループから生成されたコードです。 (各ループ内では、新しい各インスタンスを変更します。これにより、JIT コンパイラによってすべてが最適化されなくなります)。

表 4 値と参照型オブジェクトの作成時間 (ns)

Avg Min プリミティブ Avg Min プリミティブ Avg Min プリミティブ
2.6 2.6 新しい valtype L1 22.0 20.3 新しい reftype L1 22.9 20.7 new rt ctor L1
4.6 4.6 新しい valtype L2 26.1 23.9 新しい reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 新しい valtype L3 30.2 27.5 新しい reftype L3 32.7 29.9 new rt ctor L3
8.0 8.0 新しい valtype L4 34.1 30.8 新しい reftype L4 37.7 34.1 new rt ctor L4
23.0 22.9 新しい valtype L5 39.1 34.4 新しい reftype L5 43.2 39.1 new rt ctor L5
      22.3 20.3 new rt empty ctor L1 28.6 26.7 new rt no-inl L1
      26.5 23.9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
      38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
      34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
      38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

逆アセンブリ 6 値型オブジェクトの構築

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

次の 5 つのタイミング (新しい reftype L1、...新しい reftype L5) は、参照型 Aの 5 つの継承レベル (...、 Esans ユーザー定義コンストラクター) 用です。

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

参照型の時間と値の型の時間を比較すると、各インスタンスの償却された割り当てと解放コストは、テスト マシンで約 20 ns (20X int 加算時間) であることがわかります。 これは高速で、1 秒あたり約 5,000 万個の有効期間の短いオブジェクトの割り当て、初期化、回収が維持されます。 5 つのフィールドの小さいオブジェクトの場合、割り当てとコレクションは、オブジェクトの作成時間の半分のみを占めます。 「逆アセンブル 7」を参照してください。

逆アセンブリ 7 参照型オブジェクトの構築

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

5 つのタイミングの最後の 3 つのセットは、この継承されたクラス構築シナリオのバリエーションを示します。

  1. 新しい rt 空の ctor L1、...、新しい rt 空の ctor L5: 各型 A...には、 E 空のユーザー定義コンストラクターがあります。 これらはすべてインライン化され、生成されたコードは上記と同じです。

  2. 新しい rt ctor L1、...、新しい rt ctor L5: 各型 A...には、 E インスタンス変数を 1 に設定するユーザー定義コンストラクターがあります。

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

コンパイラは、入れ子になった基底クラス コンストラクター呼び出しの各セットをサイトに new インライン化します。 (逆アセンブル 8)。

逆アセンブリ 8 深くインライン化された継承されたコンストラクター

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. 新しい rt no-inl L1、...、new rt no-inl L5: 各型 A(...) には、 E インライン化にコストがかかりすぎるよう意図的に書き込まれたユーザー定義コンストラクターがあります。 このシナリオでは、深い継承階層と largish コンストラクターを使用して複雑なオブジェクトを作成するコストをシミュレートします。

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

表 4 の最後の 5 つのタイミングは、入れ子になった基本コンストラクターを呼び出す追加のオーバーヘッドを示しています。

Interlude: CLR Profiler デモ

次に、CLR Profiler の簡単なデモを行います。 CLR Profiler (以前は割り当てプロファイラー) は、CLR プロファイル API を使用して、アプリケーションの実行時にイベント データ (特に呼び出し、戻り、オブジェクトの割り当て、ガベージ コレクション イベント) を収集します。 (CLR プロファイラーは "侵入型" プロファイラーです。つまり、残念ながらプロファイリングされたアプリケーションの速度が大幅に低下します)。イベントが収集されたら、CLR Profiler を使用して、階層呼び出しグラフとメモリ割り当てパターンの相互作用など、アプリケーションのメモリ割り当てと GC 動作を調べます。

CLR Profiler は、多くの "パフォーマンスチャレンジ" マネージド コード アプリケーションの場合、データ割り当てプロファイルを理解することで、ワーキング セットを減らすために必要な重要な分析情報を提供し、高速で質の高いコンポーネントとアプリケーションを提供するため、学習に値します。

CLR プロファイラーは、どのメソッドが予想よりも多くのストレージを割り当てるかを明らかにしたり、GC によって再利用される可能性がある役に立たないオブジェクト グラフへの参照を誤って保持するケースを明らかにすることもできます。 (一般的な問題の設計パターンは、不要になった、または後で再調整しても安全な項目のソフトウェア キャッシュまたは参照テーブルです。キャッシュがオブジェクト グラフを有効期間を過ぎても生き続けると、悲惨です。代わりに、不要になったオブジェクトへの参照を必ず null にしてください)。

図 1 は、タイミング テスト ドライバーの実行中のヒープのタイムラインビューです。 鋸歯パターンは、オブジェクト C (マゼンタ)、(紫)、および E (青) D の何千ものインスタンスの割り当てを示します。 数ミリ秒ごとに、新しいオブジェクト (ジェネレーション 0) ヒープに約 150 KB の RAM が追加され、ガベージ コレクターが短時間実行されてリサイクルされ、ライブ オブジェクトが Gen 1 に昇格されます。 この侵襲的(遅い)プロファイリング環境の下でも、100ミリ秒(2.8秒から2.9秒)の間隔で、約8世代0 GCサイクルを受けるのは驚くべきことです。 次に、2.977 秒で、別 E のインスタンス用のスペースを作ると、ガベージ コレクターはジェネレーション 1 のガベージ コレクションを実行します。これにより、gen 1 ヒープが収集および圧縮されるため、より低い開始アドレスからソートゥースが続行されます。

図 1 CLR プロファイラーのタイム ライン ビュー

オブジェクトが大きいほど (E が D より大きい方が C より大きい)、gen 0 ヒープがいっぱいになり、GC サイクルの頻度が高くなります。

キャストとインスタンスの種類のチェック

安全で安全で 検証可能な マネージド コードの基盤は、タイプ セーフです。 オブジェクトをそうでない型にキャストできる場合は、CLR の整合性を損なって、信頼されていないコードのなすがままにするのも簡単です。

表 5 キャストと isinst 時間 (ns)

Avg Min プリミティブ Avg Min プリミティブ
0.4 0.4 キャストアップ 1 0.8 0.8 isinst up 1
0.3 0.3 キャストダウン 0 0.8 0.8 isinst down 0
8.9 8.8 キャストダウン 1 6.3 6.3 isinst down 1
9.8 9.7 キャスト (上 2) ダウン 1 10.7 10.6 isinst (上 2) ダウン 1
8.9 8.8 キャストダウン 2 6.4 6.4 isinst down 2
8.7 8.6 キャストダウン 3 6.1 6.1 isinst down 3

表 5 に、これらの必須の型チェックのオーバーヘッドを示します。 派生型から基本型へのキャストは常に安全であり、無料です。一方、基本型から派生型へのキャストは型チェックを行う必要があります。

(チェック済み) キャストは、オブジェクト参照をターゲット型に変換するか、 をスローします InvalidCastException

これに対しisinst、CIL 命令は C# as キーワード (keyword)を実装するために使用されます。

bac = ac as B;

が でないB場合、または からB派生した場合ac、結果は null例外ではありません。

リスト 2 はキャスト タイミング ループの 1 つを示し、逆アセンブリ 9 は 1 つのキャストダウンの生成されたコードを派生型に示しています。 キャストを実行するために、コンパイラはヘルパー ルーチンへの直接呼び出しを出力します。

リスト 2 キャストタイミングをテストするループ

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

逆アセンブル 9 ダウン キャスト

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

プロパティ

マネージド コードでは、プロパティは、オブジェクトのフィールドのように動作するメソッド、プロパティ ゲッター、およびプロパティ セッターのペアです。 get_ メソッドは、 プロパティをフェッチします。set_ メソッドは、 プロパティを新しい値に更新します。

それ以外のプロパティは、通常のインスタンス メソッドや仮想メソッドと同様に動作し、コストも発生します。 プロパティを使用してインスタンス フィールドをフェッチまたは格納するだけの場合は、通常、小さなメソッドと同様にインライン化されます。

表 6 は、整数インスタンス のフィールドとプロパティのセットをフェッチ (および追加) し、格納するために必要な時間を示しています。 プロパティを取得または設定するコストは、プロパティが仮想として宣言 されていない限り 、基になるフィールドへの直接アクセスと実際には同じです。その場合、コストは仮想メソッド呼び出しとほぼ同じです。 そこに驚きはありません。

表 6 フィールドとプロパティの時刻 (ns)

Avg Min プリミティブ
1.0 1.0 get フィールド
1.2 1.2 get prop
1.2 1.2 set フィールド
1.2 1.2 set prop
6.4 6.3 仮想プロップを取得する
6.4 6.3 仮想プロップを設定する

書き込みバリア

CLR ガベージ コレクターは、コレクションのオーバーヘッドを最小限に抑えるために、"世代的仮説" (ほとんどの新しいオブジェクトは若く死ぬ) を十分に活用しています。

ヒープは、世代に論理的にパーティション分割されます。 最新のオブジェクトは、ジェネレーション 0 (gen 0) に格納されます。 これらのオブジェクトは、コレクションにまだ残っていません。 gen 0 コレクション中に、GC は、マシン レジスタ内のオブジェクト参照、スタック上のクラス静的フィールド オブジェクト参照などを含む、GC ルート セットから到達可能な gen 0 オブジェクト (存在する場合) を決定します。推移的に到達可能なオブジェクトは "ライブ" であり、ジェネレーション 1 に昇格 (コピー) されます。

合計ヒープ サイズは数百 MB になる場合があるため、gen 0 ヒープ サイズは 256 KB のみである可能性があるため、GC のオブジェクト グラフ トレースの範囲を gen 0 ヒープに制限することは、CLR の非常に短いコレクションの一時停止時間を実現するために不可欠な最適化です。

ただし、gen 1 または gen 2 オブジェクトのオブジェクト参照フィールドに gen 0 オブジェクトへの参照を格納できます。 gen 0 コレクションでは gen 1 オブジェクトまたは gen 2 オブジェクトはスキャンされないため、それが特定の gen 0 オブジェクトへの唯一の参照である場合、そのオブジェクトは GC によって誤って再利用される可能性があります。 私たちはそれが起こらせることはできません!

代わりに、ヒープ内のすべてのオブジェクト参照フィールドに対するすべてのストアで 書き込みバリアが発生します。 これは、新しい世代のオブジェクト参照を古い世代のオブジェクトのフィールドに効率的に保存する簿記コードです。 このような古いオブジェクト参照フィールドは、後続の GC の GC ルート セットに追加されます。

オブジェクトごとの reference-field-store 書き込みバリアオーバーヘッドは、単純なメソッド呼び出しのコストに相当します (表 7)。 これは、ネイティブの C/C++ コードには存在しない新しい費用ですが、通常は、超高速オブジェクト割り当てと GC に対して支払うコストが小さく、自動メモリ管理の多くの生産性の利点があります。

表 7 書き込みバリア時間 (ns)

Avg Min プリミティブ
6.4 6.4 書き込みバリア

書き込みバリアは、狭い内部ループではコストがかかる場合があります。 しかし、数年後には、取られる書き込み障壁の数と総償却コストを減らす高度なコンパイル手法を楽しみにしています。

書き込みバリアは、参照型のオブジェクト参照フィールドへのストアでのみ必要であると考える場合があります。 ただし、値型メソッド内では、そのオブジェクト参照フィールド (存在する場合) も書き込みバリアによって保護されます。 これは、値型自体がヒープに存在する参照型内に埋め込まれる場合があるために必要です。

配列要素のアクセス

配列の範囲外のエラーとヒープの破損を診断して排除し、CLR 自体の整合性を保護するために、配列要素の読み込みと格納が境界チェックされ、インデックスが [0,array] 間隔内であることを確認します。Length-1] を含むか、 をスローします IndexOutOfRangeException

このテストでは、配列と配列の int[] 要素を読み込むか格納する時間を A[] 測定します。 (表 8)。

表 8 配列アクセス時間 (ns)

Avg Min プリミティブ
1.9 1.9 load int array elem
1.9 1.9 store int array elem
2.5 2.5 load obj array elem
16.0 16.0 store obj array elem

チェック境界では、配列インデックスと暗黙的な配列を比較する必要があります。長さフィールド。 逆アセンブル 10 が示すように、2 つの手順で、インデックスが 0 未満でも配列以上でもないチェック。長さ— である場合は、例外をスローする行外シーケンスに分岐します。 オブジェクト配列要素の読み込み、および int およびその他の単純な値型の配列への格納についても同様です。 (Load obj 配列の elem 時間は、内部ループのわずかな違いにより(重要ではありません)遅くなります。

逆アセンブリ 10 Load int 配列要素

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

コード品質の最適化を通じて、JIT コンパイラは多くの場合、冗長な境界チェックを排除します。

前のセクションを思い出すと、 オブジェクト配列要素ストア の方がかなりコストが高くなる可能性があります。 オブジェクト参照をオブジェクト参照の配列に格納するには、ランタイムで次の処理を行う必要があります。

  1. チェック配列インデックスは境界内にあります。
  2. チェック オブジェクトは配列要素型のインスタンスです。
  3. 書き込みバリアを実行します (配列からオブジェクトへの世代間オブジェクト参照に言及)。

このコード シーケンスはかなり長い。 コンパイラは、すべてのオブジェクト配列ストア サイトで出力するのではなく、逆アセンブル 11 に示すように、共有ヘルパー関数の呼び出しを出力します。 この呼び出しとこれら 3 つのアクションは、この場合に必要な追加の時間を考慮します。

逆アセンブリ 11 オブジェクト配列要素を格納する

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

ボックス化とボックス化解除

.NET コンパイラと CLR の間のパートナーシップにより、int (System.Int32) などのプリミティブ型を含む値型を参照型であるかのように参加させ、オブジェクト参照としてアドレス指定できます。 このアフォーダンス (この構文糖) を使用すると、値型をオブジェクトとしてメソッドに渡し、コレクションにオブジェクトとして格納することができます。

値型を "ボックス化" するには、その値型のコピーを保持する参照型オブジェクトを作成します。 これは概念的には、値型と同じ型の名前のないインスタンス フィールドを持つクラスを作成する場合と同じです。

ボックス化された値型を "ボックス化解除" するには、 オブジェクトから値型の新しいインスタンスに値をコピーします。

表 9 に示すように (表 4 と比較して)、int をボックス化し、後でガベージ コレクションするために必要な償却時間は、1 つの int フィールドで小さなクラスをインスタンス化するために必要な時間に相当します。

表 9 box と Unbox int Times (ns)

Avg Min プリミティブ
29.0 21.6 box int
3.0 3.0 unbox int

ボックス化された int オブジェクトのボックス化を解除するには、int への明示的なキャストが必要です。これにより、オブジェクトの型 (メソッド テーブル アドレスで表されます) とボックス化された int メソッド テーブル アドレスの比較がコンパイルされます。 等しい場合、値は オブジェクトからコピーされます。 それ以外の場合は、例外がスローされます。 「逆アセンブル 12」を参照してください。

分解 12 ボックスとボックスの取り外し int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

代理人

C では、関数へのポインターは、関数のアドレスをリテラルに格納するプリミティブ データ型です。

C++ は、メンバー関数へのポインターを追加します。 メンバー関数 (PMF) へのポインターは、遅延メンバー関数の呼び出しを表します。 非仮想メンバー関数のアドレスは単純なコード アドレスであってもかまいませんが、仮想メンバー関数のアドレスは、特定の仮想メンバー関数呼び出しを具体化する必要があります。このような PMF の逆参照は仮想関数呼び出し です

C++ PMF を逆参照するには、インスタンスを指定する必要があります。

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

何年も前に、Visual C++ コンパイラ開発チームでは、裸の式 pa->*pmf (sans 関数呼び出し演算子) の種類について自問しました。 メンバー 関数へのバインドされたポインター と呼ばれますが、 潜在的なメンバー関数の呼び出し は apt と同じように行われます。

マネージド コードに戻って、デリゲート オブジェクトは、潜在的なメソッド呼び出しです。 デリゲート オブジェクトは、呼び出すメソッドと呼び出すインスタンスの両方を表します。または静的メソッドへのデリゲートの場合は、呼び出す静的メソッドのみを表します。

(ドキュメントに記載されているように、デリゲート宣言は、特定のシグネチャを持つメソッドをカプセル化するために使用できる参照型を定義します。デリゲート インスタンスは、静的メソッドまたはインスタンス メソッドをカプセル化します。デリゲートは、C++ の関数ポインターとほぼ同じです。ただし、デリゲートは型セーフで安全です)。

C# のデリゲート型は、MulticastDelegate の派生型です。 この型は、デリゲートを呼び出すときに呼び出される (オブジェクト、メソッド) ペアの呼び出しリストを作成する機能など、豊富なセマンティクスを提供します。

デリゲートは、非同期メソッド呼び出し用の機能も提供します。 デリゲート型を定義し、潜在的なメソッド呼び出しで初期化されたデリゲート型をインスタンス化した後は、 を使用して BeginInvoke同期 (メソッド呼び出し構文) または非同期的に呼び出すことができます。 が呼び出された場合 BeginInvoke 、ランタイムは呼び出しをキューに入れ、呼び出し元に直ちに返します。 ターゲット メソッドは、後でスレッド プール スレッドで呼び出されます。

これらの豊富なセマンティクスはすべて安価ではありません。 表 10 と表 3 を比較すると、デリゲート呼び出しは ** メソッド呼び出しの約 8 倍遅くなることに注意してください。 時間の経過と同時に改善されることを期待してください。

表 10 デリゲート呼び出し時間 (ns)

Avg Min プリミティブ
41.1 40.9 デリゲート呼び出し

キャッシュ ミス、ページ フォールト、およびコンピューター アーキテクチャの数

「古き良き時代」の1983年頃、プロセッサは遅く(約500万命令/秒)、比較的言えばRAMは十分に高速でしたが、小さかった(256 KBのDRAMでは約300nsのアクセス時間)、ディスクは遅く、大きかった(10 MBディスクのアクセス時間は約25ミリ秒)。 PC マイクロプロセッサはスカラー CISC であり、ほとんどの浮動小数点はソフトウェアにあり、キャッシュはありませんでした。

2003年頃、2003年頃、プロセッサは 高速 です(3 GHzでサイクルあたり最大3回の操作を発行し、RAMは比較的非常に遅く(512 MBのDRAMでは約100nsのアクセス時間)、ディスクは 氷河的に 遅く 、巨大 です(100 GBディスクのアクセス時間は約10ミリ秒)。 PC マイクロプロセッサは現在、順不同のデータフロー スーパースカラー ハイパースレッディング トレース キャッシュ RISC (デコードされた CISC 命令を実行) であり、いくつかのキャッシュレイヤーがあります。たとえば、特定のサーバー指向マイクロプロセッサには、32 KB レベルの 1 データ キャッシュ (おそらく 2 サイクルの待機時間)、512 KB L2 データ キャッシュ、2 MB L3 データ キャッシュ (おそらく十数サイクルの待機時間) があります。 すべてチップ上に。

古き良き時代には、作成したコードのバイト数をカウントし、コードの実行に必要なサイクル数をカウントできます。また、場合によってはそうすることもできます。 ロードまたはストアには、追加と同じサイクル数がかかった。 最新のプロセッサでは、複数の関数ユニットにわたる分岐予測、予測、および順序外 (データフロー) の実行を使用して命令レベルの並列処理を見つけ、一度に複数のフロントで進行します。

これで、最速の PC で 1 マイクロ秒あたり最大 9,000 の操作を発行できるようになりましたが、その同じマイクロ秒では、DRAM ~10 個のキャッシュ行にのみ読み込みまたは格納できます。 コンピューター アーキテクチャの円では、これはメモリウォールに当たると呼ばれます。 キャッシュはメモリ待機時間を非表示にしますが、1 ポイントまでしか表示できません。 コードまたはデータがキャッシュに収まらない場合や、参照の局所性が低い場合、9000 操作/マイクロ秒超音速ジェットは 10 回の負荷/マイクロ秒の三サイクルに縮退します。

また 、プログラムの ワーキング セットが使用可能な物理 RAM を超え、プログラムがハード ページ 障害の発生を開始した後、10,000 マイクロ秒のページ フォールト サービス (ディスク アクセス) ごとに、ユーザーの回答に最大 9,000 万 の操作を近づける機会を逃します。 これは非常に恐ろしいので、この日から作業セット (vadump) を測定し、CLR Profiler などのツールを使用して、不要な割り当てや不注意なオブジェクト グラフの保持を排除します。

しかし、これらのすべてがマネージド コード プリミティブのコストを知っていることと何の関係があるのでしょうか。すべて*.*

表 1 を思い出すと、1.1 GHz P-III で測定されたマネージ コード プリミティブ時間のオムニバス リストは、5 レベルの明示的なコンストラクター呼び出しを持つ 5 つのフィールド オブジェクトの割り当て、初期化、回収の償却コストであっても、1 回の DRAM アクセスよりも 高速 であることを確認します。 すべてのレベルのオンチップ キャッシュを見逃す 1 つの負荷だけで、ほぼすべてのマネージド コード操作よりもサービスに時間がかかる場合があります。

そのため、コードの速度に情熱を持っている場合は、アルゴリズムとデータ構造を設計して実装する際に、キャッシュ/メモリ階層を検討して 測定 することが不可欠です。

簡単なデモの時間: int の配列を合計するか、同等のリンクされた ints リストを合計する方が速いですか? その理由はどれですか?

少し考えてみます。 ints などの小さな項目の場合、配列要素あたりのメモリ占有領域は、リンクリストの 4 分の 1 です。 (各リンク リスト ノードには、オブジェクトオーバーヘッドの 2 つの単語とフィールドの 2 つの単語 (次のリンクと int 項目) があります)。 これはキャッシュ使用率を損ないます。 配列アプローチのスコアを 1 つ取得します。

ただし、配列トラバーサルでは、項目ごとにチェック配列の境界が発生する可能性があります。 境界チェックには少し時間がかかることが分かっています。 おそらく、そのヒントは、リンクされたリストを優先してスケール?

逆アセンブリ 13 合計 int 配列と合計 int リンク リスト

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

逆アセンブリ13を参照して、私はリンクされたリストトラバーサルを支持してデッキを積み重ね、それを4回アンロールし、通常のnullポインタ end-of-listチェックを削除しました。 配列ループ内の各項目には 6 つの命令が必要ですが、リンクリスト ループ内の各項目には 11/4 = 2.75 命令のみが必要です。 これで、どちらが高速だと思いますか?

テスト条件: まず、100 万 int の配列と、100 万 int (1 M リスト ノード) の単純な従来のリンク リストを作成します。 その後、項目ごとに最初の 1,000、10,000、100,000、1,000,000 個の項目を加算するのにかかる時間です。 各ループを何度も繰り返して、各ケースの最もフラットなキャッシュ動作を測定します。

どちらが高速ですか? 推測した後、答えを参照してください: 表 1 の最後の 8 つのエントリ。

興味深いですね。 参照されるデータが連続するキャッシュ サイズよりも大きくなると、時間が大幅に遅くなります。 配列バージョンは、リンクされたリスト バージョンよりも常に高速です。ただし、2 倍の命令が実行されます。100,000 項目の場合、配列バージョンは 7 倍高速です。

これはなぜですか? 最初に、特定のレベルのキャッシュに収まるリンク リスト アイテムの数を減らします。 これらのオブジェクトヘッダーとリンクはすべてスペースを無駄にします。 2 つ目は、最新の順序外データフロー プロセッサを使用すると、前方に拡大し、配列内の複数の項目を同時に進行させる可能性があります。 これに対し、リンクされたリストでは、現在のリスト ノードがキャッシュに入るまで、プロセッサはその後、ノードへの次のリンクのフェッチを開始できません。

100,000 個の項目の場合、プロセッサは (平均して) 約 (22- 3.5)/22 = 84% の時間を消費し、一部のリスト ノードのキャッシュラインが DRAM から読み取られるのを待機しています。 それは悪いように聞こえるが、物事 ははるかに 悪いかもしれない。 リンクされたリスト アイテムは小さいため、その多くはキャッシュ行に収まります。 リストを割り当て順に走査し、ガベージ コレクターはヒープからデッド オブジェクトを圧縮しても割り当て順序を保持するため、キャッシュライン上の 1 つのノードをフェッチした後、次のいくつかのノードもキャッシュ内に存在する可能性があります。 ノードが大きい場合、またはリスト ノードがランダムなアドレス順にある場合は、アクセスされたすべてのノードが完全なキャッシュ ミスである可能性があります。 各リスト ノードに 16 バイトを追加すると、項目あたりのトラバーサル時間が 43 ns に倍になります。+32 バイト、67 ns/item;64 バイトを追加すると、テスト マシンでの DRAM の平均待機時間である 146 ns/item に再び 2 倍になります。

それでは、ここでの持ち帰りのレッスンは何ですか? 100,000 ノードのリンク リストを避けますか? [いいえ] 。 このレッスンでは、マネージド コードとネイティブ コードの低レベルの効率を考慮すると、キャッシュ効果が影響を受けかねないということです。 パフォーマンスが重要なマネージド コード (特に大規模なデータ構造を管理するコード) を記述する場合は、キャッシュ効果を念頭に置き、データ構造のアクセス パターンを考え、より小さなデータフットプリントと参照の良好な局所性を追求してください。

ところで、メモリウォールは、DRAMアクセス時間の比率をCPU操作時間で割ると、時間の経過と同時に悪化し続ける傾向にあります。

"キャッシュを意識した設計" の経験則を次に示します。

  • シナリオを試して測定します。これは、2 番目の順序の効果を予測するのが難しいため、また、経験則が印刷される用紙の価値がないためです。
  • 配列で例示される一部のデータ構造では、 暗黙的な隣接関係を 使用してデータ間の関係を表します。 リンク リストで例示されるその他は、 明示的なポインター (参照) を使用して関係を表します。 暗黙的な隣接関係は、一般に推奨されます。"暗黙的" は、ポインターと比較して領域を節約します。と 隣接関係は、参照の安定した局所性を提供し、プロセッサが次のポインターを追いかける前により多くの作業を開始できる場合があります。
  • 一部の使用パターンでは、ハイブリッド構造 (小さな配列の一覧、配列の配列、または B ツリー) が優先されます。
  • おそらく、ディスク アクセスに依存するスケジュール アルゴリズムは、ディスク アクセスのコストが 50,000 CPU 命令に過ぎないときに設計され、DRAM アクセスで何千もの CPU 操作が必要になったので、再利用する必要があります。
  • CLR マークとコンパクトガベージ コレクターはオブジェクトの相対的な順序を保持するため、 時間 (および同じスレッド上) に一緒に割り当てられたオブジェクトは空間内に一緒に残る傾向があります。 この現象を使用して、一般的なキャッシュ ラインに cliquish データを慎重に併置できる場合があります。
  • データを、頻繁に走査され、キャッシュに収まる必要があるホット パーツと、使用頻度の低い "キャッシュアウト" できるコールド パーツにパーティション分割したい場合があります。

Do-It-Yourself の時間実験

このペーパーのタイミング測定では、Win32 高解像度パフォーマンス カウンター QueryPerformanceCounter (および QueryPerformanceFrequency) を使用しました。

P/Invoke を使用して簡単に呼び出すことができます。

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

タイミング ループの直前と直後に を呼び出 QueryPerformanceCounter し、カウントを減算し、1.0e9 を乗算し、頻度で除算し、反復回数で除算します。これは、ns 単位の反復ごとのおおよその時間です。

スペースと時間の制限のため、ロック、例外処理、またはコード アクセス セキュリティ システムについては説明しませんでした。 読者にとっての演習と考えてください。

ところで、私は2003年 VS.NET の逆アセンブリウィンドウを使用して、この記事で分解を作成しました。 しかし、それにはトリックがあります。 VS.NET デバッガーでアプリケーションを実行する場合、リリース モードでビルドされた最適化された実行可能ファイルとしても、インライン化などの最適化が無効になっている "デバッグ モード" で実行されます。 JIT コンパイラが出力する最適化されたネイティブ コードを確認する唯一の方法は、デバッガー の外部 でテスト アプリケーションを起動し、Debug.Processes.Attach を使用してアタッチすることでした。

スペース コスト モデル?

皮肉なことに、スペースに関する考慮事項は、スペースの徹底的な議論を妨げています。 その後、いくつかの簡単な段落。

低レベルの考慮事項 (C# (既定の TypeAttributes.SequentialLayout) と x86 固有の考慮事項):

  • 値型のサイズは通常、そのフィールドの合計サイズであり、4 バイト以下のフィールドが自然な境界に揃えられます。
  • および [FieldOffset(n)] 属性を使用[StructLayout(LayoutKind.Explicit)]して共用体を実装できます。
  • 参照型のサイズは、8 バイトにフィールドの合計サイズを加算し、次の 4 バイト境界に切り上げ、4 バイト以下のフィールドが自然な境界に揃えられます。
  • C# では、列挙型宣言で任意の整数基本型 (char を除く) を指定できます。そのため、8 ビット、16 ビット、32 ビット、64 ビットの列挙型を定義できます。
  • C/C++ と同様に、整数フィールドのサイズを適切に設定することで、多くの場合、大きなオブジェクトから数十パーセントの領域を削り取ることができます。
  • CLR Profiler を使用して、割り当てられた参照型のサイズを調べることができます。
  • 大きなオブジェクト (数十 KB 以上) は、コストのかかるコピーを排除するために、個別のラージ オブジェクト ヒープで管理されます。
  • Finalizable オブジェクトは、再利用するために追加の GC 生成を使用します。これらを慎重に使用し、Dispose パターンの使用を検討してください。

全体像に関する考慮事項:

  • 現在、各 AppDomain にはかなりの領域オーバーヘッドが発生します。 多くのランタイムとフレームワークの構造は、AppDomain 間で共有されません。
  • プロセス内では、通常、jitted コードは AppDomain 間で共有されません。 ランタイムが特にホストされている場合は、この動作をオーバーライドできます。 のドキュメントと フラグをSTARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN 参照してくださいCorBindToRuntimeEx
  • いずれの場合も、jitted コードはプロセス間で共有されません。 多数のプロセスに読み込まれるコンポーネントがある場合は、ネイティブ コードを共有するために NGEN でプリコンパイルすることを検討してください。

リフレクション

「リフレクションのコストを尋ねる必要がある場合は、余裕がありません」と言われています。 ここまで読んだ場合は、コストを問い合わせ、それらのコストを測定することがいかに重要であるかを知っています。

リフレクションは便利で強力ですが、jitted ネイティブ コードと比較すると、高速でも小さくもありません。 警告が表示されました。 自分で測定します。

まとめ

これで、マネージド コードのコストが最も低いレベルで (多かれ少なかれ) わかります。 実装のトレードオフをスマートにし、より高速なマネージド コードを記述するために必要な基本的な理解を得ることができました。

Jitted マネージド コードは、ネイティブ コードとして "金属へのペダル" として使用できることを確認しました。 あなたの課題は、賢明にコーディングし、フレームワーク内の多くの豊富で使いやすい施設の中で賢明に選択することです

パフォーマンスが重要でない設定と、製品の最も重要な機能である設定があります。 早期最適化 は、 すべての悪の根源です。 しかし、効率への不注意な不注意です。 あなたはプロ、アーティスト、職人です。 だから、あなたは物事のコストを知っていることを確認してください。 知らない場合、またはそう思う場合でも、定期的に測定します。

CLR チームについては、ネイティブ コードよりも大幅に生産性が高く、 ネイティブ コードよりも 高速なプラットフォームの提供に引き続き取り組 みます。 物事が良くなるのを期待してください。 しばらくお待ちください。

約束を思い出してください。

リソース