Разные версии 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 отличалась. Исходники у нас теперь открыты, вы можете их посмотреть и осознать, насколько же это большая и сложная тема. Сегодня мы не будем пытаться охватить её, а лишь кратко посмотрим на несколько интересных особенностей отдельных версий JIT-компиляторов. Итак, сегодня в номере:
    • Почему короткий метод может не быть заинлайнен и как этого избежать
    • JIT-баги: опасные и беспощадные
    • Кто и как разматывает циклы
    • Чем отличается размотка маленьких и больших циклов



    JIT-x86 и starg


    Откроем исходник конструктора 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 (онлайн трансляция включена). Будем обсуждать будущее .NET: поговорим про анатомию нового CoreCLR, особенности RyuJIT, хардкорные примеры по работе с Roslyn и потроха CoreFx! Нескончаемый поток интересных и полезных знаний поможет вам не только намного лучше понять как работают ваши собственные C#-программы, но и подготовит к светлому .NET-будущему, в котором вы сможете использовать силу платформы на полную!

    Enterra
    Компания

    Похожие публикации

    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 4

      0
      Вспомнил ещё, что JIT-x64 иногда умеет оптимизировать хвостовую рекурсию, что может приводить к неожиданно успешному выполнению очень глубоких рекурсивных вызовов, непредсказуемому влиянию на производительность и т.п. весёлым вещам.

      (Интересно, кстати, что при этом будет в стектрейсе — будет ли рантайм наворачивать туда фреймы от хвостового вызова или нет.)

      Кстати, кто знает, как в RjuJIT с tail recursion? Он умеет .tailcall или, может быть, сам умеет оптимизировать какие-то простые случаи?
        +1
        Очень интересная тема. Постараюсь в ближайшее время заняться исследованием. Я свято верю, что RyuJIT должен оптимизировать хвостовую рекурсию. Если не умеет, то я буду грустить. По поводу стектрейса тоже интересный вопрос: если .NET умеет делать правильный StackTrace, то я плохо представляю как он это делает. Кстати говоря, в Java по этой причине от оптимизации отказались (StackTrace потеряется, это плохо, пусть лучше у нас всё будет работать медленно). У меня есть подозрение, что дело обстоит так: в Debug оптимизация не применяется и StackTrace считается правильно, а в Release ситуация скорее всего должна быть аналогична ситуации с инлайнингом функций — там же тоже по сути стек вызовов портится.
          +1
          Только что проверил вот на такой простой программе:

          	class Program
          	{
          		static int Recursive(int x)
          		{
          			if (x == int.MaxValue)
          			{
          				throw new Exception();
          			}
          
          			return Recursive(x + 1);
          		}
          
          		static void Main(string[] args)
          		{
          			int x = Recursive(0);
          			Console.WriteLine(x);
          		}
          	}
          

          В AnyCPU под 64-битной осью оптимизация не срабатывает, в дебаге — тоже. Если компилять под x64-only и в релиз — тогда начинает работать. Стек действительно портится — показывает, что упал в Main -> Recursive без отслеживания хвостовых вызовов. Ну что ж, нормально — лучше уж так, чем вообще без оптимизации.

          Вообще говоря, мне не кажется, что TCO такая уж важная вещь в .NET именно по причине того, что она не гарантируется. Вот если она будет гарантирована стандартом в каких-то случаях, или же можно будет развесить какие-то атрибуты, которые бы проверяли её наличие и падали, если оптимизировать не получилось (типа @tailrec в Scala) — это было бы нормально. Моя мотивация проста — TCO это крайне важная особенность поведения кода, которая может сильно влиять на его быстродействие и характеристики потребления памяти, ну а писать рабочий код, который полагается на какие-то негарантированные оптимизации компилятора и ломается, если у компилятора или JIT'тера что-то там «не получилось» — это уж слишком, по-моему.
            0
            В AnyCPU под 64-битной осью оптимизация не срабатывает,

            А галочка «Prefer 32 bit» не стоит

            по причине того, что она не гарантируется

            Ну а как её можно гарантировать? Там же пару байт поменяешь, так TCO станет ухудшать код или вовсе станет невозможна. Подвергать оптимизации метод нужно только тогда, когда он отвечает ряду специфических условий, за определение которых и должен отвечать JIT. А вот насколько хорошо он это делает — другой вопрос. В общем, я займусь на днях исследованием.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое