JIT-оптимизации

ОГЛАВЛЕНИЕ

Компилятор .NET Just-In-Time Compiler (JIT) считается многими одним из основных преимуществ производительности CLR по сравнению с JVM и другими управляемыми средами, которые используют двоичный код, скомпилированный компилятором JIT. 

Данное преимущество делает свойства JIT столь секретными и JIT-оптимизации настолько мистическими. Это также является причиной того, почему SSCLI (Rotor) содержит всего лишь наивную реализацию быстрого JIT, которая производит только минимальные оптимизации при трансляции каждой команды IL в ее соответствующую последовательность команд машинного кода.

Далее последует краткий список JIT-оптимизаций, а за ним детальное пояснение диспетчеризации интерфейса метода и использования техник встраивания JIT.

До того, как мы начнем, следует упомянуть кое о чем. Во-первых, очень важно, что для того, чтобы увидеть JIT-оптимизации, вам стоит изучить машинный код, выработанный JIT-компилятором во время выполнения сборки вашего приложения. Тем не менее, есть небольшое предостережение: если вы решите изучить машинный код в среде отладчика Visual Studio, вы не увидите оптимизированный код. Это потому, что JIT-оптимизации по умолчанию отключены тогда, когда процесс запущен из отладчика (для удобства). Поэтому для того, чтобы увидеть JIT-оптимизированный код, вам стоит подсоединить Visual Studio к запущенному процессу, или запустить процесс из CorDbg, установив при этом флаг JitOptimizations (выполнив команду "mode JitOptimizations 1" из командной строки CorDbg). Наконец, поскольку у нас нет доступа к реальным исходникам JIT-компилятора, вам не стоит воспринимать данную информацию как нечто само собой разумеющееся.

Также стоит отметить то, что большая часть данной статьи основана на x86 JIT , который поставляется с .NET 2.0 CLR, хотя мы также изучим поведение x64 JIT.

Отключение проверки диапазона Range-check elimination

Так как при осуществлении доступа к массиву посредством цикла условие завершения цикла зависит от длины массива, то проверка на границы массива может быть удалена. Изучите следующий код. В порядке ли он?

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 = числа
00BE00AC  cmp         edx,dword ptr [eax+4]            ; edx >= длинна?
00BE00AF  jae         00BE00C2                         ; исключение!
00BE00B1  mov         eax,dword ptr [eax+edx*4+8]      ; eax = числа[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 = числа
009D00D8  xor         edx,edx                          ; edx = 0 (i)
009D00DA  mov         esi,dword ptr [ecx+4]            ; esi = числа.длинна
009D00DD  test        esi,esi                          ; esi == 0?
009D00DF  jle         009D00F0
009D00E1  mov         eax,dword ptr [ecx+edx*4+8]      ; цикл
009D00E5  add         dword ptr ds:[922FC8h],eax       ; _i += числа[i]
009D00EB  inc         edx                              ; ++edx (i)
009D00EC  cmp         esi,edx                          ; edx > числа.длинна?
009D00EE  jg          009D00E1

Обратите внимание на отсутствие проверки границ, - теперь условие завершения цикла является единственной проверкой в данном коде.

Также стоит отметить, что очень легко сломать данную оптимизацию используя что либо другое вместо Array.Length (массив.длина) в качестве условия завершения цикла. К примеру, следующий код (с аналогичной функциональностью) сгенерирует проверку границ массива и испортит всю оптимизацию:

for (int i = 0; i < localRef.Length * 2; ++i)
{
    _i += localRef[i / 2];
}


Встраивание метода

Короткий и простой метод, который используют многократно, может быть встроен в вызывающий код. На данный момент задокументировано (скорее, просто упоминается), что JIT может встраивать методы, которые короче, чем 32 бита, не содержать никакой сложной логики ответвлений, а также не иметь механизмов обработки исключений.

Изучив следующий фрагмент кода, давайте рассмотрим, что произойдет, когда 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 свободным, тем самым его присваивание не будет оптимизировано.

При начале дизассемблирования с отключенными JIT-оптимизациями (к примеру, когда процесс начат в пределах отладочной сессии Visual Studio), будет выработан следующий код для вызова метода:

00000013  mov         edx,4 
00000018  mov         ecx,5
0000001d  call        dword ptr ds:[00A2967Ch]         ; это Util.And
00000023  mov         esi,eax
00000025  mov         dword ptr ds:[00A28A6Ch],esi     ; это  _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

Заметьте, что здесь нет оптимизации или встраивания: параметры передаются в метод в регистрах EDX и ECX (конвенция быстрого вызова), и команда AND по смещению 0x16 выполняет назначение метода.

Давайте теперь изучим встроенный вызов, выработанный в то время, как мы подсоединяем процесс после срабатывания точки прерывания отладчика. Вот как выглядит вызов метода на этот раз:

00B90075  mov         dword ptr ds:[0A22FD0h],4    ; _i = 4
00B9007F  ret

Результатом AND(5, 4) будет 4 и это значение, которое напрямую записывается во временное поле.Заметьте, что оптимизация настолько агрессивна, что операция 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. Поэтому практически невозможно встроить вызов в место встраивания. (Заметьте, что мы говорим "практически" – в теории возможно исполнить частичную оптимизацию, которая представит встраивание в отдельных случаях, но это мы обсудим потом, когда будем говорить об диспетчеризации метода интерфейса.)

Тем самым виртуальный вызов должен пройти через таблицу методов реальных объектов. Проход по таблице методов включает в себя два уровня косвенности: использование заголовка объекта для доступа к таблице методов, и использование смещения, известного во время компиляции, для определения вызываемого метода.

В следующем примере код будет выработан на месте вызова (в методе Method):

007E0076  xor         esi,esi                   ; esi = 0 (i)
007E0078  jmp         007E00AA                  ; переход к условию сравнения
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 таблица методов
007E00A6  call        dword ptr [eax+38h]       ; вызов посредством смещения 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]).

