CLRの最適化とジェネリック

この記事では、John Skeetが、最も単純な言語構成がプログラムの速度を低下させる方法と、それらを加速する方法について説明します。

アプリケーションのパフォーマンスに関連する作業と同様に、結果は条件によって異なる場合があり(特に、たとえば64ビットJIT動作が少し異なる場合があります)、ほとんどの場合、これは心配する必要はありません。 それにもかかわらず、比較的少数の開発者が、多数のマイクロ最適化で構成されるプロダクションコードを記述しています。 したがって、この投稿を不合理な最適化のためにコードを複雑にする呼び出しとして受け取らないでください。これはおそらくあなたのプログラムを高速化します。 本当に必要な場合にのみ使用してください。


制限new()


たとえば、 SteppedPattern型(著者はライブラリの例、 Noda Time 、約Transl。で最適化について説明します)があるとします。これはジェネリック型TBucketを持っています。 値をTBucketするTBucketに、 TBucketクラスの新しいオブジェクトを作成することが重要であることに注意してください。 考えは、情報のビットがBucketに積み重ねられて解析されるというものです。 そして、操作が完了すると、 ParseResultしてParseResultます。 したがって、すべての行解析操作にはTBucketインスタンスが必要TBucket 。 ジェネリック型の場合、どのように作成できますか?

これを行うには、パラメーターなしで型コンストラクターを呼び出します。 渡された型にそのようなコンストラクターがあるかどうかは考えたくないので、 new()制約を追加してnew TBucket()を呼び出します。

 // Somewhat simplified... internal sealed class SteppedPattern<TResult, TBucket> : IParsePattern<TResult>    where TBucket : new() {    public ParseResult<TResult> Parse(string value)   {       TBucket bucket = new TBucket();        // Rest of parsing goes here   } } 


いいね! とても簡単です。 ただし、残念ながら、この1行のコードが行の解析にかかる時間の75%を占めるという事実を見失いました。 そして、これは空のBucket作成にすぎません-最も単純な行を解析する最も単純なクラスです! これを理解したとき、私はショックを受けました。

プロバイダーの使用を修正します

修正は非常に簡単です。 オブジェクトをインスタンス化する方法を型に伝える必要があります。 デリゲートの助けを借りてこれを行います。
 // Somewhat simplified... internal sealed class SteppedPattern<TResult, TBucket> : IParsePattern<TResult> {    private readonly Func<TBucket> bucketProvider;    internal SteppedPattern(Func<TBucket> bucketProvider)   {        this.bucketProvider = bucketProvider;   }    public ParseResult<TResult> Parse(string value)   {       TBucket bucket = bucketProvider();        // Rest of parsing goes here   } } 


これで、 new StoppedPattern(() => new OffsetBucket())またはそのようなものを呼び出すことができます。 また、コストラクタを内部として残し、二度と面倒を見ることができないことも意味します。 さらに、後続のコードの記述をさらに簡素化するために、古いバケットを使用して後続の行を解析することもできます。

タブレットが欲しい!

誰もが自分でテストを実行したいわけではありませんが、完成した結果を見たいと思っている人は多いようです。 したがって、ベンチマークの結果を提供することにしました。これは、ジェネリック型の作成時のみをチェックするために行いました。 これらの結果がどれほど重要でないかを示すために、表に記録された値がミリ秒単位で測定されることを示します。 この間に、1億回の操作が実行されました。これをテストします。 したがって、コードがジェネリック型を作成する操作を頻繁に呼び出すことに基づいていない限り、コードを書き換えることはありません。 ただし、将来のためにこれを覚えておいてください。

いずれにしても、私たちのコードは、2つのクラスと2つの構造の4つのタイプで動作するように設計されています。 そして、それらのそれぞれについて-CLR CLR v2 、v4の32ビットおよび64ビットマシン上で、小規模および大規模バージョン(GAKの小規模および大規模バージョン、つまり85Kより小さいおよび大きいことを意味する) 私の64ビットマシン自体は高速なので、同じマシン内で結果を比較する必要があります。

CLR v4:32ビットの結果(1億回の反復あたりのミリ秒)
試験タイプ新しい()制約プロバイダーデリゲート
小さな構造6891225
大きな構造111887273
少人数制163071690
大人数174713017


CLR v4:64ビットの結果(1億回の反復あたりのミリ秒)
試験タイプ新しい()制約プロバイダーデリゲート
小さな構造473868
大きな構造26702396
少人数制83661189
大人数88051529


