シールされたクラスは本当にパフォーマンス上の利点を提供しますか?
-
08-06-2019 - |
質問
さらなるパフォーマンスの向上を得るには、クラスをシールとしてマークする必要があるという最適化のヒントをたくさん見つけました。
パフォーマンスの違いを確認するためにいくつかのテストを実行しましたが、何も見つかりませんでした。私は何か間違ったことをしているでしょうか?シールされたクラスの方が良い結果が得られるケースを私は見逃しているでしょうか?
誰かがテストを実行して違いを確認したことがありますか?
勉強を手伝ってください:)
解決
JITter は、シールされたクラスのメソッドへの非仮想呼び出しを使用することがあります。これは、メソッドをさらに拡張する方法がないためです。
呼び出しの種類、仮想/非仮想に関しては複雑なルールがあり、私はそれらをすべて知っているわけではないので、実際に概要を説明することはできませんが、シールド クラスと仮想メソッドについてグーグルで検索すると、このトピックに関する記事がいくつか見つかるかもしれません。
このレベルの最適化によって得られるあらゆる種類のパフォーマンス上の利点は最後の手段とみなされ、コード レベルで最適化する前に常にアルゴリズム レベルで最適化する必要があることに注意してください。
これについて言及しているリンクが 1 つあります。 封印されたキーワードをとりとめなく語る
他のヒント
答えは「いいえ」です。シールされたクラスのパフォーマンスは、シールされていないクラスよりも優れているわけではありません。
問題は結局のところ、 call
対 callvirt
IL オペコード。 Call
よりも速いです callvirt
, 、 そして callvirt
は主に、オブジェクトがサブクラス化されているかどうかが不明な場合に使用されます。したがって、クラスをシールすると、すべてのオペコードが変更されると人々は考えます。 calvirts
に calls
そしてより速くなります。
残念ながら callvirt
null 参照のチェックなど、その他の便利な機能も実行します。これは、クラスがシールされていても、参照が依然として null である可能性があることを意味します。 callvirt
が必要です。これを回避することはできますが (クラスをシールする必要はありません)、少し無意味になります。
構造体の使用 call
これらはサブクラス化できず、null になることもないためです。
詳細については、この質問を参照してください。
アップデート:.NET Core 2.0 および .NET Desktop 4.7.1 の時点で、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
sealed がパフォーマンス上の利点があると言われる理由としてよく引用されるのは、コンパイラーはクラスがオーバーライドされていないことを認識しているため、 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
私にとって、これは、 できない シールされたクラスと非シールされたクラスでメソッドを呼び出す間のパフォーマンスの向上...今は幸せだと思います:-)
私が知っているように、パフォーマンス上の利点は保証されません。しかし〜がある 特定の条件下でパフォーマンスのペナルティを軽減するチャンス 密閉方式で。(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
.
この答え (ソートされていない配列よりもソートされた配列の処理の方が速いのはなぜですか?) は分岐予測をより適切に説明しています。
クラスをマークする sealed
パフォーマンスに影響はないはずです。
場合があります。 csc
を発行する必要があるかもしれません callvirt
オペコードの代わりに call
オペコード。ただし、そのようなケースは稀なようです。
そして、JIT は同じ非仮想関数呼び出しを発行できるはずだと私には思われます。 callvirt
それは call
, 、クラスに(まだ)サブクラスがないことがわかっている場合。メソッドの実装が 1 つだけ存在する場合、vtable からそのアドレスをロードしても意味がありません。その 1 つの実装を直接呼び出すだけです。さらに言えば、JIT は関数をインライン化することもできます。
JIT 側にとっては、ちょっとした賭けです。 は 後でロードされると、JIT はそのマシン コードを破棄してコードを再度コンパイルし、実際の仮想呼び出しを発行する必要があります。実際にはこのようなことはあまり起こらないと思います。
(そして、はい、VM 設計者は実際に、こうした小さなパフォーマンスの向上を積極的に追求しています。)
シールドされたクラス すべき パフォーマンスの向上を実現します。シールされたクラスは派生できないため、仮想メンバーは非仮想メンバーに変わる可能性があります。
もちろん、私たちは本当に小さな利益について話しています。プロファイリングで問題があることが判明しない限り、パフォーマンスを向上させるためだけにクラスをシール済みとしてマークすることはありません。
<主題から外れた暴言>
私 嫌悪する 密閉されたクラス。たとえパフォーマンス上の利点が驚くべきものであっても (それは私には疑わしいですが)、 破壊する 継承による再利用を防止することでオブジェクト指向モデルを実現します。たとえば、Thread クラスはシールされています。スレッドを可能な限り効率的にしたいと考えるのはわかりますが、Thread をサブクラス化できることが大きなメリットとなるシナリオも想像できます。クラスの作成者 (場合) しなければならない 「パフォーマンス」上の理由からクラスを封印し、 インターフェースを提供してください 少なくとも、忘れていた機能が必要な箇所をラップして置き換える必要がなくなります。
例: セーフスレッド Thread はシールされており IThread インターフェイスがないため、Thread クラスをラップする必要がありました。SafeThread は、Thread クラスに完全に欠けている、スレッド上の未処理の例外を自動的にトラップします。[いいえ、未処理の例外イベントは発生します。 ない セカンダリ スレッドで未処理の例外を取得する]。
</トピック外の暴言>
私は「sealed」クラスが通常のケースであると考えており、常に「sealed」キーワードを省略する理由があります。
私にとって最も重要な理由は次のとおりです。
a) コンパイル時チェックの改善 (実装されていないインターフェイスへのキャストは、実行時だけでなくコンパイル時にも検出されます)
そして、主な理由は次のとおりです。
b) その方法では私の授業の悪用は不可能です
Microsoft は「アンシールド」ではなく「シールド」を標準にしてほしかったと思います。
@Vaibhav、パフォーマンスを測定するためにどのような種類のテストを実行しましたか?
使用する必要があると思います ローター また、CLI を詳しく調べて、シールされたクラスがどのようにパフォーマンスを向上させるかを理解します。
SSCLI(ローター)
SSCLI:共有ソースの共通言語インフラストラクチャ共通言語インフラストラクチャ(CLI)は、.NETフレームワークのコアを説明するECMA標準です。Rotorとしても知られる共有ソースCLI(SSCLI)は、ECMA CLIおよびECMA C#言語仕様の実装、Microsoftの.NETアーキテクチャの中心にあるテクノロジーの実用的な実装に対するソースコードの圧縮アーカイブです。
sealed クラスは少なくともほんの少し速くなりますが、場合によっては非常に速くなることもあります...JIT オプティマイザーが、仮想呼び出しになるはずの呼び出しをインライン化できる場合。したがって、インライン化できるほど小さいメソッドが頻繁に呼び出される場合は、必ずクラスをシールすることを検討してください。
ただし、クラスを封印する最善の理由は、「私はこれを継承するように設計したわけではないので、そのように設計されていると仮定して火傷を負わせるつもりはありません。私はそのつもりはありません」と言うことです。実装から派生させたために実装に閉じ込められて火傷を負うのです。」
ここにいる何人かが、何かから派生する機会が欲しいので、シールドされたクラスが嫌いだと言っていることを私は知っています...しかし、それは多くの場合、最も保守しやすい選択ではありません...なぜなら、クラスを派生に公開すると、すべてを公開しないよりもはるかに多くのことが閉じ込められるからです。これは、「プライベート メンバーがいるクラスは嫌いです...」と言っているのと似ています。アクセス権がないため、クラスに希望どおりの動作をさせることができないことがよくあります。」 カプセル化は重要です...封止はカプセル化の 1 つの形式です。
このコードを実行すると、シールされたクラスが 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