Теоретически ключевое слово sealed C# (и его соответствующий напарник final из IL) предназначено для повышения эффективности в случае с диспетчеризацией виртуального метода посредством указания того, что хотя метод и виртуальный, он не может быть больше перегружен каким-либо порожденным классом. К примеру, следующий код не должен включать в себя диспетчеризацию виртуального метода, которую мы только что рассмотрели:

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);
        }
     }
}

Ясно, что цель метода b.Foo в методе Method статически известна- будет вызван 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 не использует "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), который выработан для данного случая (учтите команду callvirt в L_0013):

.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                   ; представляет собой локальную переменную "a"
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                  ; вызов конструктора (в ветке)
00AD0098  mov         edx,eax
00AD009A  mov         eax,edx     
00AD009C  cmp         dword ptr [eax],eax       ; ссылка на проверку null
00AD009E  inc         esi                       ; переходим дальше, метод на самом деле не вызывается
00AD009F  cmp         esi,0Ah
00AD00A2  jl          00AD007A
00AD00A4  pop         esi  
00AD00A5  ret

Итак, может ли JIT встроить такой метод? Да! Заметьте, как JIT полностью убирает сам вызов (метод ,а потому и встраивание представляет собой оптимизацию), но оно не может убрать проверку на нулевой указатель, поскольку для этого предназначалось ключевое слово callvirt. Поэтому команда в 00AD009C выполняет тривиальную проверку на нулевой указатель посредством попытки разыменовывания EAX, который содержит в себе значение "a" локальной переменной. Если произойдет нарушение прав доступа, то оно будет перехвачено и будет выброшено исключение NullReferenceException.

Если промежуточный язык в L_0003 использовал команду call вместо callvirt , то машинный код, который был бы выработан не имел бы проверку на нулевой указатель, и вышеуказанный код был бы успешно обработан. Это поведение не относится к разработчикам, работающим с языком C#.

Для полноты картины стоит упомянуть, что иногда вызов виртуального метода включает команду call , а не команду callvirt. Это происходит, к примеру, в следующем случае:

class Employee
{
    public override string ToString()
        {

        return base.ToString();
        }
}

Метод ToString скомпилирован в промежуточный язык:

.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);
        }
        }
}

Тривиальный метод диспетчеризации метода в данном случае будет следующим:

  • Проникнуть в таблицу методов за реальным переданным объектом.
  • Проникнуть в карту таблицы методов интерфейсов за интерфейсом, используемым в таблице методов для реального объекта, все это посредством идентификатора интерфейса всего процесса.
  • Перейти в таблицу методов интерфейсов за методом по смещению, известному во время компиляции и выполнить его.

Тем самым примерный машинный код, который должен быть сгенерирован для реального места вызова метода интерфейса (в методе Method) должен выглядеть следующим образом:

mov    ecx, edi                   ; ecx содержит "this"
mov    eax, dword ptr [ecx]       ; eax содержит таблицу методов
mov    eax, dword ptr [eax+0Ch]   ; eax содержит таблицу методов интерфейса
mov    eax, dword ptr [eax+30h]   ; eax содержит указатель метода
call   dword ptr [eax]

Хоть это и описано в одной из статей MSDN Magazine, а также в других источниках, но это не так. Даже отладочная версия (не оптимизированная) диспетчеризации интерфейса не выглядит таким образом. Приходится говорить, что наверняка данный наивный подход имел чересчур низкую производительность и поэтому был заменен чем-то другим.

Также заметьте, что методы интерфейсов всегда неявно отмечены как virtual (посмотрите на следующий промежуточный язык для классов A и B).

