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
>>>
これらを使用した経験のある人が私を理解してくれるでしょうか?ありがとう。
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)
の呼び出しは、リテラルInt64
値1
に置き換えられました(私のシステムでは、デフォルトの整数型はInt64
です)。式b < n
は、slt_int
intrinsic ( "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
関数の型付きバージョンです。リテラル1
はInt128
に符号拡張する必要があり、結果の操作タイプはInt128
ではなくInt64
タイプになります。型の実装が大幅に異なる場合、型付きコードはまったく異なる場合があります。たとえば、nextfib
のBigInts
は、Int64
やInt128
のような単純な「ビット型」の場合よりもかなり複雑です。
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つの同型形式があります。
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
で「レジスタ」Int64
と1
を初期化します(これらの値は、異なる場所からジャンプしたときに異なる方法で取得されます。つまり、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つのレジスタを比較し、jl
はL16
にジャンプして戻るか、関数から戻り続けます。タイトループ内のこの少数の整数機械語命令は、Julia関数呼び出しの実行時に実行されるものとまったく同じで、少し読みやすい形式で表示されます。なぜ高速に動作するのかは簡単にわかります。
解釈された実装と比較して一般的なJITコンパイルに興味がある場合、Eli Benderskyは、言語の単純なインタープリター実装から同じ言語用の(単純な)最適化JITに移行する素晴らしいブログ投稿を持っています。