【问题标题】:Why are sealed types faster?为什么密封类型更快?
【发布时间】:2009-05-26 16:54:03
【问题描述】:

为什么密封类型更快?

我想知道为什么这是真的更深层次的细节。

【问题讨论】:

标签: c# .net performance clr


【解决方案1】:

在最低级别,当您拥有密封类时,编译器可以进行微优化。

如果您在密封类上调用方法,并且在编译时将类型声明为该密封类,则编译器可以使用 call IL 指令而不是 callvirt 来实现方法调用(在大多数情况下) IL指令。这是因为方法目标不能被覆盖。 Call 消除了 null 检查,并且比 callvirt 执行更快的 vtable 查找,因为它不必检查虚拟表。

这可能是对性能的非常非常轻微的改进。

话虽如此,在决定是否封课时,我会完全忽略这一点。将类型标记为密封确实应该是设计决策,而不是性能决策。您是否希望人们(包括您自己)现在或将来可能从您的类中继承?如果是这样,请不要密封。如果没有,请密封。这确实应该是决定因素。

【讨论】:

  • 在设计时,倾向于密封不需要明确扩展的公共类型可能是一个好主意,因为在未来版本中解封一个类是一个非破坏性的变化,而相反的是不正确。
  • @Neil Williams:我同意。一般来说,因为解封一个类是安全的,而密封不是,如果你正在制作公共图书馆,密封可能是一件好事。不过,这再次使密封成为一种设计选择,而不是性能问题。
  • 我认为这是由于内联。 C# 编译器总是使用 callvirt,因为它喜欢那个 IL 代码的空值检查副作用。
【解决方案2】:

本质上,这与他们不必担心对虚函数表的扩展有关;密封类型不能扩展,因此运行时不需要关心它们是如何多态的。

