65.9K
CodeProject 正在变化。 阅读更多。
Home

JIT 优化

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (69投票s)

2008年5月4日

CPOL

19分钟阅读

viewsIcon

100794

在本文中,我们将深入探讨 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.dllDebugBreak 函数(它内部会发出软件中断 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 调用约定通过 EDXECX 寄存器传递给方法,偏移量 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(请考虑 AB 类的以下 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 月。
© . All rights reserved.