- PVSM.RU - https://www.pvsm.ru -

Разные версии JIT в .NET

Каждый C#-разработчик знает, что C#-компилятор переводит исходный код программы в промежуточный язык под названием Intermediate Language (IL). А за превращение IL в последовательность машинных команд чаще всего отвечает Just-In-Time-компилятор (JIT). Да, на сегодняшний день есть NGen, Mono AOT, .NET Native, но JIT-компиляция всё ещё лидирует в мире .NET-приложений. А вот работает этот самый JIT, знают далеко не все. Если брать в расчёт только реализацию .NET от Microsoft, то стоит различать JIT-x86 и JIT-x64. А ещё за дверями стоит RyuJIT который уже совсем скоро займёт почётное место основного JIT-компилятора. А если вы любите старые версии .NET, то полезно знать, что в разных версиях CLR логика работы JIT отличалась. Исходники у нас теперь открыты, вы можете их посмотреть [1] и осознать, насколько же это большая и сложная тема. Сегодня мы не будем пытаться охватить её, а лишь кратко посмотрим на несколько интересных особенностей отдельных версий JIT-компиляторов. Итак, сегодня в номере:

  • Почему короткий метод может не быть заинлайнен и как этого избежать
  • JIT-баги: опасные и беспощадные
  • Кто и как разматывает циклы
  • Чем отличается размотка маленьких и больших циклов

Разные версии JIT в .NET - 1

JIT-x86 и starg

Откроем исходник [2] конструктора Decimal с параметром типа int из .NET Reference Source:

// Constructs a Decimal from an integer value.
//
public Decimal(int value) {
    //  JIT today can't inline methods that contains "starg" opcode.
    //  For more details, see DevDiv Bugs 81184: x86 JIT CQ: Removing the inline striction of "starg".
    int value_copy = value;
    if (value_copy >= 0) {
        flags = 0;
    }
    else {
        flags = SignMask;
        value_copy = -value_copy;
    }
    lo = value_copy;
    mid = 0;
    hi = 0;
}

Заинтригованы? А всё дело в том, что JIT-x86 не умеет инлайнить методы, в IL-коде которых содержатся инструкции starg или ldarga. Decimal-конструктор очень желательно заинлайнить, поэтому разработчики стандартного класса пошли на хитрость: скопировали параметр в локальную переменную, чтобы избежать «плохой» инструкции. В JIT-x64 эту «фичу» убрали. Для заинтересовавшихся рекомендуется к изучению:

Странный баг в JIT-x64

Уважаемые знатоки, внимание, вопрос: что выведет следующий код для step=1?

private int bar;

public void Foo(int step)
{
    for (int i = 0; i < step; i++)
    {
        bar = i + 10;
        for (int j = 0; j < 2 * step; j += step)
            Console.WriteLine(j + 10);
    }
}

Правильный ответ: зависит. Скорее всего вы ожидаете увидеть 10 11, но баг в оптимизации JIT-x64 всё испортит и даст нам 10 21. В JIT-x86 и RyuJIT всё работает хорошо. С багом придётся смириться, Microsoft не хочет его исправлять. Пример очень хрупкий, наткнуться на него в реальной жизни крайне проблематично. Кто-то спросит: но если это редкий баг, то зачем про него знать? Зачем вообще интересоваться подобными штуками? Если вы человек весёлой натуры, то можно использовать баг в своих целях. Например, определить в рантайме какая версия JIT сейчас используется:

public enum JitVersion
{
    Mono, MsX86, MsX64, RyuJit
}

public class JitVersionInfo
{
    public JitVersion GetJitVersion()
    {
        if (IsMono())
            return JitVersion.Mono;
        if (IsMsX86())
            return JitVersion.MsX86;
        if (IsMsX64())
            return JitVersion.MsX64;
        return JitVersion.RyuJit;
    }

    private int bar;

    private bool IsMsX64(int step = 1)
    {
        var value = 0;
        for (int i = 0; i < step; i++)
        {
            bar = i + 10;
            for (int j = 0; j < 2 * step; j += step)
                value = j + 10;
        }
        return value == 20 + step;
    }

    public static bool IsMono()
    {
        return Type.GetType("Mono.Runtime") != null;
    }

    public static bool IsMsX86()
    {
        return !IsMono() && IntPtr.Size == 4;
    }
}

Материал для дополнительного чтения:

Размотка циклов

Размотка циклов — это такая очень хорошая оптимизация, которую любят делать многие компиляторы. Суть в том, что мы заменяем цикл вида

for (int i = 0; i < 1024; i++)
    Foo(i);

на

for (int i = 0; i < 1024; i += 4)
{
    Foo(i);
    Foo(i + 1);
    Foo(i + 2);
    Foo(i + 3);
}

Помимо сокращение количества операций инкремента, мы имеем улучшенные условия для дополнительных операций на уровне процессора (например, branch prediction и instruction-level parallelism). Увы, JIT-x86 и RyuJIT среднестатистический цикл разматывать не особо умеют. А вот JIT-x64 иногда умеет, хоть и делает это в своей особой манере. Например, если количество итераций делится на 2 или 3, то код