【讨论】:

    【解决方案3】:

    决定发布小代码示例来说明 C# 编译器何时发出“call”和“callvirt”指令。

    所以,这里是我使用的所有类型的源代码:

        public sealed class SealedClass
        {
            public void DoSmth()
            { }
        }
    
        public class ClassWithSealedMethod : ClassWithVirtualMethod
        {
            public sealed override void DoSmth()
            { }
        }
    
        public class ClassWithVirtualMethod
        {
            public virtual void DoSmth()
            { }
        }
    

    我还有一种方法可以调用所有“DoSmth()”方法:

        public void Call()
        {
            SealedClass sc = new SealedClass();
            sc.DoSmth();
    
            ClassWithVirtualMethod cwcm = new ClassWithVirtualMethod();
            cwcm.DoSmth();
    
            ClassWithSealedMethod cwsm = new ClassWithSealedMethod();
            cwsm.DoSmth();
        }
    

    查看“Call()”方法,我们可以说(理论上)C# 编译器应该发出 2 个“callvirt”和 1 个“call”指令,对吧? 不幸的是,现实有点不同——3“callvirt”-s:

    .method public hidebysig instance void Call() cil managed
    {
        .maxstack 1
        .locals init (
            [0] class TestApp.SealedClasses.SealedClass sc,
            [1] class TestApp.SealedClasses.ClassWithVirtualMethod cwcm,
            [2] class TestApp.SealedClasses.ClassWithSealedMethod cwsm)
        L_0000: newobj instance void TestApp.SealedClasses.SealedClass::.ctor()
        L_0005: stloc.0 
        L_0006: ldloc.0 
        L_0007: callvirt instance void TestApp.SealedClasses.SealedClass::DoSmth()
        L_000c: newobj instance void TestApp.SealedClasses.ClassWithVirtualMethod::.ctor()
        L_0011: stloc.1 
        L_0012: ldloc.1 
        L_0013: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
        L_0018: newobj instance void TestApp.SealedClasses.ClassWithSealedMethod::.ctor()
        L_001d: stloc.2 
        L_001e: ldloc.2 
        L_001f: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
        L_0024: ret 
    }
    

    原因很简单:运行时必须在调用“DoSmth()”方法之前检查类型实例是否不等于null。 但是我们仍然可以编写代码,使 C# 编译器能够发出优化的 IL 代码:

        public void Call()
        {
            new SealedClass().DoSmth();
    
            new ClassWithVirtualMethod().DoSmth();
    
            new ClassWithSealedMethod().DoSmth();
        }
    

    结果是:

    .method public hidebysig instance void Call() cil managed
    {
        .maxstack 8
        L_0000: newobj instance void TestApp.SealedClasses.SealedClass::.ctor()
        L_0005: call instance void TestApp.SealedClasses.SealedClass::DoSmth()
        L_000a: newobj instance void TestApp.SealedClasses.ClassWithVirtualMethod::.ctor()
        L_000f: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
        L_0014: newobj instance void TestApp.SealedClasses.ClassWithSealedMethod::.ctor()
        L_0019: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
        L_001e: ret 
    }
    

    如果你尝试以同样的方式调用非密封类的非虚方法,你也会得到“call”指令而不是“callvirt”

    【讨论】:

    • 谢谢,为什么在你的第二个例子中避免了空检查?你能解释一下吗?
    • 因为我没有像第一个例子那样使用“SealedClass”类型的局部变量,所以编译器不需要检查它是否为'null'。如果您将“SealedClass.DoSmth()”方法声明为静态,则会生成相同的 IL 代码
    【解决方案4】:

    如果 JIT 编译器看到对使用密封类型的虚拟方法的调用,它可以通过非虚拟方式调用该方法来生成更高效的代码。现在调用非虚拟方法更快,因为不需要执行vtable 查找。恕我直言,这是微优化,应该作为提高应用程序性能的最后手段。如果您的方法包含任何代码,与执行代码本身的成本相比,虚拟版本的速度将比非虚拟版本慢得可以忽略不计。

    【讨论】:

    • 为什么这是最后的手段?为什么不默认密封你的课程?如果有一些与之相关的成本(可读性较差的代码,或者通常是更多的开发时间),它通常只被认为是一种微优化。如果这样做没有坏处,那么无论性能是否存在问题,为什么不这样做呢?
    • 当你密封一个类时,你会阻止使用继承。这会使开发更加困难,它可以防止解决某些错误。理想情况下,人们会考虑它并设计继承,并明确设计用于扩展的内容并密封其他所有内容。盲目地密封一切都太严格了。
    【解决方案5】:

    为了扩展其他人的答案,不能扩展密封类(相当于 Java 中的 final 类)。这意味着,只要编译器看到使用此类的方法,编译器就绝对知道不需要运行时分派。它不必检查类来动态地查看层次结构中哪个类的哪个方法需要被调用。这意味着分支可以被编译而不是动态的。

    例如,如果我有一个非密封类Animal 有一个方法makeNoise(),编译器不一定知道是否有任何Animal 实例覆盖该方法。因此,每次任何Animal 实例调用makeNoise() 时,都需要检查该实例的类层次结构,以查看该实例是否在扩展类中覆盖了此方法。

    但是,如果我有一个密封类AnimalFeeder,它有一个方法feedAnimal(),那么编译器肯定知道这个方法不能被覆盖。它可以在分支中编译为子程序或等效指令,而不是使用虚拟调度表。

    注意:您可以在一个类上使用sealed 来防止从该类继承任何,并且您可以在一个在基类中声明为virtual 的方法上使用sealed防止进一步覆盖该方法。

    【讨论】:

      【解决方案6】:

      要真正看到它们,您需要分析 JIT 编译的 code(最后一个)。

      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¨orn Granlund 撰写的报告,AMD 和 Intel x86 处理器的指令延迟和吞吐量,Intel Pentium 4 中以下指令的速度为:

      • mov:有 1 个周期作为延迟,处理器每个周期可以支持这种类型的 2.5 条指令
      • cmp:有 1 个周期作为延迟,处理器每个周期可以支持这种类型的 2 条指令

      链接https://gmplib.org/~tege/x86-timing.pdf

      这意味着,理想情况下,调用密封方法所需的时间是 2 个周期,而调用派生类或基类方法所需的时间是 3 个周期。

      编译器的优化使得密封类和非密封类的性能差异如此之低,以至于我们谈论的是处理器圈,因此与大多数应用程序无关。

      【讨论】:

        猜你喜欢
        • 2010-12-18
        • 1970-01-01
        • 1970-01-01
        • 2014-12-27
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多