.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 (эквивалент ключевому слову sealed в C#) по отношению к методу. Это означает то, что методы интерфейсов не могут быть перегружены пока они не будут явно отмечены как virtual в коде базового класса; тем не менее, они виртуальны в том смысле, что команда callvirt должна быть использована для их вызова, а также необходим просмотр таблицы методов для того, чтобы они были верно диспетчеризированы.

Тем не менее, в данной статье не было бы смысла, если бы все было настолько легко, как пока все кажется. Причиной написания данной статьи является то, что JIT способен встраивать вызовы методов. Далее мы предоставим небольшие исследования на эту тему.

Прежде чем мы  больше углубимся в тему, вывод данной дискуссии может быть обобщен следующим образом: в теории невозможно идеально встроить вызовы методов (сюда также входят вызовы методов интерфейсов); JIT не встраивает вызовы методов интерфейсов; вместо этого JIT выполняет оптимизацию, которая не использует упрощенную диспетчеризацию метода интерфейса, как говорилось выше.

Перед тем, как перейти к тому, что реальный CLR 2.0 JIT выполняет в местах вызовов методов интерфейсов, стоит учесть любую оптимизацию, которая теоретически может быть выполнена к таким вызовам. Наиболее явными являются:


Анализ потока

Анализ потока может определить, что конкретный статический тип используется для вызова метода интерфейса и поэтому может быть напрямую диспетчеризирован посредством типа вместо использования описанного выше метода.

IA a = new A();
a.Foo();

Практически ясно то, что A.Foo вызывается в данном месте, и неважно, что произойдет. Управляемый анализ потока компилятора может определить данное условие и выработать соответствующий двоичный код, который позволит JIT встраивать метод в случае, если он удовлетворяет другим требования для встраивания (смотрите выше).

Анализ частоты

Одна из реализаций интерфейса вызывается значительно чаще, чем другие, в определенном месте. Это может быть определено посредством динамического профилирования и исправления кода, или же какой либо подсказкой от программиста. В данном случае диспетчеризация метода интерфейса для данного конкретного интерфейса может быть изменена на прямую диспетчеризацию или даже встроенную, как это показано ниже:

if (obj->MethodTable == expectedMethodTable) {
      // встроенный код метода "hot"
}
else {
      // стандартный код размещения интерфейса метода
}

Подход 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                            ; установка параметров
007E00AB  call        dword ptr ds:[2C0010h]           ; вызов
007E00B1  mov         dword ptr ds:[002B2FE8h],eax     ; сохранение возвращаемого значения

Заметьте, что это не шаблон диспетчеризации метода интерфейса, который мы должны здесь увидеть. Вместо этого мы получили непрямой вызов через адрес 002C0010. Вам стоит запомнить данный адрес, потому что мы будем еще упоминать его в дальнейшем описании. Пройдя дальше, мы увидим:

002C6012  push        eax  
002C6013  push        30000h
002C6018  jmp         79EE9E4F

Сама реализация не была еще скомпилирована и поэтому мы трасируем код JIT-компилятора. Со временем мы будем перенаправлены (посредством сложной цепи прыжков) к самому коду метода. Выполнение команды ret оттуда вернет нас назад в основной цикл (туда, где была выполнена команда call , 007E00AB).

Тем не менее, после того как цикл будет пройден 3 раза, код, на который указывает 002C0010 (вспомните, тут проходит наше исходное место вызова), будет возвращен в оптимизированную форму. Вот как она выглядит

002D7012  cmp         dword ptr [ecx],2C3210h 
002D7018  jne         002DA011
002D701E  jmp         007E00D0

Помните, что заголовки объектов в .NET начинаются с таблицы методов объекта, и вот тривиальная оптимизация профилирования: если ожидается таблица методов (реализация типа "common" или "hot"), то мы можем выполнить переход напрямую к данному коду. (Заметьте, что реальный адрес встроен в команду, потому что JIT вернул код, добавив полную информацию о месторасположении метода. Это значит, что нет необходимости в лишнем доступе к памяти для диспетчеризации данного вызова. В противном случае нам придется пройти через стандартный уровень диспетчеризации, что мы вскоре и увидим. Для полноты, вот код в месте 007E00D0 (что является ожидаемым результатом команды CMP ):

007E00D0  and         edx,dword ptr [esp+4] 
007E00D4  mov         eax,edx
007E00D6  ret         4

Это всего лишь реализация And.Do. Заметьте, что код, скомпилированный JIT не был встроен в место вызова или в помощника диспетчеризации. Тем не менее, прямой переход к данному коду должен вызывать как можно меньше затрат. Остается лишь вопрос о том, что находится по адресу 002DA011, или другими словами, что произойдет, если таблица методов будет не той, которую мы ожидали? На этот раз код гораздо сложнее.

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


Мы отметили наиболее важные части, поэтому мы сможем на них сконцентрироваться. Во-первых, 1 декрементируется из глобальной переменной. Ее начальное значение равно 0x64 (то есть, 100). Предназначение этого мы увидим позже. Если результирующее значение меньше 0, мы перейдем к вызову одной из возвратных функций JIT (back-patching) и перейдем к потоку. А что же в этом потоке? Нормальная диспетчеризация метода интерфейса производится самим JIT. Заметьте, что со временем появится команда JMP EAX в 002DA049, которая на самом деле перенесет нас в нужное место:

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) {
      // Переход к ожидаемой реализации
}
else {
      expectedType = obj->Type;
      goto start;
}

Это финальное поведение, которое мы получим от данной программы. Это означает, что на каждое место вызова у нас будет разовый счетчик (стартующий со значением 100) , который просчитывает число "промахов" данной "горячей" реализации. Когда счетчик дойдет до значения ниже 0, будет вызван возврат 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 произведет тот же код для закрытого случая диспетчеризации, как и в случае с 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).