JIT-оптимизации - Промежуточный язык IL

ОГЛАВЛЕНИЕ

А вот промежуточный язык (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 выполняет в местах вызовов методов интерфейсов, стоит учесть любую оптимизацию, которая теоретически может быть выполнена к таким вызовам. Наиболее явными являются: