密封课真的提供性能优势吗?

Vai*_*hav 134 .net optimization performance frameworks

我遇到了很多优化提示,说明你应该将你的课程标记为密封以获得额外的性能优势.

我运行了一些测试以检查性能差异,但没有找到.难道我做错了什么?我错过了密封课程会给出更好结果的情况吗?

有没有人进行测试并看到了差异?

帮我学习:)

Cam*_*and 138

答案是否定的,密封类的表现不如非密封.

问题归结为callvs callvirtIL操作码.Call比您更快callvirt,并且callvirt主要在您不知道对象是否已被子类化时使用.因此人们认为,如果你密封一个班级,所有的操作码将会改变calvirts,calls并且会更快.

不幸的是callvirt,其他使它也有用的东西,比如检查空引用.这意味着即使密封了类,引用仍可能为null,因此callvirt需要a.你可以绕过这个(不需要密封课程),但它变得有点无意义.

结构使用call是因为它们不能被子类化并且永远不会为空.

有关更多信息,请参阅此问题:

电话和callvirt

  • AFAIK,使用`call`的情况是:在`new T().Method()`中,对于`struct`方法,对于`virtual`方法的非虚拟调用(例如`base.Virtual( )`),或"静态"方法.其他地方都使用`callvirt`. (5认同)
  • 为什么这个答案是错的.从Mono更新日志:"密封类和方法的虚拟化优化,将IronPython 2.0 pystone性能提高4%.其他程序可以期待类似的改进[罗德里戈]." 密封课程可以提高绩效,但一如既往,这取决于具体情况. (5认同)

ang*_*son 56

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

有关于呼叫类型,虚拟/非虚拟的复杂规则,我不知道所有这些,所以我无法为你概述它们,但如果你谷歌的密封类和虚拟方法,你可能会发现有关该主题的一些文章.

请注意,从这种优化级别获得的任何性能优势都应视为最后的手段,在优化代码级别之前始终优化算法级别.

这里有一个提到这个的链接:密封关键字

  • @Steven A. Lowe - 我认为杰弗里里希特试图以一种略微迂回的方式说的是,如果你让你的课程没有密封,你需要考虑派生类如何/将使用它,如果你没有时间或倾向于正确地做到这一点,然后密封它,因为它不太可能在将来导致其他代码的重大变化.这根本不是废话,这是很好的常识. (15认同)
  • 密封类可能具有虚方法,因为它可能来自声明它的类.然后,当您稍后声明密封后代类的变量并调用该方法时,编译器可能会发出对已知实现的直接调用,因为它知道这可能与已知的实现不同.该类的at-compile-time vtable.至于密封/未密封,这是一个不同的讨论,我同意默认密封课程的原因. (6认同)
  • [续]性能示例是一个笑话:优化虚拟方法调用; 为什么密封类首先会有虚拟方法,因为它不能被子类化?最后,安全性/可预测性论证显然是愚蠢的:"你不能使用它,因此它是安全的/可预测的".大声笑! (3认同)
  • "漫步"链接很有意思,因为它听起来像是技术上的好,但实际上是无稽之谈.阅读文章的评论以获取更多信息.总结:给出的3个理由是版本控制,性能和安全性/可预测性 - [见下一条评论] (2认同)

Ori*_*rds 25

更新:从.NET Core 2.0和.NET Desktop 4.7.1开始,CLR现在支持虚拟化.它可以采用密封类中的方法,并使用直接调用替换虚拟调用 - 如果它可以确定安全,也可以对非密封类执行此操作.

在这种情况下(CLR无法检测到的密封类可以安全地进行虚拟化),密封类实际上应该提供某种性能优势.

也就是说,我不认为值得担心,除非你已经对代码进行了描述并确定你处于被称为数百万次的特别热门的道路,或类似的东西:

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


原答案:

我制作了以下测试程序,然后使用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");
}
Run Code Online (Sandbox Code Playgroud)

在所有情况下,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 
Run Code Online (Sandbox Code Playgroud)

人们说密封提供性能优势的经常引用的原因是编译器知道类没有被覆盖,因此可以使用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 
Run Code Online (Sandbox Code Playgroud)

然后我想也许在调试器下运行导致它执行不太激进的优化?

然后我在任何调试环境之外运行一个独立的发布构建可执行文件,并在程序完成后使用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
Run Code Online (Sandbox Code Playgroud)

密封类:

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
Run Code Online (Sandbox Code Playgroud)

