JIT-оптимизации - Наиболее важные части
ОГЛАВЛЕНИЕ
Мы отметили наиболее важные части, поэтому мы сможем на них сконцентрироваться. Во-первых, 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).