int sum = 0;
for (int i = 0; i < 1024; i++)
    sum += i;
Console.WriteLine(sum);

превратится во что-то вида

        int sum = 0;                               
00007FFCC8710090  sub         rsp,28h              
        for (int i = 0; i < 1024; i++)             
00007FFCC8710094  xor         ecx,ecx              
00007FFCC8710096  mov         edx,1                ; edx = i + 1
00007FFCC871009B  nop         dword ptr [rax+rax]  
00007FFCC87100A0  lea         eax,[rdx-1]          ; eax = i
            sum += i;                              
00007FFCC87100A3  add         ecx,eax              ; sum += i
00007FFCC87100A5  add         ecx,edx              ; sum += i + 1
00007FFCC87100A7  lea         eax,[rdx+1]          ; eax = i + 2
00007FFCC87100AA  add         ecx,eax              ; sum += i + 2;
00007FFCC87100AC  lea         eax,[rdx+2]          ; eax = i + 3
00007FFCC87100AF  add         ecx,eax              ; sum += i + 3;
00007FFCC87100B1  add         edx,4                ; i += 4
        for (int i = 0; i < 1024; i++)             
00007FFCC87100B4  cmp         edx,401h             
00007FFCC87100BA  jl          00007FFCC87100A0     

Это достаточно важная информация. Например, многие предвкушают переход с JIT-x64 на RyuJIT, ведь Microsoft обещают нам много вкусного: поддержку SIMD и ускоренную JIT-компиляцию. А вот про производительность самого кода они как-то молчат. Нужно понимать, что отсутствие некоторых оптимизаций в RyuJIT (по сравнению с JIT-x64) может немножко сократить скорость работы вашей программы. Полезные ссылки:

Больше интересных JIT-багов

Вот вам ещё задачка:

struct Point
{
    public int X;
    public int Y;
}

static void Print(Point p)
{
    Console.WriteLine(p.X + " " + p.Y);
}

static void Main()
{
    var p = new Point();
    for (p.X = 0; p.X < 2; p.X++)
        Print(p);
}

Данный цикл также можно раскрутить. Итерации всего две, так что от условных переходов можно избавиться вовсе: достаточно повторить тело цикла дважды. Занимательный факт: в CLR2 JIT-x86 была бага, которая портила жизнь и вместо 0 1 1 0 выдавала 2 0 2 0. Наткнуться на неё не так уж и сложно. Благо, в CLR 4 её поправили, а в других версиях JIT её и вовсе не было. Имейте ввиду, что если вы работаете под .NET Framework 3.5 (да, некоторым всё ещё приходится), то это подразумевает CLR2. Нужно быть готовыми, что такой простой код превратится в

        var p = new Point();                  
05C5178C  push        esi                     
05C5178D  xor         esi,esi                 ; p.Y = 0
        for (p.X = 0; p.X < 2; p.X++)         
05C5178F  lea         edi,[esi+2]             ; p.X = 2
            Print(p);                         
05C51792  push        esi                     ; push p.Y
05C51793  push        edi                     ; push p.X
05C51794  call        dword ptr ds:[54607F4h] ; Print(p)
05C5179A  push        esi                     ; push p.Y
05C5179B  push        edi                     ; push p.X
05C5179C  call        dword ptr ds:[54607F4h] ; Print(p)
05C517A2  pop         esi                     
05C517A3  pop         edi                     
05C517A4  pop         ebp                     
05C517A5  ret 

А вообще, тема размотки маленький циклов представляет особый интерес. В то время, как JIT-x86 любит их разматывать (это большой цикл размотать сложно, а вот с маленьким всё намного проще), RyuJIT (который основан на кодовой базе 32-битного JIT) разматывать их отказывается. А вот JIT-x64 тут нас может порадовать. Скажем, он может взять код

int sum = 0;
for (int i = 0; i < 4; i++)
    sum += i;
Console.WriteLine(sum);

и предподсчитать значение:

        int sum = 0;                            
00007FFCC86F3EC0  sub         rsp,28h           
        Console.WriteLine(sum);                 
00007FFCC86F3EC4  mov         ecx,6             ; sum = 6
00007FFCC86F3EC9  call        00007FFD273DCF10  
00007FFCC86F3ECE  nop                           
00007FFCC86F3ECF  add         rsp,28h           
00007FFCC86F3ED3  ret  

Но не надо думать, что RyuJIT во всём хуже JIT-x64. Да, с оптимизациями в JIT-компиляторе нового поколения всё не так хорошо, но зато в среднем по больнице код получается более вменяемый. Узнать больше про размотку маленьких циклов можно тут:

Хотите знать больше про внутренности .NET?

Тогда заходите к нам на огонёк! В скором времени в Москве (03–04 апреля), Екатеринбурге (17 мая) и Санкт-Петербурге (29–30 мая) пройдёт серия семинаров CLRium #2 [27] (онлайн трансляция включена). Будем обсуждать будущее .NET: поговорим про анатомию нового CoreCLR, особенности RyuJIT, хардкорные примеры по работе с Roslyn и потроха CoreFx! Нескончаемый поток интересных и полезных знаний поможет вам не только намного лучше понять как работают ваши собственные C#-программы, но и подготовит к светлому .NET-будущему, в котором вы сможете использовать силу платформы на полную!