对我来说,这提供了坚实的证据,证明在密封类和非密封类之间的调用方法之间不会有任何性能改进......我想我现在很开心:-)

  • 支持深入挖掘机器代码级别的努力,但是要_非常_小心地做出诸如“这提供了可靠的证据表明_不可能_有任何性能改进”之类的声明。您所展示的是,对于一种特定场景,本机输出没有差异。这只是一个数据点,不能假设它适用于所有场景。对于初学者来说,您的类没有定义任何虚拟方法,因此根本不需要虚拟调用。 (2认同)

Eon*_*nil 23

据我所知,无法保证性能优势.但是在密封方法的某些特定条件下,有可能降低性能损失.(密封等级使所有方法都被密封.)

但这取决于编译器实现和执行环境.


细节

许多现代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();
Run Code Online (Sandbox Code Playgroud)

在这种情况下,CPU无法预取下一个要执行的代码,因为在条件解决之前,下一个代码位置是未知的.所以这会导致危险导致管道闲置.而闲置的性能损失在常规中是巨大的.

在方法重写的情况下会发生类似的事情.编译器可以确定当前方法调用的正确方法覆盖,但有时这是不可能的.在这种情况下,只能在运行时确定适当的方法.这也是动态调度的一种情况,动态类型语言的主要原因通常比静态类型语言慢.

一些CPU(包括最近的英特尔x86芯片)使用称为推测执行的技术来利用管道即使在这种情况下也是如此.只需预取一个执行路径.但是这种技术的命中率并不高.而推测失败会导致管道停滞,这也会造成巨大的性能损失.(这完全是由CPU实现的.一些移动CPU被称为没有这种节省能源的优化)

基本上,C#是一种静态编译的语言.但不总是.我不知道确切的条件,这完全取决于编译器的实现.如果方法被标记为,则某些编译器可以通过阻止方法覆盖来消除动态分派的可能性sealed.愚蠢的编译器可能不会.这是性能的好处sealed.


这个答案(为什么处理排序数组比处理未排序数组更快?)更好地描述了分支预测.

  • 非虚拟或密封功能的一个优点是它们可以在更多情况下内联. (2认同)

Ste*_*owe 5

<题外话>

讨厌密封的课程。即使性能优势令人震惊(我对此表示怀疑),它们也会通过继承阻止重用来破坏面向对象的模型。例如,Thread 类是密封的。虽然我可以看到人们可能希望线程尽可能高效,但我也可以想象能够对 Thread 进行子类化会带来很大好处的场景。类作者,如果您出于“性能”原因必须密封您的类,至少提供一个接口,这样我们就不必在需要您忘记的功能的任何地方进行包装和替换。

示例:SafeThread不得不将 Thread 类包装起来,因为 Thread 是密封的,没有 IThread 接口;SafeThread 自动捕获线程上未处理的异常,这是 Thread 类中完全缺失的。[不,未处理的异常事件不会在辅助线程中拾取未处理的异常]。

</off-topic-rant>

  • 出于性能原因,我不会密封我的课程。我出于设计原因密封它们。为继承设计是很困难的,而且大部分时间都会浪费这种努力。我完全同意提供接口 - 这是一个*远*的解封类的卓越解决方案。 (38认同)
  • 封装通常是比继承更好的解决方案。以您的特定线程为例,捕获线程异常违反了 Liskov 替换原则,因为您已经更改了 Thread 类的记录行为,因此即使您可以从中派生,也不能说您可以在任何地方使用 SafeThread你可以使用线程。在这种情况下,您最好将 Thread 封装到另一个具有不同记录行为的类中,您可以这样做。有时事情是为了你自己的利益而密封的。 (6认同)
  • @[Greg Beech]:观点,而不是事实 - 能够从 Thread 继承来修复其设计中的令人发指的疏忽并不是一件坏事;-)而且我认为你夸大了 LSP - 中的可证明属性 q(x)这种情况是“未处理的异常破坏了程序”,这不是“理想的属性”:-) (2认同)
  • 因为我们在这里做题外话,就像你_讨厌_密封类一样,我_讨厌_吞下异常。没有什么比事情发生时更糟糕的了,但程序仍在继续。JavaScript 是我的最爱。您对某些代码进行了更改,然后突然单击按钮完全没有任何作用。伟大的!ASP.NET 和 UpdatePanel 是另一个;说真的,如果我的按钮处理程序抛出它是一件大事,它需要崩溃,所以我知道有一些东西需要修复!一个什么都不做的按钮比一个弹出崩溃屏幕的按钮*更*没用! (2认同)