julia ---- 語言底層實現 @code_native, @code_typed 和 @code_llvm 區別

目錄

概述

Lowered code.

Typed code

LLVM IR.

Native code.


注: 這篇文章在翻譯的時候,有些地方我也不是太理解,因爲很多地方涉及到了語言編譯的底層內容,如果有興趣可以查閱原文。不過通過這篇文章,還是可以簡單瞭解到Julia的底層編譯實現。

概述

Python的標準CPython實現解析源代碼,並對其進行一些預處理和簡化(也稱爲“lowering”),將其轉換爲一種機器友好、易於解釋的格式,稱爲“字節碼”。這是“反彙編”Python函數時顯示的內容。此代碼不可由硬件執行,而是由CPython解釋器“可執行”。CPython的字節碼格式相當簡單,部分原因是解釋器往往擅長使用字節碼(如果字節碼太複雜,就會減慢解釋器的速度),還有部分原因是Python社區傾向於高度重視簡單性,有時會以高性能爲代價。

Julia的實現不是解釋的,它是即時(JIT)編譯的。這意味着當你調用一個函數時,它被轉換成機器代碼,由本地硬件直接執行。這個過程比Python解析和底層編譯成字節碼要複雜得多,Julia 速度提升了,但是也更加複雜。(Python的PypyJIT也比Cpython複雜得多,但通常也快得多——增加複雜性的是提升速度過程中的一個相當典型的代價)。Julia代碼的四個級別的“反彙編”使程序可以在從源代碼到機器代碼的轉換的不同階段使用特定參數類型訪問Julia方法的實現。我將使用以下函數來計算參數後的下一個Fibonacci數作爲示例:

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

Lowered code.

@code_lowered宏以最接近Python字節碼的格式顯示代碼,但它不是由解釋器執行的,而是由編譯器進一步轉換的。這種格式基本上是內部使用的,不適合人閱讀。代碼被轉換成“單個靜態賦值”形式,其中“每個變量在使用之前都被定義,且每個變量只被賦值一次”。循環和條件語句使用一個單獨的unless/goto結構轉換爲goto和標籤(這在用戶級是不可見的)。循環和條件語句使用一個單獨的除非/goto構造轉換爲goto和標籤(這在用戶級Julia中不公開)。下面是我們以較低格式編寫的示例代碼

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結構和標籤號。這不是那麼難讀,但也不是真的意味着適合人來閱讀。降低的代碼層級並不依賴於參數的類型,除非它們可以決定要調用哪個方法體——只要調用同一個方法,就會使用到相同的底層的代碼。

Typed code

@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。

降低的代碼僅依賴於參數類型來確定調用哪個方法體,與 lowered code 不同,類型化代碼的詳細信息依賴於參數類型:

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類型。如果類型的實現不相同,則類型化代碼可能也相差很大。例如,與簡單的“位類型”(如Int64和Int128)相比,大整數的nextfib要複雜得多:

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

這反映了一個事實,即對bigint的操作相當複雜,涉及到內存分配和對外部GMP庫(libgmp)的調用。

LLVM IR.

Julia使用LLVM編譯器框架生成機器代碼(LLVM compiler framework )。LLVM 核心庫提供了與編譯器相關的支持,可以作爲多種語言編譯器的後臺來使用。能夠進行程序語言的編譯期優化、鏈接優化、在線編譯優化、代碼生成。LLVM的項目是一個模塊化和可重複使用的編譯器和工具技術的集合。有三種同構形式

LLVM IR:

  1. 形式1:一種緊湊的、機器可讀的二進制表示法。
  2. 形式2:適合人來閱讀的冗長的文本表示法
  3. 形式3:由LLVM庫生成和使用的內存中表示。

Julia 使用LLVM的C++ API在內存中構建LLVM IR(形式3),然後調用LLVM進行優化並以該形式進行傳遞。當執行 @code_llvm 時,你會看到經過高級優化後的llvm IR。下面是使用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)在內存中LLVM IR的文本形式。LLVM不容易閱讀,大多數時候人們都不打算編寫或閱讀它,但它仍然是完全指定和記錄的。一旦你掌握了竅門,就不難理解了。此代碼跳轉到標籤L4,並用i64(LLVM的Int64名稱)值1初始化“寄存器”%storemerge1和%storemerge(從不同的位置跳轉到不同的位置時,它們的值會得到不同的派生,這就是phi指令的作用)。然後它執行icmp slt比較%storemerge和寄存器%0(在整個方法執行過程中保持參數不變),並將比較結果保存到寄存器%1中。它在%storemerge和%storemerge1上執行add i64,並將結果保存到寄存器%2中。如果%1爲真,則分支回L4,否則分支到L13。當代碼循環回L4時,寄存器%storemerge1獲取%storemerge的前一個值,而%storemerge獲取%2的前一個值。

Native code.

由於Julia可以執行本機代碼,方法實現的最後一種形式是機器實際執行的內容。這只是內存中的二進制代碼,很難閱讀,所以很久以前人們發明了各種形式的“彙編語言”,它們用名稱表示指令和寄存器,並有一些簡單的語法來幫助解析指令。一般來說,彙編語言與機器代碼保持着一對一的對應關係,特別是可以將機器代碼“反彙編”成彙編代碼。下面是我們的例子:

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)

這是在英特爾酷睿i7上執行的,它屬於x86_64CPU系列。它只使用標準的整數指令,所以在什麼體系結構上執行並不重要,但是根據機器的特定體系結構,某些代碼可以得到不同的結果,因爲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指令比較兩個寄存器,jl要麼跳回L16,要麼繼續從函數返回。當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

 

發佈了94 篇原創文章 · 獲贊 74 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章