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