CLR v2:32ビットの結果(1億回の反復あたりのミリ秒)
試験タイプ新しい()制約プロバイダーデリゲート
小さな構造7031246
大きな構造114117392
少人数制1439671791
大人数1431072581


CLR v2:64ビットの結果(1億回の反復あたりのミリ秒)
試験タイプ新しい()制約プロバイダーデリゲート
小さな構造510686
大きな構造23341731
少人数制818011539
大人数832931896


クラスの結果を見てください。 これらは実際の結果です。 new()制約を使用する場合はラップトップで約2分かかり、プロバイダーを使用する場合は数秒しかかかりません。 そして、これは非常に重要であり、これらの結果は.Net 2.0関連してい.Net 2.0 (つまり、 CLR意味し、バージョン2.0は.Net 2.0までは.Net 3.5まではすべてCLR v2で動作するという事実に読者を驚かせるように書かれてい.Net 2.0 )。

そしてもちろん、 ベンチマークをダウンロードして、マシン上でどのように機能するかを確認できます。

「フードの下」で何が起きているのでしょうか?

私の知る限り、 new()制約をサポートするIL命令はありません。 代わりに、コンパイラーはActivator.CreateInstance [T]呼び出し命令を挿入します。 明らかに、これはデリゲートを呼び出すよりも遅くなります。 この場合、リフレクションを介して適切なコンストラクターを見つけて呼び出します。 最適化されていないことに本当に驚きました。 結局のところ、明らかな解決策はデリゲートを使用し、将来の使用のためにそれらをキャッシュすることです。 結局、彼らのソリューションはキャッシュが占有する追加のメモリを消費しないため、彼らが行った問題について議論することはしません。

もっとベンチマークが欲しい!!


(記事の第2部から取得)

ここでは、デリゲートを使用した作業のパフォーマンスを確認します。 また、それらをスピードアップしてみてください。
私のサイトからパフォーマンステスト用の完全なソースコードをダウンロードできます。 実際、ここでは、テストを書くたびに同じことをしています。 何もしないActionデリゲートを作成し、それへのリンクが無効になっていないことを確認します。 これは、 JIT最適化を回避するためだけに行います。 各テストは、1つの汎用パラメーターを受け取る汎用メソッドとして実行されます。 各メソッドを2回呼び出します。最初はInt32を引数として渡し、2番目はStringを渡します。 また、彼はいくつかのケースを含めました。


未解決の定義もすべて明らかにします。
  private static void NoOp() {} private static void NoOp<T>() {} private class ClassHolder<T> { internal static SampleGenericClass<T> SampleInstance = new SampleGenericClass<T>(); } private class SampleGenericClass<T> { internal static void NoOpStatic() { } internal void NoOpInstance() { } } 


これはすべてジェネリックメソッドで行い、 Int32String各タイプに対して呼び出すことに注意してください。 そして、重要なことは、変数をキャプチャしないことです。また、ジェネリックパラメーターはメソッド本体の実装のどの部分にも関与しません。

試験結果

繰り返しますが、結果はミリ秒単位で1000万回の操作で表示されます。 非常に遅いので、1億回の操作でそれらを起動したくありません。 また、テストがx64 JITで実行されたことを明確にします

テストTestCase [int]テストケース[文字列]
ラムダ式18029684
汎用キャッシュクラス90288
ジェネリックメソッドグループの変換18430017
非ジェネリックメソッドグループの変換178189
ジェネリック型の静的メソッド18029276
ジェネリック型のインスタンスメソッド202299

はい、ジェネリックパラメーターとして参照型を使用するジェネリックメソッドへのデリゲートの作成は、ジェネリックパラメーターとしての値型の場合よりも150倍遅くなります。 そして、私はそれについて最初に知っているようです。 もちろん、 CLRチームのブログで答えを聞くのは非常に興味深いでしょう...

結論


テストがなかったら、この落とし穴を見つけることはできなかったでしょう。 この投稿から学べる教訓は、アプリケーションのパフォーマンスが目標であり、コードが多数の操作に依存してジェネリック型の新しいオブジェクトを選択する場合、 new()制約を使用しないことです。

正確な答えを知ることが難しくなる最も難しい質問の1つは、コンパイラがラムダ式をどうするかということです。 私たちのバージョンでは、コンパイラーはパフォーマンスにあまり関心がなく、自分で処理する必要があります。
画像

Source: https://habr.com/ru/post/J144193/


All Articles