パフォーマンスをさらに向上させるには、クラスをシール済みとしてマークする必要があるという、多くの最適化のヒントを見つけました。
パフォーマンスの違いを確認するためにいくつかのテストを実行したところ、何も見つかりませんでした。私は何か間違ったことをしていますか?密封されたクラスがより良い結果をもたらすケースを見逃していますか?
誰かがテストを実行して違いを見ましたか?
学ぶのを手伝ってください:)
パフォーマンスをさらに向上させるには、クラスをシール済みとしてマークする必要があるという、多くの最適化のヒントを見つけました。
パフォーマンスの違いを確認するためにいくつかのテストを実行したところ、何も見つかりませんでした。私は何か間違ったことをしていますか?密封されたクラスがより良い結果をもたらすケースを見逃していますか?
誰かがテストを実行して違いを見ましたか?
学ぶのを手伝ってください:)
回答:
JITterは、シールされたクラス内のメソッドへの非仮想呼び出しを使用することがあります。これは、それらをさらに拡張する方法がないためです。
呼び出しタイプ、仮想/非仮想に関する複雑なルールがあり、それらすべてを知っているわけではないので、それらの概要を説明することはできませんが、シールされたクラスと仮想メソッドをググると、トピックに関するいくつかの記事が見つかるかもしれません。
このレベルの最適化から得られるパフォーマンスの利点はすべて、最後の手段と見なす必要があります。コードレベルで最適化する前に、常にアルゴリズムレベルで最適化してください。
:ここでは、この言及一つのリンクだ密封されたキーワードのランブリングは、
答えはノーです。密封されたクラスは、密封されていないクラスよりもパフォーマンスが良くありません。
問題はcall
vs callvirt
ILオペコードに帰着します。Call
はよりも高速でcallvirt
、callvirt
主にオブジェクトがサブクラス化されているかどうかが不明な場合に使用されます。したがって、人々は、クラスを封印すると、すべてのopコードがからcalvirts
に変わり、calls
高速になると想定します。
残念ながらcallvirt
、null参照のチェックなど、他にも便利な機能があります。これは、クラスがシールされている場合でも、参照がnullである可能性があるため、a callvirt
が必要であることを意味します。(クラスを封印する必要なく)これを回避することはできますが、少し無意味になります。
call
構造体は、サブクラス化できず、nullになることがないために使用されます。
詳細については、この質問を参照してください。
call
使用される状況は次のとおりです。状況new T().Method()
、struct
メソッドの場合、メソッドへの非仮想呼び出しvirtual
(などbase.Virtual()
)、またはstatic
メソッドの場合。他のどこでも使用しcallvirt
ます。
更新:.NET Core 2.0および.NET Desktop 4.7.1以降、CLRは非仮想化をサポートするようになりました。シールクラスのメソッドを取得し、仮想呼び出しを直接呼び出しに置き換えることができます。また、安全であることがわかっている場合は、非シールクラスに対してもこれを行うことができます。
そのような場合(CLRが仮想化しても安全であるとCLRが検出できなかった封印されたクラス)、封印されたクラスは、実際にはある種のパフォーマンス上の利点を提供するはずです。
とは言っても、コードをプロファイリングして、何百万回も呼び出されている特にホットなパスにいると判断しない限り、心配する価値はないと思います。
元の回答:
次のテストプログラムを作成し、Reflectorを使用して逆コンパイルして、どのMSILコードが生成されたかを確認しました。
public class NormalClass {
public void WriteIt(string x) {
Console.WriteLine("NormalClass");
Console.WriteLine(x);
}
}
public sealed class SealedClass {
public void WriteIt(string x) {
Console.WriteLine("SealedClass");
Console.WriteLine(x);
}
}
public static void CallNormal() {
var n = new NormalClass();
n.WriteIt("a string");
}
public static void CallSealed() {
var n = new SealedClass();
n.WriteIt("a string");
}
すべての場合において、C#コンパイラ(リリースビルド構成のVisual Studio 2010)は、次のような同一のMSILを発行します。
L_0000: newobj instance void <NormalClass or SealedClass>::.ctor()
L_0005: stloc.0
L_0006: ldloc.0
L_0007: ldstr "a string"
L_000c: callvirt instance void <NormalClass or SealedClass>::WriteIt(string)
L_0011: ret
封印されているとパフォーマンスが向上するという人々のよく言われる理由は、コンパイラがクラスがオーバーライドされていないことを知っているため、仮想などをチェックする必要がないため、call
代わりに使用できることですcallvirt
。上記で証明されているように、これは本当。
私の次の考えは、MSILは同じですが、おそらくJITコンパイラーがシールされたクラスを異なる方法で処理するということでした。
Visual Studioデバッガーの下でリリースビルドを実行し、逆コンパイルされたx86出力を表示しました。どちらの場合も、クラス名と関数メモリアドレス(もちろん異なる必要があります)を除いて、x86コードは同じです。ここにあります
// var n = new NormalClass();
00000000 push ebp
00000001 mov ebp,esp
00000003 sub esp,8
00000006 cmp dword ptr ds:[00585314h],0
0000000d je 00000014
0000000f call 70032C33
00000014 xor edx,edx
00000016 mov dword ptr [ebp-4],edx
00000019 mov ecx,588230h
0000001e call FFEEEBC0
00000023 mov dword ptr [ebp-8],eax
00000026 mov ecx,dword ptr [ebp-8]
00000029 call dword ptr ds:[00588260h]
0000002f mov eax,dword ptr [ebp-8]
00000032 mov dword ptr [ebp-4],eax
// n.WriteIt("a string");
00000035 mov edx,dword ptr ds:[033220DCh]
0000003b mov ecx,dword ptr [ebp-4]
0000003e cmp dword ptr [ecx],ecx
00000040 call dword ptr ds:[0058827Ch]
// }
00000046 nop
00000047 mov esp,ebp
00000049 pop ebp
0000004a ret
次に、おそらくデバッガの下で実行すると、あまり積極的でない最適化が実行されると思いましたか?
次に、スタンドアロンのリリースビルド実行可能ファイルをデバッグ環境の外で実行し、WinDBG + SOSを使用してプログラムの完了後に侵入し、JITコンパイルされたx86コードの問題を確認しました。
以下のコードからわかるように、デバッガーの外部で実行する場合、JITコンパイラーはより積極的であり、WriteIt
メソッドを呼び出し元に直接インライン化しました。ただし、重要なのは、封印されたクラスと封印されていないクラスを呼び出すときに同じだったことです。封印されたクラスと封印されていないクラスの間で違いはありません。
これは通常のクラスを呼び出すときです:
Normal JIT generated code
Begin 003c00b0, size 39
003c00b0 55 push ebp
003c00b1 8bec mov ebp,esp
003c00b3 b994391800 mov ecx,183994h (MT: ScratchConsoleApplicationFX4.NormalClass)
003c00b8 e8631fdbff call 00172020 (JitHelp: CORINFO_HELP_NEWSFAST)
003c00bd e80e70106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c00c2 8bc8 mov ecx,eax
003c00c4 8b1530203003 mov edx,dword ptr ds:[3302030h] ("NormalClass")
003c00ca 8b01 mov eax,dword ptr [ecx]
003c00cc 8b403c mov eax,dword ptr [eax+3Ch]
003c00cf ff5010 call dword ptr [eax+10h]
003c00d2 e8f96f106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c00d7 8bc8 mov ecx,eax
003c00d9 8b1534203003 mov edx,dword ptr ds:[3302034h] ("a string")
003c00df 8b01 mov eax,dword ptr [ecx]
003c00e1 8b403c mov eax,dword ptr [eax+3Ch]
003c00e4 ff5010 call dword ptr [eax+10h]
003c00e7 5d pop ebp
003c00e8 c3 ret
対密封されたクラス:
Normal JIT generated code
Begin 003c0100, size 39
003c0100 55 push ebp
003c0101 8bec mov ebp,esp
003c0103 b90c3a1800 mov ecx,183A0Ch (MT: ScratchConsoleApplicationFX4.SealedClass)
003c0108 e8131fdbff call 00172020 (JitHelp: CORINFO_HELP_NEWSFAST)
003c010d e8be6f106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c0112 8bc8 mov ecx,eax
003c0114 8b1538203003 mov edx,dword ptr ds:[3302038h] ("SealedClass")
003c011a 8b01 mov eax,dword ptr [ecx]
003c011c 8b403c mov eax,dword ptr [eax+3Ch]
003c011f ff5010 call dword ptr [eax+10h]
003c0122 e8a96f106f call mscorlib_ni+0x2570d0 (6f4c70d0) (System.Console.get_Out(), mdToken: 060008fd)
003c0127 8bc8 mov ecx,eax
003c0129 8b1534203003 mov edx,dword ptr ds:[3302034h] ("a string")
003c012f 8b01 mov eax,dword ptr [ecx]
003c0131 8b403c mov eax,dword ptr [eax+3Ch]
003c0134 ff5010 call dword ptr [eax+10h]
003c0137 5d pop ebp
003c0138 c3 ret
私にとって、これは、シールされたクラスとシールされていないクラスでメソッドを呼び出す間にパフォーマンスの向上があり得ないという確かな証拠を提供します...私は今幸せだと思います:-)
callvirt
これらのメソッドが最初から仮想化されていないのになぜそこにあるのですか?
callvirt
、メソッド呼び出しを呼び出す前にオブジェクトをnullチェックする必要があるため、シールされたメソッドにCLRが使用されていることを読みました。使用しますcallvirt
。を削除してcallvirt
直接ジャンプするに((string)null).methodCall()
は、C ++のようにC#を変更できるようにするか、オブジェクトがnullではないことを静的に証明する必要があります(これは可能ですが、気にしていません)。
私が知っているように、パフォーマンス上の利点の保証はありません。ただし、特定の条件下でパフォーマンスのペナルティを減らす可能性があります、密封された方法を使用とでます。(sealedクラスは、すべてのメソッドをシールします。)
しかし、それはコンパイラの実装と実行環境次第です。
最近のCPUの多くは、長いパイプライン構造を使用してパフォーマンスを向上させています。CPUはメモリよりも非常に高速であるため、CPUはメモリからコードをプリフェッチしてパイプラインを高速化する必要があります。適切なタイミングでコードの準備ができていない場合、パイプラインはアイドル状態になります。
この「プリフェッチ」最適化を中断させる動的ディスパッチと呼ばれる大きな障害があります。これは単なる条件分岐として理解できます。
// Value of `v` is unknown,
// and can be resolved only at runtime.
// CPU cannot know which code to prefetch.
// Therefore, just prefetch any one of a() or b().
// This is *speculative execution*.
int v = random();
if (v==1) a();
else b();
この場合、CPUは次のコードをプリフェッチして実行できません。条件が解決されるまで、次のコードの位置は不明であるためです。これは危険ですがパイプラインのアイドル状態を引き起こします。そしてレギュラーではアイドルによるパフォーマンスペナルティが莫大です。
メソッドのオーバーライドの場合も同様です。コンパイラは、現在のメソッド呼び出しに対して適切なメソッドのオーバーライドを決定する場合がありますが、それが不可能な場合もあります。この場合、適切な方法は実行時にのみ決定できます。これは動的ディスパッチの場合でもあり、動的型付け言語の主な理由は、静的型付け言語よりも一般的に遅いです。
一部のCPU(最近のIntelのx86チップを含む)は、投機的実行と呼ばれる手法を使用していますを使用して、状況下でもパイプラインを利用します。実行パスの1つをプリフェッチするだけです。しかし、この手法のヒット率はそれほど高くありません。そして、投機の失敗はパイプラインの停止を引き起こし、これもまたパフォーマンスを大幅に低下させます。(これは完全にCPUの実装によるものです。一部のモバイルCPUは、エネルギーを節約するためにこの種の最適化を行わないことが知られています)
基本的に、C#は静的にコンパイルされた言語です。しかしいつもではない。正確な状態はわかりませんが、これは完全にコンパイラの実装次第です。一部のコンパイラは、メソッドがとしてマークされてsealed
いる場合にメソッドのオーバーライドを防止することにより、動的ディスパッチの可能性を排除できます。愚かなコンパイラはそうではないかもしれません。これはのパフォーマンス上の利点ですsealed
。
この答え(ソートされていない配列よりもソートされた配列を処理する方が速いのはなぜですか?)は、分岐予測をよりよく説明しています。
<off-topic-rant>
私 密封されたクラス嫌います。パフォーマンスの利点が驚異的であるとしても(疑わしいと思います)、継承による再利用を防ぐことでオブジェクト指向モデルを破壊します。たとえば、Threadクラスはシールされています。スレッドをできるだけ効率的にしたいと思うかもしれませんが、Threadをサブクラス化できることで大きなメリットが得られるシナリオも想像できます。クラスの作成者は、「パフォーマンス」の理由でクラスをシールする必要がある場合は、少なくともインターフェイスを提供してください。忘れた機能が必要なすべての場所で折り返し置換する必要はありません。
例:SafeThread Threadがシールされており、IThreadインターフェイスがないため、はThreadクラスをラップする必要がありました。SafeThreadは、スレッドで未処理の例外を自動的にトラップします。これは、Threadクラスから完全に失われたものです。[いいえ、未処理の例外イベントは、セカンダリスレッドの未処理の例外を取得しません ]。
</ off-topic-rant>
クラスをマークする sealed
をても、パフォーマンスに影響はありません。
オペコードの代わりにオペコードを発行csc
する必要がある場合があります。ただし、そのようなケースはまれです。callvirt
call
そしてJITが同じ非仮想関数呼び出しを放出することができるべきであると私には思えるのためにcallvirt
、それがためになることcall
、それは(まだ)クラスは、任意のサブクラスを持っていないことを知っている場合。メソッドの実装が1つしかない場合は、vtableからそのアドレスをロードしても意味がありません。1つの実装を直接呼び出すだけです。さらに言えば、JITは関数をインライン化することもできます。
それはJITの部分で少しギャンブルです。なぜなら、サブクラスが後でロードされると、JITはそのマシンコードを破棄してコードを再度コンパイルし、実際の仮想呼び出しを発行する必要があるためです。私の推測では、これは実際には頻繁には起こりません。
(そして、はい、VMデザイナーは実際にこれらの小さなパフォーマンスの勝利を積極的に追求しています。)
シールされたクラスは、パフォーマンスを向上させるはずです。封印されたクラスは派生できないため、仮想メンバーを非仮想メンバーに変えることができます。
もちろん、私達は本当に小さな利益について話している。プロファイリングで問題であることが明らかにならない限り、パフォーマンスを向上させるためだけにクラスをシール済みとしてマークしません。
call
代わりにエミットすることができるため、シールされたクラスが実際に優れていcallvirt
ます...他の多くの理由からもnull以外の参照型が好きです...ため息:-(
私は「密封された」クラスを通常の場合と考えており、「密封された」キーワードを省略する理由は常にあります。
私にとって最も重要な理由は次のとおりです。
a)より良いコンパイル時間チェック(実装されていないインターフェースへのキャストは、実行時だけでなく、コンパイル時にも検出されます)
そして、一番の理由:
b)私のクラスの悪用はそのようには不可能です
Microsoftが「封印された」標準ではなく「封印された」標準にしたかったのですが。
@Vaibhav、パフォーマンスを測定するためにどのようなテストを実行しましたか?
私は、Rotorを使用してCLIにドリルダウンし、密封されたクラスがパフォーマンスを向上させる方法を理解する必要があると思います。
SSCLI(ローター)
SSCLI:共有ソース共通言語インフラストラクチャ共通言語インフラストラクチャ(CLI)は、.NET Frameworkのコアを記述するECMA標準です。共有ソースCLI(SSCLI)(Rotorとも呼ばれます)は、Microsoftの.NETアーキテクチャーの中心にあるテクノロジーであるECMA CLIおよびECMA C#言語仕様の実用的な実装へのソースコードの圧縮アーカイブです。
密封されたクラスは少なくとも少し高速になりますが、場合によってはwaayyy高速になる可能性があります... JITオプティマイザが仮想呼び出しである呼び出しをインライン化できる場合。そのため、インライン化するのに十分なほど小さい小さなメソッドがよくある場合は、クラスをシーリングすることを検討してください。
ただし、クラスをシールする最も良い理由は、「これを継承するように設計していないので、そうであると想定してあなたをやけどさせたりはしません。私はあなたがそれから派生させたので、実装に閉じ込められて自分を燃やすために」
私は、何人かから派生する機会が欲しいので、彼らが封印されたクラスを嫌うと言った人がいることを知っています...それ。「プライベートメンバーがいるクラスは嫌いです...アクセスできないため、クラスに思い通りの結果を出せないことがよくあります。」カプセル化は重要です...シーリングはカプセル化の一形態です。
callvirt
、シールされたクラスのインスタンスメソッドに対して(仮想メソッド呼び出し)を引き続き使用します。これは、クラスに対してnullオブジェクトチェックを実行する必要があるためです。インライン化に関して、CLR JITは、シールされたクラスとシールされていないクラスの両方の仮想メソッド呼び出しをインライン化できます(実際にインライン化します)。パフォーマンスは神話です。
それらを実際に見るには、JITコンパイル済みコード(最後のもの)を分析する必要があります。
C#コード
public sealed class Sealed
{
public string Message { get; set; }
public void DoStuff() { }
}
public class Derived : Base
{
public sealed override void DoStuff() { }
}
public class Base
{
public string Message { get; set; }
public virtual void DoStuff() { }
}
static void Main()
{
Sealed sealedClass = new Sealed();
sealedClass.DoStuff();
Derived derivedClass = new Derived();
derivedClass.DoStuff();
Base BaseClass = new Base();
BaseClass.DoStuff();
}
MILコード
.method private hidebysig static void Main() cil managed
{
.entrypoint
// Code size 41 (0x29)
.maxstack 8
IL_0000: newobj instance void ConsoleApp1.Program/Sealed::.ctor()
IL_0005: callvirt instance void ConsoleApp1.Program/Sealed::DoStuff()
IL_000a: newobj instance void ConsoleApp1.Program/Derived::.ctor()
IL_000f: callvirt instance void ConsoleApp1.Program/Base::DoStuff()
IL_0014: newobj instance void ConsoleApp1.Program/Base::.ctor()
IL_0019: callvirt instance void ConsoleApp1.Program/Base::DoStuff()
IL_0028: ret
} // end of method Program::Main
JITコンパイル済みコード
--- C:\Users\Ivan Porta\source\repos\ConsoleApp1\Program.cs --------------------
{
0066084A in al,dx
0066084B push edi
0066084C push esi
0066084D push ebx
0066084E sub esp,4Ch
00660851 lea edi,[ebp-58h]
00660854 mov ecx,13h
00660859 xor eax,eax
0066085B rep stos dword ptr es:[edi]
0066085D cmp dword ptr ds:[5842F0h],0
00660864 je 0066086B
00660866 call 744CFAD0
0066086B xor edx,edx
0066086D mov dword ptr [ebp-3Ch],edx
00660870 xor edx,edx
00660872 mov dword ptr [ebp-48h],edx
00660875 xor edx,edx
00660877 mov dword ptr [ebp-44h],edx
0066087A xor edx,edx
0066087C mov dword ptr [ebp-40h],edx
0066087F nop
Sealed sealedClass = new Sealed();
00660880 mov ecx,584E1Ch
00660885 call 005730F4
0066088A mov dword ptr [ebp-4Ch],eax
0066088D mov ecx,dword ptr [ebp-4Ch]
00660890 call 00660468
00660895 mov eax,dword ptr [ebp-4Ch]
00660898 mov dword ptr [ebp-3Ch],eax
sealedClass.DoStuff();
0066089B mov ecx,dword ptr [ebp-3Ch]
0066089E cmp dword ptr [ecx],ecx
006608A0 call 00660460
006608A5 nop
Derived derivedClass = new Derived();
006608A6 mov ecx,584F3Ch
006608AB call 005730F4
006608B0 mov dword ptr [ebp-50h],eax
006608B3 mov ecx,dword ptr [ebp-50h]
006608B6 call 006604A8
006608BB mov eax,dword ptr [ebp-50h]
006608BE mov dword ptr [ebp-40h],eax
derivedClass.DoStuff();
006608C1 mov ecx,dword ptr [ebp-40h]
006608C4 mov eax,dword ptr [ecx]
006608C6 mov eax,dword ptr [eax+28h]
006608C9 call dword ptr [eax+10h]
006608CC nop
Base BaseClass = new Base();
006608CD mov ecx,584EC0h
006608D2 call 005730F4
006608D7 mov dword ptr [ebp-54h],eax
006608DA mov ecx,dword ptr [ebp-54h]
006608DD call 00660490
006608E2 mov eax,dword ptr [ebp-54h]
006608E5 mov dword ptr [ebp-44h],eax
BaseClass.DoStuff();
006608E8 mov ecx,dword ptr [ebp-44h]
006608EB mov eax,dword ptr [ecx]
006608ED mov eax,dword ptr [eax+28h]
006608F0 call dword ptr [eax+10h]
006608F3 nop
}
0066091A nop
0066091B lea esp,[ebp-0Ch]
0066091E pop ebx
0066091F pop esi
00660920 pop edi
00660921 pop ebp
00660922 ret
オブジェクトの作成は同じですが、シールされたクラスと派生/ベースクラスのメソッドを呼び出すために実行される命令は少し異なります。データをレジスタまたはRAM(mov命令)に移動した後、密封されたメソッドを呼び出し、dword ptr [ecx]、ecx(cmp命令)の比較を実行し、派生/基本クラスがメソッドを直接実行しながらメソッドを呼び出します。 。
Torbj¨ornGranlundが書いたレポートによると、AMDおよびIntel x86プロセッサの命令レイテンシとスループットは、Intel Pentium 4での次の命令の速度です。
リンク:https : //gmplib.org/~tege/x86-timing.pdf
つまり、理想的には、シールドされたメソッドを呼び出すのに必要な時間は2サイクルであり、派生または基本クラスのメソッドを呼び出すのに必要な時間は3サイクルです。
コンパイラーの最適化により、封印されたクラスと封印されていないクラスのパフォーマンスの差が非常に小さくなり、プロセッサーサークルについて話しているため、大部分のアプリケーションには関係ありません。
このコードを実行すると、シールされたクラスが2倍高速であることがわかります。
class Program
{
static void Main(string[] args)
{
Console.ReadLine();
var watch = new Stopwatch();
watch.Start();
for (int i = 0; i < 10000000; i++)
{
new SealedClass().GetName();
}
watch.Stop();
Console.WriteLine("Sealed class : {0}", watch.Elapsed.ToString());
watch.Start();
for (int i = 0; i < 10000000; i++)
{
new NonSealedClass().GetName();
}
watch.Stop();
Console.WriteLine("NonSealed class : {0}", watch.Elapsed.ToString());
Console.ReadKey();
}
}
sealed class SealedClass
{
public string GetName()
{
return "SealedClass";
}
}
class NonSealedClass
{
public string GetName()
{
return "NonSealedClass";
}
}
出力:封印されたクラス:00:00:00.1897568非封印されたクラス:00:00:00.3826678