Age of JIT compiling. Part II. CLR is watching you

    Продолжая тему JIT-компиляции .NET'a, сегодня мы рассмотрим диспетчеризацию методов у интерфейсов, generics (как классов, так и отдельных методов вместе с реальными сигнатурами); производить отладку релизных сборок с оптимизациями; разберемся с истинным предназначением типа System.__Canon (это не то, что Вы подумали).

    Настройка среды


    Прежде чем двигаться дальше, нам необходимо подготовить Visual Studio для отладки релизных сборок.

    Использовать будем VS 2013, поэтому для использования SOS.dll придется включить compatibility mode:
    Tools -> Options -> Debugging -> General


    Далее снимем галочки здесь же с:
    • Suppress JIT optimization on module load
    • Enable Just My Code

    Также необходимо включить поддержку Native Debugging:
    Project Settings -> Debug -> Enable native code debugging
    Теперь приступим к нашим исследованиям.

    Interface dispatch stubs (Virtual Stub Dispatch)


    CLR постоянно проводит мониторинг всех участков кода. Имеет несколько стратегий по обновлению нативного кода методов. Именно так – не только HotSpot в Java имеет такой функционал, или же современные JS-движки.

    Такой функционал появился в CLR 2.0 еще в 2006 году. И…остался во многом в таком же виде + новые эвристики.

    Особенно “бдительно” среда следит за интерфейсами.
    Надеюсь, Вы уже настроили студию для дебага релизного кода.
    Рассмотрим пример:
    class Program
    {
        static void Main(string[] args)
        {
            ICallable target = new FirstCallableImpl();
            CallInterface(target);
    
            ICallable target2 = new SecondCallableImpl();
            CallInterface(target2);
        }
    
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static void CallInterface(ICallable callable)
        {
            for (int i = 0; i < 1000000; i++)
            {
                callable.DoSomething(); // place breakpoint
            }
        }
    }
    
    interface ICallable
    {
        void DoSomething();
    }
    
    class FirstCallableImpl : ICallable
    {
        public void DoSomething()
        {
    
        }
    }
    
    class SecondCallableImpl : ICallable
    {
        public void DoSomething()
        {
    
        }
    }
    


    Запустим отладку. Далее откроем окно Disassembly (Debug -> Windows -> Disassembly).


    Рассмотрим инструкцию call dword ptr ds:[00450010h].
    Чтобы узнать значение по адресу 0x00450010 откроем окно памяти (Debug -> Windows-> Memory-> Memory1).


    На данном этапе JIT еще не создал необходимый узел вызова, пока что среда сама производит «интерпретацию» вызова метода интерфейса (это значит, происходит линейный поиск требуемого метода в рантайме).

    Однако позволим еще 2 раза выполниться этому коду и увидим, что значение адреса 0x0450010 изменилось:


    Для инспекции значения 00457012 загрузим SOS.dll:
    Immediate window -> .load sos
    !u 00457012
    Unmanaged code
    00457012 813908314400     cmp         dword ptr [ecx],443108h
    00457018 0F85F32F0000     jne         0045A011
    0045701E E9BD901D00       jmp         006300E0
    

    Инструкция jmp 006300E0 представляет собой вызов требуемого метода интерфейса. Проверим:
    !u 006300E0
    Normal JIT generated code
    ConsoleApplication1.FirstCallableImpl.DoSomething()
    Begin 006300e0, size 1
    >>> 006300E0 C3               ret
    

    Так… С методом понятно, но что же за сравнение происходит в инструкции cmp dword ptr [ecx],443108h?
    !DumpMT 443108
    EEClass: 00441378
    Module: 00442c5c
    Name: ConsoleApplication1.FirstCallableImpl
    mdToken: 02000004  (C:\*path to project*\InterfaceStubsTest.exe)
    BaseSize: 0xc
    ComponentSize: 0x0
    Number of IFaces in IFaceMap: 1
    Slots in VTable: 6
    

    Ага! Сравниваем this на соответствие типу FirstCallableImpl(т.е. MethodTable) и при значении true вызываем метод FirstCallableImpl.DoSomething().
    Инструкция jne 0045A011 представляет собой fallback на линейный поиск, как и было до кэширования.

    Когда дело дойдет до вызова следующего типа — SecondCallableImpl, то все так же будет проверяться в узле вызова именно FirstCallableImpl, а не SecondCallableImpl.

    Но это же неэффективно! Именно поэтому, по достижению определенного количества итераций вызова кода, среда просто заменит данный узел вызова с кэшем на (как Вы уже догадались) линейный поиск.
    Кэширование весьма эффективно, если мы вызываем методы у коллекций, например.

    Generic types stubs


    Выход CLR 2.0 вместе с generics ознаменовал существенные изменения в среде исполнения. Если до этого для описания конкретного типа “хватало” лишь структуры EEClass, то теперь связка структура EEClass+MethodTable представляет собой текущий тип.

    Более того, для List<string> и List<int> разными будут даже EEClass (про code-sharing будет чуть ниже).
    Рассмотрим пример:
    class Program
    {
        static void Main(string[] args)
        {
            var refTypeHolder = new HolderOf<object>(null);
            var intTypeHolder = new HolderOf<int>(0);
    
            // call JIT
            refTypeHolder.GetPointer();
            intTypeHolder.GetPointer();
    
            Console.Read(); // place breakpoint
        }
    }
    
    class HolderOf<T>
    {
        private readonly T _pointer;
    
        public HolderOf(T pointer)
        {
            _pointer = pointer;
        }
    
        public T GetPointer()
        {
            return _pointer;
        }
    }
    


    Для инспекции используем команду !dumpheap:
    .load sos.dll
    
    !dumpheap -type HolderOf
    PDB symbol for mscorwks.dll not loaded
     Address       MT     Size
    02d332c8 00f531e0       12     
    02d332d4 00f53268       12     
    total 2 objects
    Statistics:
          MT    Count    TotalSize Class Name
    00f53268        1           12 ConsoleApplication1.HolderOf`1[[System.Int32, mscorlib]]
    00f531e0        1           12 ConsoleApplication1.HolderOf`1[[System.Object, mscorlib]]
    Total 2 objects
    

    Как мы видим, среда создала две различные специализации класса HolderOf<T>
    !dumpmt -md 00f53268 (HolderOf<int>)
    !dumpmt -md 00f53268
    EEClass: 00f514cc
    Module: 00f52c5c
    Name: ConsoleApplication1.HolderOf`1[[System.Int32, mscorlib]]
    mdToken: 02000006  (C:\*path to samples*\InterfaceStubsTest.exe)
    BaseSize: 0xc
    ComponentSize: 0x0
    Number of IFaces in IFaceMap: 0
    Slots in VTable: 6
    --------------------------------------
    MethodDesc Table
       Entry MethodDesc      JIT Name
    66ae6a30   66964968   PreJIT System.Object.ToString()
    66ae6a50   66964970   PreJIT System.Object.Equals(System.Object)
    66ae6ac0   669649a0   PreJIT System.Object.GetHashCode()
    66b57940   669649c4   PreJIT System.Object.Finalize()
    00f5c088   00f53250      JIT ConsoleApplication1.HolderOf`1[[System.Int32, mscorlib]]..ctor(Int32)
    00f5c090   00f5325c     NONE ConsoleApplication1.HolderOf`1[[System.Int32, mscorlib]].GetPointer()
    


    !dumpmt -md 00f531e0 (HolderOf<object>)
    !dumpmt -md 00f531e0
    EEClass: 00f51438
    Module: 00f52c5c
    Name: ConsoleApplication1.HolderOf`1[[System.Object, mscorlib]]
    mdToken: 02000006  (C:\*path to samples*\InterfaceStubsTest.exe)
    BaseSize: 0xc
    ComponentSize: 0x0
    Number of IFaces in IFaceMap: 0
    Slots in VTable: 6
    --------------------------------------
    MethodDesc Table
       Entry MethodDesc      JIT Name
    66ae6a30   66964968   PreJIT System.Object.ToString()
    66ae6a50   66964970   PreJIT System.Object.Equals(System.Object)
    66ae6ac0   669649a0   PreJIT System.Object.GetHashCode()
    66b57940   669649c4   PreJIT System.Object.Finalize()
    00f5c068   00f53154      JIT ConsoleApplication1.HolderOf`1[[System.__Canon, mscorlib]]..ctor(System.__Canon)
    00f5c070   00f53160     NONE ConsoleApplication1.HolderOf`1[[System.__Canon, mscorlib]].GetPointer()
    



    В вышеприведенном дампе, нас интересует HolderOf<T>.GetPointer(). Рассмотрим:
    !dumpmd 00f5325c (HolderOf<int>.GetPointer())
    !dumpmd 00f5325c
    Method Name: ConsoleApplication1.HolderOf`1[[System.Int32, mscorlib]].GetPointer()
    Class: 00f514cc
    MethodTable: 00f53268
    mdToken: 0600000b
    Module: 00f52c5c
    IsJitted: yes
    CodeAddr: 01090318
    


    !dumpmd 00f53160 (HolderOf<object>.GetPointer())
    !dumpmd 00f53160
    Method Name: ConsoleApplication1.HolderOf`1[[System.__Canon, mscorlib]].GetPointer()
    Class: 00f51438
    MethodTable: 00f53178
    mdToken: 0600000b
    Module: 00f52c5c
    IsJitted: yes
    CodeAddr: 010902b8
    


    HolderOf<object> HolderOf<int>
    MethodDesc 00f53160 00f5325c
    CodeAddr 01090318 010902b8
    Initiation type HolderOf`1[[System.__Canon, mscorlib]] HolderOf`1[[System.Int32, mscorlib]]

    Итак, мы видим, что отличаются не только Methodtable, но и нативный код (CodeAddr).

    А теперь самое интересное – куда делся System.Object для Holderof<object> ?! Что за System.__Canon?
    Знакомьтесь:
    [Serializable()]
    [ClassInterface(ClassInterfaceType.AutoDual)]
    [ComVisible(true)]
    internal class __Canon
    {
    }
    

    Если кратко, то обычно говорят, что для ссылочных типов среда использует тип System.__Canon для шаринга кода.
    Но не в этом дело. Серьезно.

    Дело в том, что generic-типы могут содержать циклические зависимости от других типов, что чревато бесконечным созданием специализаций для кода. Например:
    Generics cyclomatic dependencies
    class GenericClassOne<T>
    {
        private T field;
    }
    
    class GenericClassTwo<U>
    {
        private GenericClassThree<GenericClassOne<U>> field
    }
    
    class GenericClassThree<S>
    {
        private GenericClassTwo<GenericClassOne<S>> field
    }
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine((new GenericClassTwo<object>()).ToString());
            Console.Read();
        }
    }
    


    Однако этот код не упадет и выведет GenericClassTwo`1[System.Object].

    — Так и что там про зависимости было? (примечание: мысли вслух).

    Type loader (он же загрузчик типов) сканирует каждый generic-тип на наличие циклической зависимости и присваивает очередность (т.н. LoadLevel для класса). Хотя все специализации для ref-types имеют System.__Canon как аргумент типа — это следствие, а не причина.

    Фазы загрузки (они же ClassLoadLevel):
    enum ClassLoadLevel
    {
        CLASS_LOAD_BEGIN,
        CLASS_LOAD_UNRESTOREDTYPEKEY,
        CLASS_LOAD_UNRESTORED,  
        CLASS_LOAD_APPROXPARENTS,
        CLASS_LOAD_EXACTPARENTS,
        CLASS_DEPENDENCIES_LOADED,
        CLASS_LOADED,
        CLASS_LOAD_LEVEL_FINAL = CLASS_LOADED,
    };
    

    Для SSLCI (Rotor) код, ответственный за сканирование находится в файле sscli20/clr/src/vm/Generics.cpp:
    Generics.cpp
    BOOL Generics::CheckInstantiationForRecursion(const unsigned int nGenericClassArgs, const TypeHandle pGenericArgs[])
    {
        CONTRACTL
        {
            NOTHROW;
            GC_NOTRIGGER;
        }
        CONTRACTL_END;
        
        if (nGenericClassArgs == 0)
            return TRUE;
    
    
        _ASSERTE(pGenericArgs);
    
    
        struct PerIterationData {
            const TypeHandle * genArgs;
            int index;
            int numGenArgs;
        };
        
        PerIterationData stack[MAX_GENERIC_INSTANTIATION_DEPTH];
        stack[0].genArgs = pGenericArgs;
        stack[0].numGenArgs = nGenericClassArgs;
        stack[0].index = 0;
        int curDepth = 0;
    
    
        // Walk over each instantiation, doing a depth-first search looking for any
        // instantiation with a depth of over 100, in an attempt at flagging 
        // recursive type definitions.  We're doing this to help avoid a stack 
        // overflow in the loader.  
        // Avoid recursion here, to avoid a stack overflow.  Also, this code
        // doesn't allocate memory.
        while(curDepth >= 0) {
            PerIterationData * cur = &stack[curDepth];
            if (cur->index == cur->numGenArgs) {
                // Pop
                curDepth--;
                if (curDepth >= 0)
                    stack[curDepth].index++;
                continue;
            }
            if (cur->genArgs[cur->index].HasInstantiation()) {
                // Push
                curDepth++;
                if (curDepth >= MAX_GENERIC_INSTANTIATION_DEPTH)
                    return FALSE;
                stack[curDepth].genArgs = cur->genArgs[cur->index].GetInstantiation();
                stack[curDepth].numGenArgs = cur->genArgs[cur->index].GetNumGenericArgs();
                stack[curDepth].index = 0;
                continue;
            }
            
            // Continue to the next item
            cur->index++;
        }
        return TRUE;
    }
    


    Для CoreCLR код изменился в сторону ООП :)

    Итак, разобрались: ссылочные типы имеют шаринг кода, значимые – нет… А почему?
    Если все сводится к размеру типа (ref – размер слова; In32 – 4 байта, double – 8 байт и т.д.), тогда можно для DateTime и long расшарить.

    Во-первых, это неправильно с точки зрения семантики. Во-вторых, разработчики CLR решили этого не делать.

    Generic method stubs


    Мы рассмотрели специализацию кода для generic-типов, а как насчет методов? Как найти отдельные методы вне класса?
    Рассмотрим пример:
    Generic methods
    class Program
    {
        static void Main(string[] args)
        {
            var refTypeHolder = new HolderOf();
            
            Test(refTypeHolder);
            Test2(refTypeHolder);
            Console.Read();
        }
    
        [MethodImpl(MethodImplOptions.NoInlining)]
        static void Test(HolderOf typeHolder)
        {
            for (int i = 0; i < 10; i++)
            {
                typeHolder.GetPointer<Program>();
            }
        } // place breakpoint
    
        [MethodImpl(MethodImplOptions.NoInlining)]
        static void Test2(HolderOf typeHolder)
        {
            for (int i = 0; i < 10; i++)
            {
                typeHolder.GetPointer<object>();
            }
        } // place breakpoint
    }
    
    class HolderOf
    {
        [MethodImpl(MethodImplOptions.NoInlining)]
        public void GetPointer<T>()
        {
            Console.WriteLine(typeof(T));
        }
    }
    


    В точке останова в окне Disassembly для метода Test() можно увидеть следующее:
    00000045  mov         ecx,dword ptr [ebp-3Ch] 
    00000048  mov         edx,10031B8h 
    0000004d  cmp         dword ptr [ecx],ecx 
    0000004f  call        FFE8BF40 
    

    А для Test2() — следующее:
    00000045  mov         ecx,dword ptr [ebp-3Ch] 
    00000048  mov         edx,1003574h 
    0000004d  cmp         dword ptr [ecx],ecx 
    0000004f  call        FFE8BE40 
    

    Регистр ECX содержит указатель на this (calling convention — FastCall), но ведь GetPointer() имеет ноль аргументов, что же тогда записывается в EDX?!

    Исследуем:
    !dumpmd 10031B8 (from Test())
    !dumpmd 10031B8
    Method Name: ConsoleApplication1.HolderOf.GetPointer[[ConsoleApplication1.Program, InterfaceStubsTest]]()
    Class: 01001444
    MethodTable: 01003118
    mdToken: 0600000e
    Module: 01002c5c
    IsJitted: no
    CodeAddr: ffffffffffffffff
    


    !dumpmd 1003574 (from Test2())
    !dumpmd 1003574
    Method Name: ConsoleApplication1.HolderOf.GetPointer[[System.Object, mscorlib]]()
    Class: 01001444
    MethodTable: 01003118
    mdToken: 0600000e
    Module: 01002c5c
    IsJitted: no
    CodeAddr: ffffffffffffffff
    


    Ага! передается структура MethodDesc, которая содержит в себе указатель на MethodTable (хочу заметить — оба дескриптора указывают на один и тот же MethodTable 0x01003118) и служит источником метаданных.

    Таким образом, при вызове generic-методов, передается дополнительный параметр с MethodDesc.
    Сами адреса FFE8BF40 и FFE8BE40 являются трамплином, который отдает (forward) реальный специализированный (для int, object и т.д.) нативный код.

    Т.к. сам дескриптор также хранит в себе generic-параметры, то получается еще и экономия на количестве передаваемых аргументов в случае, например, нескольких generic-параметров Some<T, TU, TResult>().

    Share post

    Comments 11

      +4
      Спасибо за отличный пост! Очень интересная информация. Есть несколько вопросов:
      • Вы пробовали найти в исходниках нового CoreCLR непосредственный код методов, которые отвечают за описанную вам логику?
      • В посте вы описываете работу JIT-x86. Есть ли какие-нибудь принципиальные отличия в JIT-x64?
      • Есть ли отличия между старым JIT и новым RyuJIT в отношении описанных вами вещей?
        +3
        спасибо :)

        • Для Virtual stub dispatch — virtualcallstub.h
        • x64 для интерфейсов кэширует либо сразу, либо линейный поиск. плана как для x86, увы, нет. MethodDesc для generic-методов передается также
        • RyuJIT — признаюсь, не щупал. дождемся финального релиза )
        0
        HotSpot в Java

        HotSwap
          0
          имел ввиду мониторинг кода на «горячие» участки кода.

          но спасибо за дополнение :)
          +1
          Спасибо за отличную статью! Жалко только на VS2015 не получается то же самое провернуть: при попытку запустить sos жалуется
          .load sos
          Error during command: Warning. Extension is using a callback which Visual Studio does not implement.
          
          Error during command: Exception c0000005 occurred at 59D66D56
          
          
            0
            спасибо :)

            такая же проблема возникает и с VS2013. попробуте установить WDK.

            кстати, это поведение зависит и от конфигурации (хочу заметить, что без WDK):
            • VS+Win7 — OK
            • VS (Update 3) + Win8.1 — OK
            • VS (Update 4) + Win8.1 — Fail
            0
            А можете объяснить для C++-программиста, как тот бесконечно рекурсивный шаблон из вашего примера разворачивается и работает? Вроде и статью прочитал, и все равно неясно.
              0
              Вы про Generics cyclomatic dependencies?

              Все это «разворачивается» в 3 класса в каждом из которых по одному полю, более ничего.
              Если бы эти поля инициализировались бы в конструкторе, то тогда мы получим stackoverflow — здесь CLR уже никаких оптимизаций не может делать

              Хочу заметить, что новых специализаций не будет, т.е. остаются все те же 3 класса.

              Надеюсь этот момент прояснился)
                0
                Да, погуглил ещё про дженерики, стало чуть понятнее вроде. Очень непривычно с такой вот рантаймовой штукой :)

                А почему в конструкторе никаких оптимизаций не получится?
                  0
                  т.к. мы выделяем память явно.

                  экземпляр одного типа будет иметь поле со специализацией другого (который так же содержит в себе текущий), что чревато рекурсией.

                  так же это важно с семантической точки зрения, т.к. .NET поддерживает интроспекцию (Reflection), а каждый такой экземпляр должен будет иметь поле, которое также имеет поле и т.д. граф должен быть создан, если иницализация (т.е. конструктор вызван).
                0
                Просто не стоит воспринимать Generic как template и все будет нормально :)

              Only users with full accounts can post comments. Log in, please.