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

我遇到过许多优化技巧,它们说您应该将类标记为密封的,以获得额外的性能好处。

我运行了一些测试来检查性能差异,没有发现任何问题。我做错什么了吗?我是否忽略了密封类可以提供更好结果的情况?

有人做过测试,发现有什么不同吗?

帮我学习:)

36589 次浏览

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

关于调用类型,虚拟/非虚拟,有很多复杂的规则,我不知道所有这些规则,所以我不能真正为你概述它们,但是如果你搜索密封类和虚拟方法,你可能会找到一些关于这个主题的文章。

请注意,从这一级别的优化中获得的任何性能好处都应该被视为最后的手段,在代码级别优化之前,始终要在算法级别进行优化。

这里有一个提到这一点的链接: 在密封关键字上胡言乱语

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

当然,我们谈论的是非常小的收益。我不会仅仅为了获得性能改进而将类标记为密封的,除非分析显示它是一个问题。

< 偏题-咆哮 >

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

例如: 安全线程必须包装 Thread 类,因为 Thread 是密封的,而且没有 IThread 接口; SafeThread 自动捕获线程上未处理的异常,而 Thread 类完全缺少这些异常。[不,未处理的异常事件确实会在辅助线程中接收未处理的异常]。

答案是否定的,密封类不比非密封类表现得更好。

2021年: 现在的答案是肯定的,密封一个类会带来性能上的好处。

密封一个类可能并不总是能提高性能,但是网络团队正在采用密封所有内部类的规则,以给优化者最好的机会。

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

老答案在下面。

这个问题归结为 callcallvirt的 IL 操作代码。Callcallvirt快,而 callvirt主要用于不知道对象是否已被子类化的情况。因此,人们认为,如果你密封一个类,所有的操作代码将从 calvirts改为 calls,并将更快。

不幸的是,callvirt还做了其他一些有用的事情,比如检查 null 引用。这意味着即使类是密封的,引用也可能是 null,因此需要 callvirt。您可以绕过这个问题(不需要密封类) ,但是它变得有点无意义。

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

更多信息见下面的问题:

调用并调用 virt

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

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

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

对于 JIT 来说,这有点冒险,因为如果后来加载了一个子类 ,JIT 将不得不扔掉那些机器代码,重新编译代码,发出一个真正的虚拟调用。我猜这种情况在实践中并不常见。

(是的,虚拟机设计师确实积极地追求这些微小的性能胜利。)

运行这段代码,您将看到密封类的速度是原来的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:00.3826678

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

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

A)更好的编译时检查(不仅在运行时,而且在编译时会检测到对未实现的接口的强制转换)

最重要的原因是:

B)那样的话就不可能滥用我的课程

我希望微软把标准定为“密封”,而不是“解封”。

据我所知,没有性能收益的保证。但有 在某些特定条件下减少绩效惩罚的机会与密封的方法。(密封类使所有方法都被密封。)

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


细节

许多现代 CPU 使用长流水线结构来提高性能。因为 CPU 比内存快得难以置信,CPU 必须从内存预取代码来加速管道。如果代码没有在适当的时候准备好,管道将处于空闲状态。

有一个叫做 译自: 美国《科学》杂志网站(http://en.wikipedia.org/wiki/渠道)原文地址: http://www.wikipedia.org/wiki/Dynamic _  克劳斯·福尔曼的大障碍干扰了这种“预取”优化。您可以将其理解为一个条件分支。

// 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芯片)甚至在这种情况下也使用称为 返回文章页面处理程序的 Speculative_execution 译者:的技术来利用管道。只需预取一个执行路径。但是这种技术的命中率并不高。投机失败导致管道失速,也造成了巨大的性能损失。(这完全是由 CPU 实现的。有些移动 CPU 称为不做这种优化来节约能源)

基本上,c # 是一个静态编译语言。但并不总是如此。我不知道确切的条件,这完全取决于编译器的实现。一些编译器可以通过防止方法重写(如果方法被标记为 sealed)来消除克劳斯·福尔曼的可能性。愚蠢的编译器可能不会。 这就是 sealed的性能优势。


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

如果 JIT 优化器可以内联调用,那么密封类的速度至少会快一点,但有时候可能会快得离谱。因此,如果经常调用的方法足够小,可以内联,那么一定要考虑密封类。

然而,密封一个类的最好理由是: “我并没有把它设计成可以继承的,所以我不会因为假设它被设计成这样而让你焦头烂额,我也不会因为锁定到一个实现中而让自己焦头烂额,因为我让你从它派生出来。”

我知道这里有些人说他们讨厌密封类,因为他们希望有机会从任何东西派生... ... 但是这通常不是最可维护的选择... ... 因为将一个类暴露在派生中比不暴露所有这些要困难得多。它类似于说“我讨厌有私有成员的类... ... 我经常不能让类做我想做的事情,因为我没有访问权限。”封装很重要... 封装是封装的一种形式。

更新: 截至。NET Core 2.0及。NET 桌面4.7.1,CLR 现在支持虚拟化。它可以使用密封类中的方法,并用直接调用替换虚调用——如果它能够确定这样做是安全的,它也可以对非密封类这样做。

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

也就是说,我认为没有必要担心 除非你已经对代码进行了分析,并确定你正处于一个被调用数百万次的特别热门的路径,或者类似的东西:

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


原答案:

我制作了下面的测试程序,然后使用反射器反编译它,以查看发出了什么 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

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

要真正看到它们,您需要分析 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处理器的指令延迟和吞吐量撰写的报告,英特尔奔腾4中下列指令的速度是:

  • Mov : 有1个周期作为延迟,处理器每个周期可以维持2.5条指令
  • Cmp : 有1个周期作为延迟,并且处理器每个周期可以维持这种类型的2条指令

链接 : < a href = “ https://gmplib.org/~ tege/x86-timing.pdf”rel = “ nofollow noReferrer”> https://gmplib.org/~tege/x86-timing.pdf

这意味着,最好是这样,调用密封方法所需的时间是2个周期,而调用派生或基类方法所需的时间是3个周期。

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

从.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;
}

“ NonSeally”基准测试在0.9837 ns 内运行,但是“ Seally”方法所需的时间并不比仅返回常量值的函数多。这是常数折叠造成的。

类型检查密封类也有性能方面的好处,如 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.76 ns,而检查密封类型只需约0.07 ns。

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

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