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