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