密封类真的提供性能好处吗?

我遇到了很多优化提示,说你应该把你的课程标记为密封,以获得额外的性能好处。

我跑了一些testing来检查性能差异,发现没有。 我做错了什么? 我是否错过了密封课程会给更好的结果?

有没有人运行testing,看到了不同?

帮我学习:)

JITter有时会对密封类中的方法使用非虚拟调用,因为它们无法进一步扩展。

关于调用types有复杂的规则,虚拟/非虚拟,我不知道它们,所以我不能真正为你勾画它们,但如果你谷歌密封类和虚拟方法,你可能会find一些关于这个主题的文章。

请注意,从这个优化级别获得的任何types的性能优势都应视为最后的手段,在优化代码级别之前,始终要优化algorithm级别。

这里有一个提到这个的链接: 在密封的关键字上进行Rambling

答案是否定的,密封的课程performance不如非密封。

问题归结为callvirt IL操作码。 Callcallvirt更快, callvirt主要用于不知道对象是否已被子类化的情况。 所以人们认为,如果你盖上一个class,所有的操作代码将从calvirts变为calls并会更快。

不幸的是, callvirt做其他的事情,比如检查空引用。 这意味着,即使一个类被封闭,引用可能仍然是空的,因此需要一个callvirt 。 你可以解决这个问题(不需要封闭课程),但是它变得有点毫无意义。

结构使用call因为它们不能被子类化,也不能为空。

看到这个问题的更多信息:

呼叫和callvirt

据我所知,没有性能优势的保证。 但是在一些特定的情况下,用密封方法有机会降低性能的惩罚 。 (密封的课程使所有的方法被密封。)

但是这取决于编译器的实现和执行环境。


细节

许多现代的CPU使用长pipe道结构来提高性能。 由于CPU比内存快得多,CPU必须从内存中预取代码来加速pipe道。 如果代码在适当的时候没有准备好,pipe道将空闲。

dynamic调度有一个很大的障碍,它扰乱了这种“预取”的优化。 你可以理解这只是一个条件分支。

 // Value of `v` is unknown, // and can be resolved only at runtime. // CPU cannot know code to prefetch, // so just prefetch one of a() or b(). // This is *speculative execution*. int v = random(); if (v==1) a(); else b(); 

在这种情况下,CPU不能预取下一个要执行的代码,因为下一个代码位置是未知的,直到条件解决。 所以这会导致pipe道闲置的危险 。 而且闲置的performance惩罚是非常重要的。

类似的事情发生在方法覆盖的情况下。 编译器可能会确定正确的方法覆盖当前的方法调用,但有时是不可能的。 在这种情况下,只有在运行时才能确定正确的方法。 这也是dynamic调度的一个例子,dynamictypes语言的一个主要原因通常比静态types语言慢。

一些CPU(包括最近Intel的x86芯片)甚至在情况上也使用了被称为投机执行的技术来利用pipe道。 只要预取一个执行path。 但这种技术的命中率并不高。 投机失败导致pipe道失速,这也造成巨大的性能损失。 (这是完全由CPU实现的,有些移动CPU被称为不节能这种优化)

基本上,C#是一种静态编译的语言。 但并不总是。 我不知道确切的条件,这完全取决于编译器的实现。 如果方法被标记为sealed一些编译器可以通过防止方法重写来消除dynamic调度的可能性。 愚蠢的编译器可能不会。 这是sealed的性能好处。


这个答案( 为什么处理sorting的数组比没有sorting的数组更快? )更好地描述了分支预测。

更新:从.NET Core 2.0和.NET Desktop 4.7.1开始,CLR现在支持虚拟化。 它可以采用密封类方法,用直接调用替代虚拟调用 – 如果可以确定这样做是安全的,也可以对非密封类进行调用。

在这种情况下(一个CLR无法察觉的安全的密封类别),一个密封的类别实际上应该提供某种性能好处。

也就是说, 除非你已经对代码进行了描述,并确定你处在一个被称为数百万次的特别热门的path中, 否则我不认为值得担心。

https://blogs.msdn.microsoft.com/dotnet/2017/06/29/performance-improvements-in-ryujit-in-net-core-and-net-framework/


原始答案:

我做了下面的testing程序,然后使用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#编译器(发布版本configuration中的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 Studiodebugging器下运行了一个发布版本,并查看了反编译的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 

然后我认为可能在debugging器下运行会导致它执行较不积极的优化?

然后,我在任何debugging环境之外运行了一个独立的发行版构build可执行文件,并在程序完成后使用WinDBG + SOS进行入侵,并查看JIT编译的x86代码的拆分。

从下面的代码可以看出,当在debugging器外部运行时,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 

对我来说,这提供了坚实的certificate,密封和非密封类的调用方法之间不能有任何性能改进…我想我现在很高兴:-)

标记sealed的类别应该没有性能影响。

有些情况下, csc可能必须发出callvirt操作码而不是call操作码。 但是,这种情况似乎很less见。

而且在我看来,JIT应该能够为callvirt发出相同的非虚函数call ,如果它知道该类没有任何子类(尚)。 如果只有一个方法的实现存在,没有意义从一个vtable加载它的地址 – 直接调用一个实现。 对于这个问题,JIT甚至可以内联这个function。

这在JIT方面有点冒险,因为如果稍后加载子类,JIT将不得不丢弃该机器代码并再次编译代码,发出真正的虚拟调用。 我的猜测是这在实践中并不经常发生。

(是的,虚拟机devise师真的很积极地追求这些微小的performance胜利。)

密封类提供性能改进。 由于无法获得密封的课程,所以任何虚拟成员都可以变成非虚拟成员。

当然,我们正在谈论很小的收益。 我不会把class级标记为只是为了获得绩效改进,除非分析显示这是一个问题。

<题外话-咆哮>

厌恶封闭的课程。 即使性能优势令人吃惊(我怀疑),它们通过inheritance来阻止重用,从而摧毁了面向对象的模型。 例如,Thread类是密封的。 虽然我可以看到有人可能希望线程尽可能高效,但我也可以想象,能够子类Thread的场景会有很大的好处。 class级作者,如果你因为“表演”的原因必须把你的class级封闭起来, 至less提供一个界面 ,这样我们就不必在任何地方包装和replace我们需要你忘记的function。

例如: SafeThread必须包装Thread类,因为Thread是密封的,并且没有IThread接口; SafeThread会自动捕获未处理的线程exception,这是Thread类中完全没有的东西。 [和不,未处理的exception事件不会在次要线程中拾取未处理的exception]。

</题外话-咆哮>

我认为“密封”类正常情况下,我总是有一个理由,省略“密封”的关键字。

对我来说最重要的原因是:

a)更好的编译时间检查(转换为未实现的接口将在编译时检测到,而不仅仅是在运行时)

最重要的原因是:

b)虐待我的课是不可能的

我希望微软能够“封”这个标准,而不是“开封”。

@Vaibhav,你执行了什么样的testing来衡量性能?

我想我们不得不使用Rotor并钻入CLI,并了解密封类如何提高性能。

SSCLI(转子)
SSCLI:共享源代码公共语言基础结构

公共语言基础结构(CLI)是描述.NET Framework核心的ECMA标准。 共享源CLI(SSCLI),也称为Rotor,是ECMA CLI和ECMA C#语言规范技术的工作实现的源代码压缩归档,是Microsoft .NET体系结构的核心。

密封类将至less快一点,但有时候可以更快一点,如果JIT优化器可以内联本来是虚拟调用的调用。 所以,在那里所谓的方法,小到可以内联,绝对考虑封闭class级。

然而,封印课程的最好理由是说:“我没有devise这个inheritance,所以我不会因为假设它被devise成这样而被烧毁,而且我也不会通过locking实施来刻录自己,因为我让你从中得出结论。“

我知道这里有人说他们讨厌密封类,因为他们希望有机会得到任何东西…但这是不是最可维护的select…因为暴露一个类派生locking你在很多比不暴露所有那。 它类似于说:“我厌恶有私人成员的class级……我经常不能让class级做我想做的事,因为我没有进入。” 封装是重要的…密封是封装的一种forms。

运行这个代码,你会看到密封类的速度提高了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