web-dev-qa-db-ja.com

Juliaの@ code_native、@ code_typed、@ code_llvmの違いは何ですか?

Juliaを使用しているときに、Pythonのdisモジュールに似た機能が必要でした。ネットで調べたところ、Juliaコミュニティがこの問題を解決し、これらを提供していることがわかりました( https://github.com/JuliaLang/Julia/issues/218

finfer -> code_typed
methods(function, types) -> code_lowered
disassemble(function, types, true) -> code_native
disassemble(function, types, false) -> code_llvm

Julia REPLを使って個人的に試しましたが、なかなかわかりにくいようです。

Pythonでは、このような関数を分解できます。

>>> import dis
>>> dis.dis(lambda x: 2*x)
  1           0 LOAD_CONST               1 (2)
              3 LOAD_FAST                0 (x)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        
>>>

これらを使用した経験のある人が私を理解してくれるでしょうか?ありがとう。

23
Rahul

Pythonの標準CPython実装は、ソースコードを解析し、いくつかの前処理と簡略化を行います(別名 "低下")。これを " バイトコード "。これは、Python関数を「逆アセンブル」するときに表示されるものです。このコードはハードウェアでは実行できません。CPythonインタープリターによって「実行可能」です。 CPythonのバイトコード形式はかなり単純です。これは、インタープリターがうまく機能する傾向があるためです。バイトコードが複雑すぎると、インタープリターの速度が低下します。また、Pythonコミュニティが高いプレミアムを重視する傾向があるためです。シンプルさ、時には高性能を犠牲にして。

Juliaの実装は解釈されず、 ジャストインタイム(JIT)コンパイル済み です。つまり、関数を呼び出すと、ネイティブハードウェアによって直接実行されるマシンコードに変換されます。このプロセスは、Pythonが行うように解析してバイトコードに下げるよりもかなり複雑ですが、その複雑さと引き換えに、Juliaはその特徴的な速度を取得します。 (PythonのPyPyJITもCPythonよりもはるかに複雑ですが、通常ははるかに高速です。複雑さが増すと、速度のコストがかなり一般的になります。)Juliaコードの4つのレベルの「分解」により、ソースコードからマシンコードへの変換のさまざまな段階での特定の引数タイプのJuliaメソッド実装の表現。例として、引数の後の次のフィボナッチ数を計算する次の関数を使用します。

function nextfib(n)
    a, b = one(n), one(n)
    while b < n
        a, b = b, a + b
    end
    return b
end

Julia> nextfib(5)
5

Julia> nextfib(6)
8

Julia> nextfib(123)
144

低めのコード。@code_loweredマクロは、Pythonバイトコードに最も近い形式でコードを表示しますが、インタプリタによる実行であり、コンパイラによるさらなる変換を目的としています。このフォーマットは主に内部用であり、人間が使用することを想定していません。コードは「 単一静的代入 」形式に変換され、「各変数は1回だけ割り当てられ、すべての変数は使用前に定義されます」。ループと条件文は、単一のunless/goto構文を使用してgotoとラベルに変換されます(これはユーザーレベルのJuliaでは公開されません)。これが低形式のサンプルコードです(Julia 0.6.0-pre.beta.134で、これは私がたまたま利用できるものです):

Julia> @code_lowered nextfib(123)
CodeInfo(:(begin
        nothing
        SSAValue(0) = (Main.one)(n)
        SSAValue(1) = (Main.one)(n)
        a = SSAValue(0)
        b = SSAValue(1) # line 3:
        7:
        unless b < n goto 16 # line 4:
        SSAValue(2) = b
        SSAValue(3) = a + b
        a = SSAValue(2)
        b = SSAValue(3)
        14:
        goto 7
        16:  # line 6:
        return b
    end))

SSAValueノードとunless/gotoコンストラクトとラベル番号を確認できます。これはそれほど読みにくいものではありませんが、繰り返しになりますが、人間が簡単に消費できるようにすることも意図されていません。下げられたコードは、呼び出すメソッド本体を決定する場合を除いて、引数のタイプに依存しません。同じメソッドが呼び出される限り、同じ下げられたコードが適用されます。

型付きコード。@code_typedマクロは、 型推論 および (型推論)の後に、特定の引数型のセットのメソッド実装を示します。インライン化 。このコードの具体化は、下げられた形式に似ていますが、型情報で注釈が付けられた式と、いくつかの汎用関数呼び出しがその実装に置き換えられています。たとえば、サンプル関数のタイプコードは次のとおりです。

Julia> @code_typed nextfib(123)
CodeInfo(:(begin
        a = 1
        b = 1 # line 3:
        4:
        unless (Base.slt_int)(b, n)::Bool goto 13 # line 4:
        SSAValue(2) = b
        SSAValue(3) = (Base.add_int)(a, b)::Int64
        a = SSAValue(2)
        b = SSAValue(3)
        11:
        goto 4
        13:  # line 6:
        return b
    end))=>Int64

one(n)の呼び出しは、リテラルInt641に置き換えられました(私のシステムでは、デフォルトの整数型はInt64です)。式b < nは、slt_intintrinsic ( "signed integer less than")の観点から実装に置き換えられ、その結果に戻り型Boolの注釈が付けられています。式a + bも、add_int組み込み関数と、Int64として注釈が付けられた結果タイプに関する実装に置き換えられました。また、関数本体全体の戻り値の型には、Int64という注釈が付けられています。

どのメソッド本体が呼び出されるかを決定するために引数タイプのみに依存するローダウンコードとは異なり、型付きコードの詳細は引数タイプに依存します。

Julia> @code_typed nextfib(Int128(123))
CodeInfo(:(begin
        SSAValue(0) = (Base.sext_int)(Int128, 1)::Int128
        SSAValue(1) = (Base.sext_int)(Int128, 1)::Int128
        a = SSAValue(0)
        b = SSAValue(1) # line 3:
        6:
        unless (Base.slt_int)(b, n)::Bool goto 15 # line 4:
        SSAValue(2) = b
        SSAValue(3) = (Base.add_int)(a, b)::Int128
        a = SSAValue(2)
        b = SSAValue(3)
        13:
        goto 6
        15:  # line 6:
        return b
    end))=>Int128

これは、Int128引数のnextfib関数の型付きバージョンです。リテラル1Int128に符号拡張する必要があり、結果の操作タイプはInt128ではなくInt64タイプになります。型の実装が大幅に異なる場合、型付きコードはまったく異なる場合があります。たとえば、nextfibBigIntsは、Int64Int128のような単純な「ビット型」の場合よりもかなり複雑です。

Julia> @code_typed nextfib(big(123))
CodeInfo(:(begin
        $(Expr(:inbounds, false))
        # meta: location number.jl one 164
        # meta: location number.jl one 163
        # meta: location gmp.jl convert 111
        z@_5 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt))) # line 112:
        $(Expr(:foreigncall, (:__gmpz_set_si, :libgmp), Void, svec(Ptr{BigInt}, Int64), :(&z@_5), :(z@_5), 1, 0))
        # meta: pop location
        # meta: pop location
        # meta: pop location
        $(Expr(:inbounds, :pop))
        $(Expr(:inbounds, false))
        # meta: location number.jl one 164
        # meta: location number.jl one 163
        # meta: location gmp.jl convert 111
        z@_6 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt))) # line 112:
        $(Expr(:foreigncall, (:__gmpz_set_si, :libgmp), Void, svec(Ptr{BigInt}, Int64), :(&z@_6), :(z@_6), 1, 0))
        # meta: pop location
        # meta: pop location
        # meta: pop location
        $(Expr(:inbounds, :pop))
        a = z@_5
        b = z@_6 # line 3:
        26:
        $(Expr(:inbounds, false))
        # meta: location gmp.jl < 516
        SSAValue(10) = $(Expr(:foreigncall, (:__gmpz_cmp, :libgmp), Int32, svec(Ptr{BigInt}, Ptr{BigInt}), :(&b), :(b), :(&n), :(n)))
        # meta: pop location
        $(Expr(:inbounds, :pop))
        unless (Base.slt_int)((Base.sext_int)(Int64, SSAValue(10))::Int64, 0)::Bool goto 46 # line 4:
        SSAValue(2) = b
        $(Expr(:inbounds, false))
        # meta: location gmp.jl + 258
        z@_7 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt))) # line 259:
        $(Expr(:foreigncall, ("__gmpz_add", :libgmp), Void, svec(Ptr{BigInt}, Ptr{BigInt}, Ptr{BigInt}), :(&z@_7), :(z@_7), :(&a), :(a), :(&b), :(b)))
        # meta: pop location
        $(Expr(:inbounds, :pop))
        a = SSAValue(2)
        b = z@_7
        44:
        goto 26
        46:  # line 6:
        return b
    end))=>BigInt

