我遇到了很多优化技巧,这些技巧说您应该将类标记为已密封,以获得更多的性能优势。
我进行了一些测试以检查性能差异,但没有发现。 难道我做错了什么? 我是否错过了密封类会带来更好结果的情况?
有没有人进行测试并发现差异?
帮我学习:)
答案是否定的,密封类的性能不会比非密封类好。
问题归结于call与callvirt IL操作码。 call比callvirt快,并且callvirt主要在您不知道对象是否已被子类化时使用。因此人们认为,如果您密封一个类,则所有操作码将从calvirts更改为calls,并且速度会更快。
不幸的是callvirt做了其他使它有用的事情,例如检查空引用。这意味着即使密封了一个类,该引用仍可能为null,因此需要callvirt。您可以解决这个问题(而无需密封类),但是它变得毫无意义。
结构使用call,因为它们不能被子类化并且永远不能为null。
有关更多信息,请参见此问题:
通话和通话
JITter有时会在密封类中使用对方法的非虚拟调用,因为无法进一步扩展它们。
关于调用类型,虚拟/非虚拟,有复杂的规则,我不了解它们,因此我无法真正为您概述它们,但是如果您搜索密封类和虚拟方法,则可能会找到有关该主题的文章。
请注意,您将从此优化级别获得的任何类型的性能收益都应视为最后解决方案,始终在算法级别进行优化,然后再在代码级别进行优化。
这是一个提到此的链接:在密封关键字上乱逛
更新:从.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代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| 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,如下所示:
1 2 3 4 5 6
| 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代码都是相同的,除了类名和函数内存地址(当然它们必须不同)之外。这里是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| // 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方法直接内联到调用程序中。
然而,关键的是,在调用密封与非密封类时,它是相同的。密封或非密封类之间没有任何区别。
这是在调用普通类时的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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 |
与密封类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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 |
对我来说,这提供了有力的证据,证明在密封类和非密封类上的调用方法之间不会有任何性能改进……我想我现在很高兴:-)
据我所知,不能保证性能的提高。但是在某些特定条件下使用密??封方法可以降低性能损失。 (密封类使所有方法都被密封。)
但这取决于编译器的实现和执行环境。
细节
许多现代CPU使用长的流水线结构来提高性能。因为CPU的速度比内存快得多,所以CPU必须从内存中预取代码以加速管线。如果未在适当的时间准备好代码,则管道将处于空闲状态。
有一个称为动态调度的大障碍会破坏此"预取"优化。您可以将其理解为只是条件分支。
1 2 3 4 5 6 7 8
| // 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的性能优势。
这个答案(为什么处理排序数组比未排序数组快?)描述了分支预测好得多。
将类标记为sealed应该不会对性能产生影响。
在某些情况下,csc可能必须发出callvirt操作码而不是call操作码。但是,这些情况似乎很少见。
在我看来,如果JIT知道类还没有任何子类(尚未),它应该能够对callvirt发出与call相同的非虚拟函数调用。如果仅存在该方法的一个实现,则从vtable加载其地址毫无意义,只需直接调用一个实现即可。为此,JIT甚至可以内联功能。
JIT方面有些赌博,因为如果以后加载子类,则JIT将不得不丢弃该机器代码并再次编译该代码,从而发出真实的虚拟调用。我的猜测是,这在实践中并不经常发生。
(是的,VM设计人员确实确实在积极追求这些微小的性能优势。)
<题外话>
我讨厌密封班。即使性能优势惊人(我对此表示怀疑),它们也会通过防止通过继承进行重用而破坏面向对象的模型。例如,Thread类是密封的。虽然我可以看到人们可能希望线程尽可能高效,但我也可以想象能够将Thread子类化的巨大好处。
类作者,如果您出于"性能"的原因必须密封类,请至少提供一个接口,这样我们就不必在所有需要忘记的功能的地方进行包装和替换。
示例:SafeThread必须包装Thread类,因为Thread是密封的,并且没有IThread接口。 SafeThread会自动捕获线程上未处理的异常,这些异常在Thread类中完全丢失。 [并且不,未处理的异常事件不会在辅助线程中拾取未处理的异常]。
off-topic-rant>
我认为"密封"类是正常情况,我总是有理由省略"密封"关键字。
对我来说,最重要的原因是:
a)更好的编译时检查(在编译时,不仅在运行时,还将检测到未实现的接口的广播)
并且,首要原因:
b)这样不可能滥用我的课程
我希望微软能够使"密封"成为标准,而不是"未密封"。
密封的类应可提高性能。由于无法派生密封类,因此任何虚拟成员都可以变成非虚拟成员。
当然,我们说的是很小的收获。除非剖析表明存在问题,否则我不会将一个类标记为仅密封以提高性能。
如果JIT Optimizer可以内联调用,否则密封类将至少快一点,但是有时可以更快一些。因此,在通常被称为内联的方法足够小的地方,绝对可以考虑密封该类。
但是,密封类的最好理由是说"我没有将其设计为从继承而来的,因此,我不会通过假定它是这样设计而让您感到厌倦的,因此,我不会通过锁定到一个实现中来烧自己,因为我让您从中获取它。"
我知道这里有些人说他们讨厌密封类,因为他们希望有机会从任何东西中派生……但这通常不是最可维护的选择……因为将类暴露给派生不仅将您暴露在外,还会使您陷入困境那。这类似于说"我讨厌有私人成员的课程……我经常无法使课程做我想要的事情,因为我没有访问权限。"封装很重要...密封是封装的一种形式。
@Vaibhav,您执行了哪种测试来衡量性能?
我猜想人们将不得不使用Rotor并深入CLI,并了解密封类如何提高性能。
SSCLI (Rotor)
SSCLI: Shared Source Common Language Infrastructure
The Common Language Infrastructure
(CLI) is the ECMA standard that
describes the core of the .NET
Framework. The Shared Source CLI
(SSCLI), also known as Rotor, is a
compressed archive of the source code
to a working implementation of the
ECMA CLI and the ECMA C# language
specification, technologies at the
heart of Microsoft’s .NET
architecture.
运行此代码,您将看到密封类快2倍:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| 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