Co produkuje .NET JIT?

8 Stycznia 2019

Postanowiłem odpowiedzieć na pytanie jak wyglądają dane - wartości zmiennych - w kodzie produkowanym przez RyuJIT - system emitujący natywny kod (w moim przypadku x86_64) podczas działania aplikacji napisanej w C#.

Moja instrukcja bazuje na oficjalnej.

Pierwszą rzeczą jaką musimy zrobić to zdobyć wersję CoreCLR skompilowaną z flagami do debugowania. Nie ma takich oficjalnych obrazów (nie znalazłem), więc odpowiedzią jest zbudowanie go sobie ze źródeł.

Żeby było łatwiej to zamieszczam krótką instrukcję poniżej:

  1. Sklonuj repo
  2. Zainstaluj zależności albo użyj obrazu dockera
  3. Uruchom skrypt budowania

W moim przypadku

git clone --depth=1 https://github.com/dotnet/coreclr/
docker run -it \                               #interactive
           --rm \                              #remove after use
           --entrypoint "/bin/bash" \          #start shell
           -v "$PWD/coreclr:/mnt/coreclr" \    #mount sources
           -w /mnt/coreclr \                   #workdir
           microsoft/dotnet-buildtools-prereqs:ubuntu-18.04-c103199-20180628134610
# apt update && apt install libcurl4
# ./build.sh

Instrukcje budowania na inne platformy znajdziecie w repozytorium.

Note: potrzeba jakieś 9GB wolnego miejsca.

Po tym jak się zbuduje to można usunąć folder bin/obj, który zajmuje najwięcej miejsca i zawiera tymczasowe pliki potrzebne do zlinkowania końcowych binarek.

Przygotowanie projektu

Następnie utworzyłem nowy folder i w środku wykonałem dotnet new console i rozpisałem mój kod, który chcę wyemitować

using System;
using System.Runtime.CompilerServices;

namespace jitdumptest
{
    struct Pair { public int x; public short y; public bool b; }

    class PairR { public int x; public short y; public bool b; }

    class Program
    {
        static void Main(string[] args)
        {
            var p1 = CreatePair();
            var p2 = CreatePairR();
            Console.WriteLine(p1);
            Console.WriteLine(p2);
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        static Pair CreatePair()
        {
            Pair p = new Pair();
            p.x = 10;
            p.y = 20;
            p.b = true;
            return p;
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        static PairR CreatePairR()
        {
            PairR p = new PairR();
            p.x = 10;
            p.y = 20;
            p.b = true;
            return p;
        }
    }
}

Dodatkowe atrybuty na metodach nie pozwolą im się zinlinować.

W projekcie csproj musiałem dodać linijkę

    <RuntimeIdentifier>linux-x64</RuntimeIdentifier>

poniżej TargetFramework. Potem dotnet restore, dotnet build i dotnet publish -c Release.

W tym momencie mam binarki w bin/Release/linux-x64/publish.

Podmiana CLR

Teraz trzeba wstrzyknąć nasze zbudowane CoreCLR do aplikacji oraz ustawić zmienną środowiskową, która spowoduje wypisanie emitowanych przez JIT metod.

Wchodzę do folderu z binarkami (powyżej) i wykonuję

cp -rT ~/Code/coreclr/bin/Product/Linux.x64.Debug .

Gdzie jest to ścieżka do zbudowanego wcześniej CoreCLR.

Następnie ustawiam zmienną

export COMPlus_JitDump="Main CreatePair CreatePairR"

która określa które metody JIT ma nam wypisać.

I wykonuję

./corerun jitdumptest.dll

Gdzie jitdumptest to nazwa mojego projektu.

Następnie dostaję ogromną ścianę tekstu. Tam jest wszystko co robi JIT. Jeśli chcemy tylko kod wynikowy, to należy wyczyścić zmienną COMPlus_JitDump (ustawiając ją na "") i ustawić za to zmienną COMPlus_JitDisasm.

Wynik:

; jitdumptest.Program:Main(ref)
G_M56875_IG01:
    push     rbp
    sub      rsp, 32
    lea      rbp, [rsp+20H]
    xor      rax, rax
    mov      qword ptr [rbp-10H], rax
    mov      gword ptr [rbp-08H], rdi

G_M56875_IG02:
    call     jitdumptest.Program:CreatePair():struct
    mov      qword ptr [rbp-18H], rax
    call     jitdumptest.Program:CreatePairR():ref
    mov      gword ptr [rbp-10H], rax
    lea      rsi, bword ptr [rbp-18H]
    mov      rdi, 0x7FA58A0BA048
    call     CORINFO_HELP_BOX
    mov      rdi, rax
    call     System.Console:WriteLine(ref)
    mov      rdi, gword ptr [rbp-10H]
    call     System.Console:WriteLine(ref)
    nop      

G_M56875_IG03:
    lea      rsp, [rbp]
    pop      rbp
    ret      

; ==================================================
; jitdumptest.Program:CreatePair():struct
G_M64814_IG01:
    push     rbp
    sub      rsp, 16
    lea      rbp, [rsp+10H]
    xor      rax, rax
    mov      qword ptr [rbp-08H], rax

G_M64814_IG02:
    xor      rax, rax
    mov      qword ptr [rbp-08H], rax
    mov      dword ptr [rbp-08H], 10
    lea      rax, bword ptr [rbp-08H]
    mov      word  ptr [rax+4], 20
    lea      rax, bword ptr [rbp-08H]
    mov      byte  ptr [rax+6], 1
    mov      rax, qword ptr [rbp-08H]

G_M64814_IG03:
    lea      rsp, [rbp]
    pop      rbp
    ret      

; ==================================================
; jitdumptest.Program:CreatePairR():ref
G_M52188_IG01:
    push     rbp
    sub      rsp, 16
    lea      rbp, [rsp+10H]
    xor      rax, rax
    mov      qword ptr [rbp-08H], rax

G_M52188_IG02:
    mov      rdi, 0x7FA58A0B9EE0
    call     CORINFO_HELP_NEWSFAST
    mov      gword ptr [rbp-08H], rax
    mov      rdi, gword ptr [rbp-08H]
    call     jitdumptest.PairR:.ctor():this
    mov      rax, gword ptr [rbp-08H]
    mov      dword ptr [rax+8], 10
    mov      rax, gword ptr [rbp-08H]
    mov      word  ptr [rax+12], 20
    mov      rax, gword ptr [rbp-08H]
    mov      byte  ptr [rax+14], 1
    mov      rax, gword ptr [rbp-08H]

G_M52188_IG03:
    lea      rsp, [rbp]
    pop      rbp
    ret      
Interpretacja

Analizując powyższy kod zauważymy bardzo ciekawe rzeczy:

  1. Jeśli struktura ma conajwyżej 8 bajtów to jest przekazywana jako wartość przez rejestr.
  2. Obiekty są przekazywane przez wskaźnik, w dodatku ich obszar pamięci danych zaczyna się od offsetu 8 bajtów.
  3. Zamiast robić mov rbp, rsp; sub rsp, X emitowany jest kod sub rsp, X; lea rbp, [rsp+X]