153

我遇到了很多优化技巧,它们说你应该将你的类标记为密封以获得额外的性能优势。

我进行了一些测试以检查性能差异,但没有发现。难道我做错了什么?我是否错过了密封类会产生更好结果的情况?

有没有人运行测试并看到了不同?

帮我学习:)

4

12 回答 12

153

答案是否定的,密封类的性能并不比非密封类好。

2021 年:现在的答案是肯定的,密封课程有性能优势。

密封一个类可能并不总能提高性能,但 dotnet 团队正在采用密封所有内部类的规则,以便为优化器提供最佳机会。

有关详细信息,您可以阅读https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/#peanut-butter

下面的旧答案。

问题归结为callvs callvirtIL 操作码。Call比 快callvirtcallvirt主要用于不知道对象是否被子类化的情况。所以人们假设如果你密封一个类,所有的操作码都会从calvirts变为calls并且会更快。

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

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

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

调用和调用virt

于 2008-12-24T02:09:26.440 回答
63

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

关于调用类型、虚拟/非虚拟有复杂的规则,我并不了解它们,所以我无法为您真正概述它们,但如果您在谷歌上搜索密封类和虚拟方法,您可能会找到一些关于该主题的文章。

请注意,您将从这一优化级别获得的任何性能优势都应被视为最后的手段,始终在算法级别进行优化,然后再进行代码级别的优化。

这是一个提到这一点的链接:Rambling on the seal关键字

于 2008-08-05T12:32:40.510 回答
32

更新:从 .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");
}

在所有情况下,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

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

于 2012-02-20T20:45:28.267 回答
24

据我所知,不能保证性能优势。但是在某些特定条件下使用密​​封方法有可能降低性能损失。(密封类使所有方法都被密封。)

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


细节

许多现代 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 芯片)甚至在这种情况下使用称为推测执行的技术来利用管道。只需预取其中一个执行路径。但是这种技术的命中率并没有那么高。推测失败会导致管道停顿,这也会造成巨大的性能损失。(这完全是由 CPU 实现的。有些移动 CPU 号称没有这种优化以节省能源)

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


这个答案(Why is it faster to process a sorted array than an unsorted array?)更好地描述了分支预测。

于 2011-02-03T15:22:32.063 回答
5

<题外话>

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

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

</off-topic-rant>

于 2008-10-14T20:04:19.703 回答
4

标记一个类sealed不应该对性能产生影响。

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

在我看来,如果 JIT知道该类没有任何子类(还)callvirt,它应该能够发出与它相同的非虚拟函数调用。call如果该方法只存在一个实现,那么从 vtable 加载它的地址是没有意义的——直接调用一个实现即可。就此而言,JIT 甚至可以内联该函数。

这对 JIT 来说是一场赌博,因为如果稍后加载子类 JIT 将不得不丢弃该机器代码并再次编译代码,发出真正的虚拟调用。我的猜测是这在实践中并不经常发生。

(是的,VM 设计者确实在积极地追求这些微小的性能优势。)

于 2009-11-20T09:53:29.783 回答
3

密封类应该提供性能改进。由于无法派生密封类,因此任何虚拟成员都可以变成非虚拟成员。

当然,我们说的是非常小的收益。我不会仅仅为了提高性能而将一个类标记为密封类,除非分析表明它是一个问题。

于 2008-08-05T12:37:07.067 回答
3

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

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

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

最重要的原因:

b) 这样就不可能滥用我的课程

我希望微软将“密封”作为标准,而不是“未密封”。

于 2010-08-03T14:27:15.043 回答
2

密封类至少会快一点,但有时可能会快得多……如果 JIT 优化器可以内联本来是虚拟调用的调用。因此,如果经常调用的方法足够小可以内联,那么一定要考虑密封类。

然而,封闭一个类的最好理由是说“我没有设计它来继承,所以我不会让你因为假设它是这样设计的而被烧毁,我不会去因为我让你从中获得灵感,所以被锁定在一个实现中来燃烧自己。”

我知道这里有些人说他们讨厌密封类,因为他们希望有机会从任何东西中派生出来……但这通常不是最易于维护的选择……因为将类暴露给派生比不暴露所有内容更能锁定你那。这类似于说“我讨厌有私人成员的课程......我经常无法让课程做我想做的事,因为我没有访问权限。” 封装很重要……密封是封装的一种形式。

于 2011-10-01T10:37:28.847 回答
1

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

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();
}

军用代码

.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 个周期。

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

于 2020-02-25T15:00:22.427 回答
0

从 .NET 6.0 开始,答案是肯定的。

密封类可以帮助 JIT 去虚拟化调用,从而减少调用方法时的开销。这有额外的好处,因为必要时 JIT 可以内联去虚拟化的调用,这也可能导致不断折叠。

例如,在 MSDN 文章的这段代码中:

[Benchmark(Baseline = true)]
public int NonSealed() => _nonSealed.M() + 42;

[Benchmark]
public int Sealed() => _sealed.M() + 42;

public class BaseType
{
    public virtual int M() => 1;
}

public class NonSealedType : BaseType
{
    public override int M() => 2;
}

public sealed class SealedType : BaseType
{
    public override int M() => 2;
}

“NonSealed”基准运行时间为 0.9837ns,但“Sealed”方法并不比仅返回一个常量值的函数花费更多时间。这是由于不断折叠。

类型检查密封类也具有性能优势,例如 MSDN 文章中的这段代码:

private object _o = "hello";

[Benchmark(Baseline = true)]
public bool NonSealed() => _o is NonSealedType;

[Benchmark]
public bool Sealed() => _o is SealedType;

public class NonSealedType { }
public sealed class SealedType { }

检查非密封类型大约需要 1.76ns,而检查密封类型只需大约 0.07ns。

事实上,.NET 团队制定了一个策略来密封所有可以密封的私有和内部类。

请注意,我们正在处理节省不到 2 纳秒的调用,因此调用虚拟方法的开销在大多数情况下不会成为瓶颈。我认为它更适合简单的虚拟吸气剂或非常短的方法。

于 2021-11-22T11:38:46.357 回答
-11

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

于 2009-11-26T17:50:14.860 回答