Автор: DreamWalker

Источник [28]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/c-2/84657

Ссылки в тексте:

[1] посмотреть: https://github.com/dotnet/coreclr/tree/master/src/jit

[2] исходник: http://referencesource.microsoft.com/#mscorlib/system/decimal.cs,158

[3] История про инлайнинг под JIT-x86 и starg: http://aakinshin.net/ru/blog/dotnet/inlining-and-starg/

[4] CoreCLR, JIT sources: flowgraph.cpp (Feb 26, 2015): https://github.com/dotnet/coreclr/blob/65456c070ffbc97f14c1c32318dabc221646d8d6/src/jit/flowgraph.cpp#L4252

[5] CoreCLR, JIT sources: importer.cpp (Feb 26, 2015): https://raw.githubusercontent.com/dotnet/coreclr/65456c070ffbc97f14c1c32318dabc221646d8d6/src/jit/importer.cpp

[6] MSDN: starg: https://msdn.microsoft.com/library/system.reflection.emit.opcodes.starg.aspx

[7] MSDN: ldarga: https://msdn.microsoft.com/library/system.reflection.emit.opcodes.ldarga.aspx

[8] Stackoverflow: .NET local variable optimization: http://stackoverflow.com/questions/26369163/net-local-variable-optimization

[9] История про баг в JIT-x64: http://aakinshin.net/ru/blog/dotnet/subexpression-elimination-bug-in-jit-x64/

[10] Определение версии JIT в рантайме: http://aakinshin.net/ru/blog/dotnet/jit-version-determining-in-runtime/

[11] StackOverflow: JIT .Net compiler bug?: http://stackoverflow.com/questions/20701701/jit-net-compiler-bug

[12] MS Connect: x64 jitter sub-expression elimination optimizer bug: https://connect.microsoft.com/VisualStudio/feedback/details/812093/x64-jitter-sub-expression-elimination-optimizer-bug

[13] StackOverflow: How to detect which .NET runtime is being used (MS vs. Mono)?: http://stackoverflow.com/q/721161/184842

[14] StackOverflow: How do I verify that ryujit is jitting my app?: http://stackoverflow.com/q/22422021/184842

[15] RyuJIT CTP5 и размотка циклов: http://aakinshin.net/ru/blog/dotnet/ryujit-ctp5-and-loop-unrolling/

[16] Википедия: Размотка цикла: https://ru.wikipedia.org/wiki/%D0%A0%D0%B0%D0%B7%D0%BC%D0%BE%D1%82%D0%BA%D0%B0_%D1%86%D0%B8%D0%BA%D0%BB%D0%B0

[17] Wikipedia: Loop unrolling: http://en.wikipedia.org/wiki/Loop_unrolling

[18] J. C. Huang, T. Leng, Generalized Loop-Unrolling: a Method for Program Speed-Up (1998): https://www.researchgate.net/publication/2449271_Generalized_Loop-Unrolling_a_Method_for_Program_Speed-Up

[19] Википедия: Предсказывание переходов (branch prediction): https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%B5%D0%B4%D1%81%D0%BA%D0%B0%D0%B7%D0%B0%D1%82%D0%B5%D0%BB%D1%8C_%D0%BF%D0%B5%D1%80%D0%B5%D1%85%D0%BE%D0%B4%D0%BE%D0%B2

[20] Википедия: Параллелизма уровня команд (instruction-level parallelism): https://ru.wikipedia.org/wiki/%D0%9F%D0%B0%D1%80%D0%B0%D0%BB%D0%BB%D0%B5%D0%BB%D0%B8%D0%B7%D0%BC_%D0%BD%D0%B0_%D1%83%D1%80%D0%BE%D0%B2%D0%BD%D0%B5_%D0%BA%D0%BE%D0%BC%D0%B0%D0%BD%D0%B4

[21] Wikipedia: Inline expansion: http://en.wikipedia.org/wiki/Inline_expansion

[22] Wikipedia: Cache miss: http://en.wikipedia.org/wiki/CPU_cache#Cache_miss

[23] StackOverflow: http://stackoverflow.com/questions/2349211/when-if-ever-is-loop-unrolling-still-useful: http://stackoverflow.com/questions/2349211/when-if-ever-is-loop-unrolling-still-useful

[24] Blogs.Msdn: RyuJIT: The next-generation JIT compiler for .NET: http://blogs.msdn.com/b/dotnet/archive/2013/09/30/ryujit-the-next-generation-jit-compiler.aspx

[25] Размотка маленьких циклов в разных версиях JIT: http://aakinshin.net/ru/blog/dotnet/unrolling-of-small-loops-in-different-jit-versions/

[26] StackOverflow: .NET JIT potential error?: http://stackoverflow.com/q/2056948/184842

[27] CLRium #2: http://clrium.ru/?utm_source=aakinshin&utm_medium=direct&utm_campaign=habr

[28] Источник: http://habrahabr.ru/post/252105/