これは、BigIntsの操作がかなり複雑で、メモリの割り当てと外部GMPライブラリ(libgmp)の呼び出しを伴うという事実を反映しています。

LLVM IR。Juliaは LLVMコンパイラフレームワーク を使用してマシンコードを生成します。 LLVMは、異なるコンパイラ最適化パスとフレームワーク内の他のツールの間で共有される 中間表現 (IR)として使用するアセンブリのような言語を定義します。 LLVM IRには3つの同型形式があります。

  1. コンパクトで機械が読み取り可能なバイナリ表現。
  2. 冗長で多少人間が読めるテキスト表現。
  3. LLVMライブラリによって生成および消費されるメモリ内表現。

JuliaはLLVMのC++ APIを使用してメモリ内にLLVM IR(フォーム3)を構築し、そのフォームでLLVM最適化パスを呼び出します。 @code_llvmを実行すると、生成後のLLVMIRといくつかの高レベルの最適化が表示されます。進行中の例のLLVMコードは次のとおりです。

Julia> @code_llvm nextfib(123)

define i64 @Julia_nextfib_60009(i64) #0 !dbg !5 {
top:
  br label %L4

L4:                                               ; preds = %L4, %top
  %storemerge1 = phi i64 [ 1, %top ], [ %storemerge, %L4 ]
  %storemerge = phi i64 [ 1, %top ], [ %2, %L4 ]
  %1 = icmp slt i64 %storemerge, %0
  %2 = add i64 %storemerge, %storemerge1
  br i1 %1, label %L4, label %L13

