JIT 优化






4.96/5 (69投票s)
在本文中,我们将深入探讨 JIT 优化,并重点关注内联。
引言
许多人认为 .NET 即时 (JIT) 编译器是 CLR 相较于 JVM 和其他使用即时编译字节码的托管环境的主要性能优势之一。正是这种优势使得 JIT 的内部机制如此神秘,JIT 优化如此难以捉摸。这也是为什么 SSCLI (Rotor) 只包含一个简陋的快速 JIT 实现,它在将每个 IL 指令转换为相应的机器语言指令序列时只执行最少的优化。
在我为 Sela 编写的 .NET 性能课程的研究过程中,我找到了一些关于已知 JIT 优化的方法。以下是这些优化的一个不完全列表,随后是对接口方法分派和 JIT 内联技术应用的详细讨论。
在开始之前,有几点值得一提。首先,很明显,要看到 JIT 优化,您必须查看 JIT 在运行时为应用程序的发布版本生成的汇编代码。但是,有一个小小的注意点:如果您尝试在 Visual Studio 调试会话中查看汇编代码,您将看不到优化后的代码。这是因为当进程在调试器下启动时,JIT 优化默认是禁用的(出于便利性考虑)。因此,要查看 JIT 优化后的代码,您必须将 Visual Studio 附加到正在运行的进程,或者在 CorDbg 中使用 JitOptimizations
标志运行进程(通过在 CorDbg 命令提示符中输入“mode JitOptimizations 1”命令)。最后,在“注意事项”部分,请记住我不在微软工作,也无法访问 JIT 编译器的实际源代码。因此,以下分析中的任何内容都不应视为理所当然。
另一重要的一点是,本文大部分内容基于 .NET 2.0 CLR 附带的 x86 JIT,尽管我们也会关注 x64 JIT 的行为。
范围检查消除
当在循环中访问数组时,并且循环的终止条件依赖于数组的长度,则可以消除数组边界访问检查。请考虑以下代码。您看出有什么问题吗?
private static int[] _array = new int[10];
private static volatile int _i;
static void Main(string[] args)
{
for (int i = 0; i < _array.Length; ++i)
_i += _array[i];
}
这是生成的 32 位代码
00BE00A5 xor edx,edx ; edx = 0 (i)
00BE00A7 mov eax,dword ptr ds:[02491EC4h] ; eax = numbers
00BE00AC cmp edx,dword ptr [eax+4] ; edx >= Length?
00BE00AF jae 00BE00C2 ; exception!
00BE00B1 mov eax,dword ptr [eax+edx*4+8] ; eax = numbers[i]
00BE00B5 add dword ptr ds:[0A22FC8h],eax ; _i += eax
00BE00BB inc edx ; ++edx (i)
00BE00BC cmp edx,0Ah ; edx < 100?
00BE00BF jl 00BE00A7
第一行是序言,中间的几行是循环体,最后三行是递增计数器并测试循环终止条件的代码。
上面列表中的第三行 (00BE00AC) 执行范围检查——它确保用于索引数组的 EDX
寄存器不大于或等于 [EAX + 4]
处的数组长度(EAX
包含数组地址,该地址在距其起始位置 4 字节偏移处存储数组长度)。此外,在列表的倒数第二行 (00BE00BC) 执行了循环终止检查。
那么,范围检查消除在哪里呢?这种行为的原因是数组引用本身在这种情况下是静态的。处理静态引用会导致生成上述代码(另请注意,在 00BE00A7 中,我们实际上会在每次循环迭代时将数组引用获取到寄存器中)。通过对上述程序进行非常简单的修改,可以消除这种行为。
private static int[] _array = new int[10];
private static volatile int _i;
static void Main(string[] args)
{
int[] localRef = _array;
for (int i = 0; i < localRef.Length; ++i)
{
_i += localRef[i];
}
}
这是生成的 32 位代码
009D00D2 mov ecx,dword ptr ds:[1C21EC4h] ; ecx = numbers
009D00D8 xor edx,edx ; edx = 0 (i)
009D00DA mov esi,dword ptr [ecx+4] ; esi = numbers.Length
009D00DD test esi,esi ; esi == 0?
009D00DF jle 009D00F0
009D00E1 mov eax,dword ptr [ecx+edx*4+8] ; loop proper
009D00E5 add dword ptr ds:[922FC8h],eax ; _i += numbers[i]
009D00EB inc edx ; ++edx (i)
009D00EC cmp esi,edx ; edx > numbers.Length?
009D00EE jg 009D00E1
请注意,范围检查已被消除,现在循环终止条件是此代码中执行的唯一测试。注意:此讨论反映了 Greg Young 在此主题上的帖子。
还值得注意的是,很容易通过使用 Array.Length
以外的任何内容作为循环终止条件来破坏此优化。例如,以下功能上相同的代码将生成数组边界检查并破坏优化。
for (int i = 0; i < localRef.Length * 2; ++i)
{
_i += localRef[i / 2];
}
方法内联
一个简短且常用的简单方法可以内联到调用代码中。目前,JIT 被记录(也许“写博客”更贴切)为内联长度小于 32 字节、不包含任何复杂分支逻辑且不包含任何异常处理机制的方法。有关此主题的一些附加信息,请参阅 David Notario 的博客(请注意,这与 CLR 2.0 完全无关)。
鉴于以下代码片段,让我们检查一下 JIT 不优化和内联方法调用时会发生什么,以及当它进行优化和内联时会发生什么。
public class Util
{
public static int And(int first, int second)
{
return first & second;
}
[DllImport("kernel32")]
public static extern void DebugBreak();
}
class Program
{
private static volatile int _i;
static void Main(string[] args)
{
Util.DebugBreak();
_i = Util.And(5, 4);
}
}
注意这里使用了 kernel32.dll 的 DebugBreak
函数(它内部会发出软件中断 int 3
)。我在这里使用它,以便在调用此方法时 Windows 会给我一个调试进程的机会,这样我就不必从 Visual Studio 或任何其他首选调试器手动附加它。最后,请注意,我将 _i
字段设为 volatile,以防止对它的赋值被优化掉。
当 JIT 优化禁用时(例如,当进程在 Visual Studio 调试会话中启动时),在方法调用站点发出的代码如下所示
00000013 mov edx,4
00000018 mov ecx,5
0000001d call dword ptr ds:[00A2967Ch] ; this is Util.And
00000023 mov esi,eax
00000025 mov dword ptr ds:[00A28A6Ch],esi ; this is _i
如果我们继续进入 00A2967C 处的代码,我们会找到方法本身
00000000 push edi
00000001 push esi
00000002 mov edi,ecx
00000004 mov esi,edx
00000006 cmp dword ptr ds:[00A28864h],0
0000000d je 00000014
0000000f call 794F1116
00000014 mov eax,edi
00000016 and eax,esi
00000018 pop esi
00000019 pop edi
0000001a ret
请注意,这里没有优化或内联:参数按照 fastcall 调用约定通过 EDX
和 ECX
寄存器传递给方法,偏移量 0x16 处的 AND
指令执行方法本身的实际功能。
现在,让我们看看内联调用,这是在我附加到进程并在其中发出调试断点后生成的。这次,方法调用站点看起来是这样的。
00B90075 mov dword ptr ds:[0A22FD0h],4 ; _i = 4
00B9007F ret
AND(5, 4)
的结果是 4,这是直接写入 volatile 成员字段的值。请注意,优化非常激进,以至于 AND
操作甚至没有发生——它可以在编译时直接计算(折叠)。
但是,似乎不可能内联 **虚拟** 方法调用。这当然是因为要调用的实际方法在编译时是未知的,并且可以在方法调用之间发生变化。例如,考虑以下代码。
class A
{
public virtual void Foo() { }
}
class B : A
{
public override void Foo() { }
}
class Program
{
static void Method(A a)
{
a.Foo();
}
static void Main(string[] args)
{
for (int i = 0; i < 10; ++i)
{
A a = (i % 2 == 0) ? new A() : new B();
Method(a);
}
}
}
当 JIT 编译 Method
方法时,它无法知道应该调用两个实现中的哪一个——A.Foo
还是 B.Foo
。因此,在调用站点内联调用似乎是不可能的。(请注意我反复使用“似乎”——理论上可以执行部分优化,在某些选定情况下引入内联,我在后面讨论接口方法分派时会深入探讨。)
因此,虚拟调用必须通过实际对象的成员表进行。(如果您需要一些回顾,请简要浏览 此 MSDN 杂志文章。)通过成员表涉及两个级别的间接寻址:使用对象头访问成员表,然后使用编译时已知的偏移量访问成员表以确定要调用的方法。
在前面的示例中,将在调用站点(在 Method
方法中)发出以下代码。
007E0076 xor esi,esi ; esi = 0 (i)
007E0078 jmp 007E00AA ; jump to compare condition
007E007A mov eax,esi ; i % 2 == 0?
007E007C and eax,80000001h
007E0081 jns 007E0088
007E0083 dec eax
007E0084 or eax,0FFFFFFFEh
007E0087 inc eax
007E0088 test eax,eax
007E008A je 007E0098
007E008C mov ecx,2B3180h
007E0091 call 002A201C
007E0096 jmp 007E00A2
007E0098 mov ecx,2B3100h
007E009D call 002A201C
007E00A2 mov ecx,eax ; ecx = a
007E00A4 mov eax,dword ptr [ecx] ; eax = a's method table
007E00A6 call dword ptr [eax+38h] ; call through offset 0x38
007E00A9 inc esi
007E00AA cmp esi,0Ah
007E00AD jl 007E007A
007E00AF pop esi
007E00B0 ret
如上所述,虚拟调用分两步进行:首先,访问对象的成员表(007E00A4 mov eax, dword ptr [ecx]
);然后,在编译时已知的偏移量处调用成员指针(007EE00A6 call dword ptr [eax+38h]
)。
请注意,理论上,C# 的 **sealed
** 关键字(及其相应的 IL 对应项 final
)旨在通过指示尽管方法是虚拟的,但不能再被任何派生类覆盖来最小化虚拟方法分派的低效率。例如,以下代码不必涉及我们刚才看到的虚拟方法分派。
class A
{
public virtual void Foo() { }
}
class B : A
{
public override sealed void Foo() { }
}
class C : B
{
}
class Program
{
static void Method(B b)
{
b.Foo();
}
static void Main(string[] args)
{
for (int i = 0; i < 10; ++i)
{
B b = (i % 2 == 0) ? new B() : new C();
Method(b);
}
}
}
显而易见,在 Method
方法中,调用目标 b.Foo
是静态已知的:即将调用的是 B.Foo
。然而,**JIT 没有选择使用此信息来阻止虚拟方法分派**——它仍然会生成,正如我们从以下汇编代码中可以看到的(这次,我删除了对象设置部分)。
005000A2 mov ecx,eax
005000A4 mov eax,dword ptr [ecx]
005000A6 call dword ptr [eax+38h]
请注意,在类本身上使用 sealed
关键字也没有效果,即使调用目标是静态已知的。
为了完全理解后台发生的事情,值得了解 IL 有两个用于分派方法调用的指令:**call
** 和 **callvirt
**。使用 callvirt
指令进行实例方法调用的主要原因之一是,JIT 生成的代码包含一个检查,确保实例不是 null
,否则会抛出 NullReferenceException
。这就是为什么 C# 编译器即使在调用非虚拟实例方法时也会发出 callvirt
IL 指令。如果不是这样,以下代码就可以成功编译和运行。
class A
{
public void Foo() { } // Foo doesn't use "this"
}
class Program
{
static void Main(string[] args)
{
for (int i = 0; i < 10; ++i)
{
A a = (i % 2 == 0) ? new A() : null;
a.Foo();
}
}
}
这是为此场景生成的 IL(注意 L_0013 处的 callvirt
指令)。
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 2
.locals init (
[0] int32 num1,
[1] CallAndCallVirt.A a1)
L_0000: ldc.i4.0
L_0001: stloc.0
L_0002: br.s L_001c
L_0004: ldloc.0
L_0005: ldc.i4.2
L_0006: rem
L_0007: brfalse.s L_000c
L_0009: ldnull
L_000a: br.s L_0011
L_000c: newobj instance void CallAndCallVirt.A::.ctor()
L_0011: stloc.1
L_0012: ldloc.1
L_0013: callvirt instance void CallAndCallVirt.A::Foo()
L_0018: ldloc.0
L_0019: ldc.i4.1
L_001a: add
L_001b: stloc.0
L_001c: ldloc.0
L_001d: ldc.i4.s 10
L_001f: blt.s L_0004
L_0021: ret
}
这是为此场景生成的汇编。
00AD0076 xor esi,esi
00AD0078 jmp 00AD009F
00AD007A xor edx,edx ; represents the "a" local variable
00AD007C mov eax,esi
00AD007E and eax,80000001h
00AD0083 jns 00AD008A
00AD0085 dec eax
00AD0086 or eax,0FFFFFFFEh
00AD0089 inc eax
00AD008A test eax,eax
00AD008C jne 00AD009A
00AD008E mov ecx,0A230E0h
00AD0093 call 00A1201C ; constructor call (in the branch)
00AD0098 mov edx,eax
00AD009A mov eax,edx
00AD009C cmp dword ptr [eax],eax ; null reference check
00AD009E inc esi ; move on, the method isn't really called
00AD009F cmp esi,0Ah
00AD00A2 jl 00AD007A
00AD00A4 pop esi
00AD00A5 ret
那么,JIT 能内联这样的方法吗?是的!请注意 JIT 如何完全消除了调用本身(方法是空的,因此内联它意味着简单地将其优化掉),但它无法消除空引用检查,因为那是 callvirt
关键字的意图。因此,00AD009C 处的指令通过尝试取消引用 EAX
(包含“a
”局部变量的值)执行一个简单的空引用检查。如果发生访问冲突,它将被捕获,并改为抛出 NullReferenceException
异常。
如果 L_0003 中的 IL 使用 call
指令而不是 callvirt
指令,生成的汇编代码将不会嵌入空引用检查,并且上述代码可以成功运行。这并不是 C# 语言开发者所采取的行为。
为求完整性,值得注意的是,有时调用虚拟方法会涉及 call
指令而不是 callvirt
指令。例如,在以下代码片段中会发生这种情况。
class Employee
{
public override string ToString()
{
return base.ToString();
}
}
ToString
方法编译为 IL。
.method public hidebysig virtual instance string ToString() cil managed
{
.maxstack 8
L_0000: ldarg.0
L_0001: call instance string object::ToString()
L_0006: ret
}
如果此处生成的指令是 callvirt
指令,Employee.ToString
将会无限递归调用自身。明确我们的目的是忽略正常的虚拟方法分派机制,并将实现委托给基类的方法。因此,对 Object.ToString
的调用不会用 callvirt
指令发出,而是用 call
指令发出。
接口方法分派本质上与虚拟方法分派没有太大区别。它之所以没有太大区别,是因为必须添加一个间接层,因此实际要调用的实现不能在调用站点确定。例如,考虑以下代码。
interface IA
{
void Foo();
}
class A : IA
{
public void Foo()
{
}
}
class B : IA
{
void IA.Foo()
{
}
}
class Program
{
[MethodImpl(MethodImplOptions.NoInlining)]
static void Method(IA a)
{
a.Foo();
}
static void Main(string[] args)
{
for (int i = 0; i < 10; ++i)
{
IA a = (i % 2 == 0) ? (IA)new A() : (IA)new B();
Method(a);
}
}
}
在这种情况下,简单的分派技术将如下所示。
- 访问实际对象的方法表。
- 访问用于对象的方法表中的接口方法表映射,使用进程范围的接口 ID。
- 访问编译时已知偏移量的接口方法表中的方法,并执行它。
因此,为实际接口方法调用站点(在 Method
方法内部)生成的近似汇编代码应该如下所示。
mov ecx, edi ; ecx holds "this"
mov eax, dword ptr [ecx] ; eax holds method table
mov eax, dword ptr [eax+0Ch] ; eax holds interface method table
mov eax, dword ptr [eax+30h] ; eax holds method pointer
call dword ptr [eax]
这在 此 MSDN 杂志文章 和其他来源中有描述,但在现实生活中并非如此。即使是接口分派的调试(非优化)版本看起来也不是这样的。我将检查代码实际的样子,但现在,仅说朴素的方法可能性能很差,因此被替换为其他东西就足够了。
顺便说一句,请注意接口方法总是被隐式标记为 virtual
(请考虑 A
和 B
类的以下 IL)。
.class private auto ansi beforefieldinit A extends object implements NaiveMethodDispatch.IA
.method public hidebysig newslot virtual final instance void Foo() cil managed
.class private auto ansi beforefieldinit B extends object implements NaiveMethodDispatch.IA
.method private hidebysig newslot virtual final instance void
NaiveMethodDispatch.IA.Foo() cil managed
.override NaiveMethodDispatch.IA::Foo
如果接口实现是显式的,或者您在隐式实现接口时没有指定 virtual
关键字,编译器还会在方法上发出 final
关键字(等同于 C# 中的 sealed
关键字)。这意味着接口方法除非在基类代码中显式标记为 virtual
,否则不能被覆盖;但是,它们仍然是虚拟的,因为必须使用 callvirt
指令来调用它们,并且需要进行成员表查找以便正确分派它们。
但是,如果一切都像到目前为止那样简单,我甚至不会考虑写这篇文章。这篇文章的触发点实际上是 Ayende 关于一个大致相关主题的帖子,其中他提到 JIT 能够内联接口方法调用。一次温和的理论电子邮件讨论产生了一些初步研究,我在下面介绍。
在深入之前,以下讨论的结论可以概括为:理论上不可能完美内联虚拟方法调用(接口方法调用也属于此类);JIT 不内联接口方法调用;相反,JIT 执行了一种优化,该优化不使用上面概述的朴素接口方法分派。
在我们深入了解实际 CLR 2.0 JIT 在接口方法调用站点上执行的操作之前,最好考虑一下可以对这些调用理论上执行的任何优化。最明显的是以下两种。
流分析
流分析可以确定在接口方法调用中使用的是特定的静态类型,因此可以直接通过该类型进行分派,而不是使用上述技术。
IA a = new A();
a.Foo();
可以清楚地看到,无论发生什么,在此调用站点都调用的是 A.Foo
。托管编译器的流分析可以检测到这种情况并发出相应的字节码,如果它符合其他内联要求(参见上文),JIT 就可以内联该方法。
频率分析
在给定的调用站点,一个接口实现比其他实现调用得更频繁。这可以通过动态分析和修补代码,或者通过程序员的某种提示来确定。(有关该主题的一般信息,特别是其 JVM 实现,请参阅 此 和 Overview of the IBM Java Just-in-Time Compiler。)在这种情况下,可以修改该特定接口的接口方法分派以进行直接分派,甚至可以内联,如下所示。
if (obj->MethodTable == expectedMethodTable) {
// Inlined code of "hot" method
}
else {
// Normal interface method dispatch code
}
JIT 的方法
JIT 并未记录(或“写博客”)执行这两种优化中的任何一种。然而,通过探查 JIT 生成的代码会发现,情况并不像看起来那么简单。
让我们看看以下代码的调用站点,并尝试分析方法分派过程中的情况。我为接口和方法赋予了有意义的名称和内容,以便我们可以更轻松地研究示例。
interface IOperation
{
int Do(int a, int b);
}
class And : IOperation
{
public int Do(int a, int b) { return a & b; }
}
class Or : IOperation
{
public int Do(int a, int b) { return a | b; }
}
class Program
{
static volatile int _i;
static void Main()
{
for (int i = 0; i < 10; ++i)
{
IOperation op = (i % 2 == 0) ?
(IOperation)new And() : (IOperation)new Or();
_i = op.Do(5, 3);
}
}
}
在这种情况下,我们只有一个 IOperation.Do
的调用站点,JIT 无法静态确定将调用哪个实现。因此,直接内联或直接方法分派是不可能的。那么,在这种情况下生成的是什么代码呢?
让我们先看看调用站点本身,即 Main
入口点。当调用入口点时,它会被编译,因此我们应该能够立即查看代码。以下是循环中 IOperation.Do
实际调用的代码。
007E00A4 push 3
007E00A6 mov edx,5 ; setup parameters
007E00AB call dword ptr ds:[2C0010h] ; the actual call
007E00B1 mov dword ptr ds:[002B2FE8h],eax ; save return value
请注意,这不是我们在此处看到的接口方法分派模式。取而代之的是,我们通过地址 002C0010 进行间接调用。您应该记住这个地址,因为我们将在后续讨论中提到它。进一步深入,我们看到。
002C6012 push eax
002C6013 push 30000h
002C6018 jmp 79EE9E4F
实际实现尚未编译,因此我们发现自己正在跟踪 JIT 编译器的代码。最终,我们被重定向(通过一系列复杂的跳转)到实际接口方法的代码。从那里发出 ret
指令后,我们返回到主循环(其中 call
指令在 007E00AB 处发出)。
但是,在循环运行三次之后,002C0010 指向的代码(回想一下,这是我们原始调用站点通过的地方)将被回填为优化版本。这个优化版本如下。
002D7012 cmp dword ptr [ecx],2C3210h
002D7018 jne 002DA011
002D701E jmp 007E00D0
回想一下,.NET 对象头以对象的成员表开头,而我们在这里看到的是简单的性能分析优化:如果成员表是预期的(即,“常用”或“热”实现),我们可以直接跳转到该代码。(请注意,它的实际地址嵌入在指令中,因为 JIT 已回填此代码,并完全了解方法的地址。这意味着不需要额外的内存访问即可分派此调用。)否则,我们必须通过通常的分派层,我们稍后会看到。为求完整性,这里是 007E00D0 处的代码(它是 CMP
指令的“预期”结果)。
007E00D0 and edx,dword ptr [esp+4]
007E00D4 mov eax,edx
007E00D6 ret 4
这只是 And.Do
的实现。请注意,JIT 编译的代码既没有内联到调用站点,也没有内联到分派辅助函数。但是,直接跳转到此代码应该开销最小。剩下的问题是:002DA011 处是什么,换句话说,如果成员表不是预期的怎么办?这次,代码要复杂得多。
002DA011 sub dword ptr ds:[14D3D0h],1
002DA018 jl 002DA056
002DA01A push eax
002DA01B mov eax,dword ptr [ecx]
002DA01D push edx
002DA01E mov edx,eax
002DA020 shr eax,0Ch
002DA023 add eax,edx
002DA025 xor eax,3984h
002DA02A and eax,3FFCh
002DA02F mov eax,dword ptr [eax+151A6Ch]
002DA035 cmp edx,dword ptr [eax]
002DA037 jne 002DA04B
002DA039 cmp dword ptr [eax+4],30000h
002DA040 jne 002DA04B
002DA042 mov eax,dword ptr [eax+8]
002DA045 pop edx
002DA046 add esp,4
002DA049 jmp eax
002DA04B pop edx
002DA04C push 30000h
002DA051 jmp 79EED9A8
002DA056 call 79F02065
002DA05B jmp 002DA01A
我标记了最重要的一些部分,以便我们专注于它们。首先,一个全局变量被减一。其初始值为 0x64(即 100)。我们稍后会看到它的用途。如果结果小于 0,我们会跳转到调用一个 JIT 回填函数,并继续执行。这个流程中有什么?JIT 执行的正常接口方法分派。请注意,最终,在 002DA049 处会有一个 JMP EAX
指令,它实际上会让我们到达所需代码。
007E00F0 or edx,dword ptr [esp+4]
007E00F4 mov eax,edx
007E00F6 ret 4
这显然是 Or.Do
的实现。好吧,一切似乎都很顺利。那么我们刚才看到的全局变量的目的是什么呢?考虑我们之前讨论过的优化,“热”路径被内联(或者至少直接跳转)到如果实际对象的成员表与“热”实现的成员表匹配。在运行时,通过每个实现的调用频率可能会动态变化。例如,用户在前 500 次调用 And.Do
,但随后 5000 次调用 Or.Do
。这使得我们的优化看起来有点愚蠢,因为我们实际上是为最不常见的情况进行了优化。为了防止这种情况,为每个调用站点建立一个计数器。当“未命中”时,即当实际对象的成员表与预期方法的成员表不匹配时,计数器就会递减。当计数器降至 0 以下时,JIT 会再次回填 002C0010 指向的代码,将其替换为以下版本。
0046A01A push eax
0046A01B mov eax,dword ptr [ecx]
0046A01D push edx
0046A01E mov edx,eax
0046A020 shr eax,0Ch
0046A023 add eax,edx
0046A025 xor eax,3984h
0046A02A and eax,3FFCh
0046A02F mov eax,dword ptr [eax+151A74h]
0046A035 cmp edx,dword ptr [eax]
0046A037 jne 0046A04B
0046A039 cmp dword ptr [eax+4],30000h
0046A040 jne 0046A04B
0046A042 mov eax,dword ptr [eax+8]
0046A045 pop edx
0046A046 add esp,4
0046A049 jmp eax
0046A04B pop edx
0046A04C push 30000h
0046A051 jmp 79EED9A8
0046A056 call 79F02065
0046A05B jmp 0046A01A
同样,我强调了重要部分。不深入细节,这段代码的目的是检查当前对象的类型是否与上一个对象的类型匹配(请注意,与之前的代码片段不同,这里的检查不是与文字常量地址进行比较——相反,它是使用对象指针本身在此计算的)。如果匹配,则计算跳转到的位置,然后执行 JMP EAX
(在 0046A049 处)。如果不匹配,将再次调用 JIT 的回填代码,然后重复该过程。
请注意,这段代码的效率不如计数器降至 0 以下的状态。那时,我们有一个直接跳转到文字常量地址。现在,我们有一个基于对象指针本身的跳转地址的计算。此外,这次没有计数器——每次未命中时,这段代码的含义都会发生变化。用伪代码总结,这看起来大致如下。
start: if (obj->Type == expectedType) {
// Jump to the expected implementation
}
else {
expectedType = obj->Type;
goto start;
}
这是我们在整个程序过程中获得的最终行为。这意味着**每个调用站点**都有一个一次性的计数器(初始化为 100),用于计算“热”实现未命中的次数。当计数器降为负数后,会触发 JIT 回填,代码会被替换为我们刚才看到的版本,该版本用**每次**未命中来交替“热”实现。
请注意,对于每个尝试分派接口调用的调用站点,都会生成 00C20010 存根。这意味着计数器数据和相关代码是每个调用站点的优化,在某些情况下可能非常有价值。
通过编写一个执行接口方法分派的测试程序,测试上述假设相对容易。一种模式是程序在一个循环中调用第一个实现,然后在另一个循环中调用第二个实现;另一种模式是程序在每次调用第一个实现时都穿插调用第二个实现。鉴于最后一个分派代码的行为,我们可以合理地预期第一种测试用例的性能会优于第二种。使用以下程序对此进行了测试。
interface IOperation
{
int Do(int a, int b);
}
class And : IOperation
{
public int Do(int a, int b) { return a & b; }
}
class Or : IOperation
{
public int Do(int a, int b) { return a | b; }
}
class Program
{
[MethodImpl(MethodImplOptions.NoInlining)]
static void Method(IOperation op)
{
_i = op.Do(5, 3);
}
static readonly int NUM_ITERS = 100000000;
static readonly int HALF_ITERS = NUM_ITERS / 2;
static volatile int _i;
static void Main()
{
IOperation and = new And();
IOperation or = new Or();
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < HALF_ITERS; ++i)
{
Method(and);
Method(and);
}
for (int i = 0; i < HALF_ITERS; ++i)
{
Method(or);
Method(or);
}
Console.WriteLine("Sequential: {0} ms", sw.ElapsedMilliseconds);
sw.Reset();
sw.Start();
for (int i = 0; i < HALF_ITERS; ++i)
{
Method(and);
Method(or);
}
for (int i = 0; i < HALF_ITERS; ++i)
{
Method(and);
Method(or);
}
Console.WriteLine("Interleaved: {0} ms", sw.ElapsedMilliseconds);
}
}
在我的笔记本电脑上,顺序情况的平均测试结果为 2775 毫秒,而循环(交错)情况的平均测试结果为 2960 毫秒。差异是持续的,但显然不是很明显。因此,我目前得出结论,这两种使用模式对程序的性能影响很小(甚至没有),特别是如果方法比单个 x86 指令更“块状”。
其他琐事
为了完整起见,64 位 JIT 为 **sealed** 分派场景生成与 32 位相同的代码。这显然是一个设计决定。如果您对 64 位虚拟方法分派的样子非常感兴趣,那么它就在这里。
00000642`8015047d 488b03 mov rax, qword ptr [rbx]
00000642`8015048b 488bcb mov rcx, rbx
00000642`8015048e ff5060 call qword ptr [rax+60h]
所以,我们再次通过成员表进行调用,即使静态类型和动态类型都是预先已知的。(RBX
包含参数值,RCX
被设置为相同,因为它必须包含 this
,然后调用是通过 RAX+60h
进行的。)
历史
- 版本 1 - 2008 年 5 月。