密封类真的能提供性能优势吗?
-
08-06-2019 - |
题
我遇到过很多优化技巧,这些技巧表明您应该将类标记为密封以获得额外的性能优势。
我进行了一些测试来检查性能差异,但没有发现任何差异。难道我做错了什么?我是否错过了密封课程会产生更好结果的情况?
有人进行测试并发现差异吗?
帮助我学习:)
解决方案
JITter 有时会使用对密封类中方法的非虚拟调用,因为它们无法进一步扩展。
关于调用类型、虚拟/非虚拟有复杂的规则,我不知道它们全部,所以我无法真正为您概述它们,但如果您搜索密封类和虚拟方法,您可能会找到一些有关该主题的文章。
请注意,从这一级别的优化中获得的任何类型的性能优势都应被视为最后的手段,在代码级别优化之前始终在算法级别进行优化。
这是一个提到这一点的链接: 漫谈密封的关键字
其他提示
答案是否定的,密封类的性能并不比非密封类更好。
问题归结为 call
与 callvirt
IL 操作码。 Call
比 callvirt
, , 和 callvirt
主要用于不知道对象是否已被子类化的情况。所以人们假设如果你密封一个类,所有操作码都会改变 calvirts
到 calls
并且会更快。
很遗憾 callvirt
还可以做其他一些使它有用的事情,比如检查空引用。这意味着即使一个类被密封,引用可能仍然为空,因此 callvirt
是需要的。您可以解决这个问题(无需密封班级),但这变得有点毫无意义。
结构使用 call
因为它们不能被子类化并且永远不为空。
请参阅此问题以获取更多信息:
更新:从 .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)都会发出相同的 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
对我来说,这提供了确凿的证据: 不能 密封类与非密封类上的调用方法之间是否有任何性能改进......我想我现在很幸福:-)
据我所知,无法保证性能优势。但是还有 在某些特定条件下有机会减少性能损失 采用密封法。(密封类使所有方法都被密封。)
但这取决于编译器实现和执行环境。
细节
许多现代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
.
这个答案(为什么处理排序数组比处理未排序数组更快?)更好地描述了分支预测。
标记班级 sealed
应该不会对性能产生影响。
有些情况下 csc
可能需要发出一个 callvirt
操作码而不是 call
操作码。然而,这些案例似乎很少见。
在我看来,JIT 应该能够发出相同的非虚拟函数调用 callvirt
它会为了 call
, ,如果它知道该类还没有任何子类。如果该方法只存在一种实现,则没有必要从 vtable 加载其地址,只需直接调用该方法的一种实现即可。就此而言,JIT 甚至可以内联该函数。
对于 JIT 来说,这有点赌博,因为如果子类 是 稍后加载,JIT 将不得不丢弃该机器代码并再次编译该代码,发出真正的虚拟调用。我的猜测是,这种情况在实践中并不经常发生。
(是的,VM 设计者确实积极追求这些微小的性能提升。)
密封课程 应该 提供性能改进。由于无法派生密封类,因此任何虚拟成员都可以变成非虚拟成员。
当然,我们谈论的是非常小的收益。我不会仅仅为了获得性能改进而将类标记为密封,除非分析表明它是一个问题。
<离题咆哮>
我 厌恶 密封类。即使性能优势令人震惊(我对此表示怀疑),它们 破坏 通过继承防止重用的面向对象模型。例如,Thread类是密封的。虽然我可以看到人们可能希望线程尽可能高效,但我也可以想象能够子类化 Thread 会带来巨大好处的场景。班级作者,如果您 必须 出于“表现”原因密封你的课程, 请提供一个接口 至少这样我们就不必在需要您忘记的功能的地方进行包装和替换。
例子: 安全线程 必须包装Thread类,因为Thread是密封的并且没有IThread接口;SafeThread 自动捕获线程上未处理的异常,这是 Thread 类中完全缺少的东西。[不,未处理的异常事件确实 不是 在辅助线程中拾取未处理的异常]。
</离题咆哮>
我认为“密封”类是正常情况,并且我总是有理由省略“密封”关键字。
对我来说最重要的原因是:
a) 更好的编译时检查(对未实现的接口的强制转换将在编译时检测到,而不仅仅是在运行时检测)
并且,首要原因:
b) 这样就不可能滥用我的课程
我希望微软能够制定“密封”标准,而不是“未密封”。
@Vaibhav,您执行了什么样的测试来衡量性能?
我想人们必须使用 转子 并深入研究 CLI 并了解密封类如何提高性能。
SSCLI(转子)
SSCLI:共享源通用语言基础设施通用语言基础架构(CLI)是描述.NET框架核心的ECMA标准。共享源CLI(SSCLI),也称为转子,是源代码的压缩档案,用于ECMA CLI的工作实现和ECMA C#语言规范,即Microsoft .NET架构的核心。
密封类至少会快一点,但有时可能会更快......如果 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.3826678