L13:                                              ; preds = %L4
  ret i64 %storemerge
}

これは、nextfib(123)メソッド実装用のメモリ内LLVMIRのテキスト形式です。 LLVMは読みやすいものではなく、ほとんどの場合、人々が書いたり読んだりすることを意図したものではありませんが、徹底的に 指定および文書化 です。一度コツをつかめば、理解するのは難しいことではありません。このコードは、ラベルL4にジャンプし、%storemerge1%storemergeのLLVMの名前)の値i64で「レジスタ」Int641を初期化します(これらの値は、異なる場所からジャンプしたときに異なる方法で取得されます。つまり、phi命令とは異なります)。次に、icmp sltを実行して%storemergeをレジスタ%0と比較します。これにより、引数はメソッドの実行全体にわたってそのまま保持され、比較結果がレジスタ%1に保存されます。 add i64%storemerge%storemerge1を実行し、結果をレジスタ%2に保存します。 %1がtrueの場合、L4に分岐し、それ以外の場合はL13に分岐します。コードがループしてL4に戻ると、レジスタ%storemerge1%storemergeの以前の値を取得し、%storemerge%2の以前の値を取得します。

ネイティブコード。Juliaはネイティブコードを実行するため、メソッドの実装がとる最後の形式は、マシンが実際に実行するものです。これはメモリ内の単なるバイナリコードであり、読みづらいため、昔から、さまざまな形式の「アセンブリ言語」が発明され、命令を表し、名前で登録し、命令が何をするかを表すのに役立つ簡単な構文をいくつか持っています。一般に、アセンブリ言語はマシンコードと1対1の対応に近いままです。特に、マシンコードをアセンブリコードにいつでも「逆アセンブル」できます。次に例を示します。

Julia> @code_native nextfib(123)
    .section    __TEXT,__text,regular,pure_instructions
Filename: REPL[1]
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $1, %ecx
    movl    $1, %edx
    nop
L16:
    movq    %rdx, %rax
Source line: 4
    movq    %rcx, %rdx
    addq    %rax, %rdx
    movq    %rax, %rcx
Source line: 3
    cmpq    %rdi, %rax
    jl  L16
Source line: 6
    popq    %rbp
    retq
    nopw    %cs:(%rax,%rax)

これは、x86_64 CPUファミリのIntel Core i7にあります。標準の整数命令のみを使用するため、アーキテクチャが何であるかは問題ではありませんが、yourマシンの特定のアーキテクチャに応じて、一部のコードで異なる結果を得ることができます。 JITコードはシステムによって異なる可能性があるためです。最初のpushqおよびmovq命令は標準の関数プリアンブルであり、レジスタをスタックに保存します。同様に、popqはレジスタを復元し、retqは関数から戻ります。 nopwは、関数の長さを埋めるためだけに含まれる、何もしない2バイトの命令です。したがって、コードの要点はこれだけです。

    movl    $1, %ecx
    movl    $1, %edx
    nop
L16:
    movq    %rdx, %rax
Source line: 4
    movq    %rcx, %rdx
    addq    %rax, %rdx
    movq    %rax, %rcx
Source line: 3
    cmpq    %rdi, %rax
    jl  L16

上部のmovl命令は、レジスタを1つの値で初期化します。 movq命令はレジスタ間で値を移動し、addq命令はレジスタを追加します。 cmpq命令は2つのレジスタを比較し、jlL16にジャンプして戻るか、関数から戻り続けます。タイトループ内のこの少数の整数機械語命令は、Julia関数呼び出しの実行時に実行されるものとまったく同じで、少し読みやすい形式で表示されます。なぜ高速に動作するのかは簡単にわかります。

解釈された実装と比較して一般的なJITコンパイルに興味がある場合、Eli Benderskyは、言語の単純なインタープリター実装から同じ言語用の(単純な)最適化JITに移行する素晴らしいブログ投稿を持っています。

  1. http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-1-an-interpreter/
  2. http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-2-an-x64-jit.html
52
StefanKarpinski