我遇到了很多优化技巧,这些技巧说您应该将类标记为已密封,以获得更多的性能优势。
我进行了一些测试以检查性能差异,但没有发现。难道我做错了什么?我是否错过了密封类会带来更好结果的情况?
有没有人进行测试并发现差异?
帮我学习:)
我遇到了很多优化技巧,这些技巧说您应该将类标记为已密封,以获得更多的性能优势。
我进行了一些测试以检查性能差异,但没有发现。难道我做错了什么?我是否错过了密封类会带来更好结果的情况?
有没有人进行测试并发现差异?
帮我学习:)
Answers:
JITter有时会在密封类中使用对方法的非虚拟调用,因为无法进一步扩展它们。
关于调用类型,虚拟/非虚拟类型有复杂的规则,我不了解它们的全部,因此我无法真正为您概述它们,但是如果您搜索密封类和虚拟方法,则可能会找到有关该主题的文章。
请注意,您将从此优化级别获得的任何类型的性能收益都应视为最后解决方案,始终在算法级别进行优化,然后再在代码级别进行优化。
这是一个提到此的链接:在密封关键字上乱逛
答案是否定的,密封类的性能不会比非密封类好。
问题归结于call
vs callvirt
IL操作码。Call
比快callvirt
,并且callvirt
主要在您不知道对象是否已被子类化时使用。因此人们认为,如果您密封一个班级,那么所有操作码都会从更改为calvirts
,calls
并且操作速度会更快。
不幸的是callvirt
,还有其他使它有用的事情,例如检查空引用。这意味着即使一个类是密封的,引用也可能仍然为空,因此callvirt
需要a。您可以解决这个问题(而无需密封类),但是它变得毫无意义。
使用结构call
是因为它们不能被子类化,并且永远不能为null。
有关更多信息,请参见此问题:
call
是:在情况中new T().Method()
,用于struct
方法,用于方法的非虚拟调用virtual
(例如base.Virtual()
)或用于static
方法。其他任何地方都使用callvirt
。
更新:从.NET Core 2.0和.NET Desktop 4.7.1开始,CLR现在支持虚拟化。它可以采用密封类中的方法,并用直接调用替换虚拟调用-如果可以确定这样做是安全的,它也可以对非密封类执行此操作。
在这种情况下(CLR无法以其他方式检测到安全性的密封类),密封类实际上应该提供某种性能优势。
就是说,除非您已经分析了代码并确定自己处在被称为数百万次的热路径中,否则我认为不必担心。
原始答案:
我编写了以下测试程序,然后使用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在Release构建配置中)发出相同的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
对我来说,这提供了有力的证据,表明在密封类和非密封类上的调用方法之间不会有任何性能改进……我想我现在很高兴:-)
callvirt
这些方法不是虚拟的,为什么会出现呢?
callvirt
密封方法,因为它仍然必须在调用方法调用之前对对象进行空检查,并且一旦考虑到这一点,您也可以使用callvirt
。要删除callvirt
和直接跳转,他们要么需要((string)null).methodCall()
像C ++一样修改C#以允许使用,要么需要静态证明对象不是null(他们可以这样做,但没有打扰到)
据我所知,无法保证性能的提高。但是在某些特定条件下使用密封方法可以降低性能损失。(密封类使所有方法都被密封。)
但这取决于编译器的实现和执行环境。
许多现代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
。
这个答案(为什么处理排序数组比未排序数组更快?)描述了分支预测好得多。
<题外话>
我讨厌密封班。即使性能优势惊人(我对此表示怀疑),它们也会通过防止通过继承进行重用而破坏面向对象的模型。例如,Thread类是密封的。虽然我可以看到人们可能希望线程尽可能地高效,但我也可以想象能够将Thread子类化的巨大好处。类作者,如果您出于“性能”的原因必须密封类,请至少提供一个接口,这样我们就不必在所有我们需要您忘记的功能的地方进行包装和替换。
示例:SafeThread必须包装Thread类,因为Thread是密封的,并且没有IThread接口。SafeThread会自动捕获线程上未处理的异常,这些异常在Thread类中完全丢失。[并且不,未处理的异常事件不会在辅助线程中拾取未处理的异常]。
</ off-topic-rant>
标记一个类sealed
应该不会对性能产生影响。
在某些情况下,csc
可能必须发出callvirt
操作码而不是call
操作码。但是,这些情况似乎很少见。
在我看来,如果JIT 知道该类还没有任何子类(尚未)callvirt
,它应该能够发出与该请求相同的非虚拟函数调用call
。如果仅存在该方法的一个实现,则从vtable加载其地址毫无意义,只需直接调用一个实现即可。为此,JIT甚至可以内联功能。
这是一个有点对JIT的部分赌博的,因为如果一个子类是后加载,JIT将不得不扔掉机器代码,并重新编译代码,散发出真实的虚拟呼叫。我的猜测是在实践中这种情况很少发生。
(是的,VM设计人员确实确实在积极追求这些微小的性能优势。)
密封的类应可提高性能。由于无法派生密封类,因此任何虚拟成员都可以变成非虚拟成员。
当然,我们说的是很小的收获。除非剖析显示它是一个问题,否则我不会将一个类标记为封闭的只是为了提高性能。
call
而不是callvirt
...我也很喜欢非空引用类型,原因很多。叹息:-(
我认为“密封”类是正常情况,我总是有理由省略“密封”关键字。
对我而言,最重要的原因是:
a)更好的编译时检查(将在编译时(不仅在运行时)检测到未实现的接口的广播)
并且,首要原因:
b)这样不可能滥用我的课程
我希望微软能够使“密封”成为标准,而不是“未密封”。
@Vaibhav,您执行了哪种测试来衡量性能?
我猜想人们将不得不使用Rotor并深入研究CLI并了解密封类如何提高性能。
SSCLI(转子)
SSCLI:共享源通用语言基础结构通用语言基础结构(CLI)是ECMA标准,描述了.NET Framework的核心。共享源CLI(SSCLI),也称为Rotor,是源代码的压缩存档,用于ECMA CLI和ECMA C#语言规范的有效实现,这些技术是Microsoft .NET体系结构的核心。
如果JIT Optimizer可以内联调用,否则密封类将至少快一点,但是有时可以更快一些。。。因此,在经常调用的方法足够小以内联的地方,绝对可以考虑密封该类。
但是,密封类的最佳理由是说“我没有将其设计为从继承而来,因此,我不会通过假定它是这样设计而让您感到厌倦的,因此,我不会通过锁定到实现中来烧死自己,因为我让您从中获得好处。”
我知道这里有些人说他们讨厌密封类,因为他们希望有机会从任何东西中派生……但这通常不是最可维护的选择……因为将类暴露给派生会使您锁定很多,而不是不暴露所有对象。那。这类似于说“我讨厌具有私有成员的类……我经常无法使类做我想要的事情,因为我没有访问权限。” 封装很重要...密封是封装的一种形式。
callvirt
对密封类的实例方法使用(虚拟方法调用),因为它仍必须对它们进行空对象检查。关于内联,CLR JIT可以(并且确实)对密封类和非密封类都进行内联虚拟方法调用……是的。表演是神话。
要真正看到它们,您需要分析JIT编译的编码(最后一个)。
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(移动指令)后,调用密封方法,在dword ptr [ecx],ecx(cmp指令)之间执行比较,然后在派生/基类直接执行该方法的同时调用该方法。 。
根据Torbjorn Granlund撰写的报告,AMD和Intel x86处理器的指令等待时间和吞吐量, Intel Pentium 4中以下指令的速度为:
链接:https : //gmplib.org/~tege/x86-timing.pdf
这意味着,理想情况下,调用密封方法所需的时间为2个周期,而调用派生或基类方法所需的时间为3个周期。
编译器的优化使密封和未密封分类的性能之间的差异变得如此之低,以至于我们谈论的是处理器界,因此对于大多数应用程序而言,它们是无关紧要的。
运行以下代码,您会发现密封类的速度提高了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