為什麼Julia比Python快?因為天生理念就更先進啊

機器之心發表於2019-02-25

Julia 語言因為「快」和「簡潔」可兼得而聞名,我們可以用類似 Python 的優美語句獲得類似 C 的效能。那麼你知道為什麼 Julia 比 Python 快嗎?這並不是因為更好的編譯器,而是一種更新的設計理念,關注「人生苦短」的 Python 並沒有將這種理念納入其中。

選自Github,機器之心編譯,參與:思源、李亞洲。

為什麼Julia比Python快?因為天生理念就更先進啊

其實像以前 C 或其它主流語言在使用變數前先要宣告變數的具體型別,而 Python 並不需要,賦值什麼資料,變數就是什麼型別。然而沒想到正是這種型別穩定性,讓 Julia 相比 Python 有更好的效能。

選擇 Julia 的最主要原因:要比其他指令碼語言快得多,讓你擁有 Python/Matlab /R 一樣快速的開發速度,同時像 C/Fortan 那樣高效的執行速度。

Julia 的新手可能對下面這些描述略為謹慎:

  1. 為什麼其他語言不能更快一點?Julia 能夠做到,其他語言就不能?

  2. 你怎麼解釋 Julia 的速度基準?(對許多其他語言來說也很難?)

  3. 這聽起來違背沒有免費午餐定律,在其他方面是否有損失?

許多人認為 Julia 快是因為它使用的是 JIT 編譯器,即每一條語句在使用前都先使用編譯函式進行編譯,不論是預先馬上編譯或之前先快取編譯。這就產生了一個問題,即 Python/R 和 MATLAB 等指令碼語言同樣可以使用 JIT 編譯器,這些編譯器的優化時間甚至比 Julia 語言都要久。所以為什麼我們會瘋狂相信 Julia 語言短時間的優化就要超過其它指令碼語言?這是一種對 Julia 語言的完全誤解。

在本文中,我們將瞭解到 Julia 快是因為它的設計決策。它的核心設計決策:通過多重分派的型別穩定性是允許 Julia 能快速編譯並高效執行的核心,本文後面會具體解釋為什麼它是快的原因。此外,這一核心決策同時還能像指令碼語言那樣令語法非常簡潔,這兩者相加可以得到非常明顯的效能增益。

但是,在本文中我們能看到的是 Julia 不總像其他指令碼語言,我們需要明白 Julia 語言因為這個核心決策而有一些「損失」。理解這種設計決策如何影響你的程式設計方式,對你生成 Julia 程式碼而言非常重要。

為了看見其中的不同,我們可以先簡單地看看數學運算案例。

Julia 中的數學運算

總而言之,Julia 中的數學運算看起來和其他指令碼語言是一樣的。值得注意的一個細節是 Julia 的數值是「真數值」,在 Float64 中真的就和一個 64 位的浮點數值一樣,或者是 C 語言的「雙精度浮點數」。一個 Vector{Float64} 中的記憶體排列等同於 C 語言雙精度浮點數陣列,這都使得它與 C 語言的互動操作變得簡單(確實,某種意義上 Julia 是構建在 C 語言頂層的),且能帶來高效能(對 NumPy 陣列來說也是如此)。

Julia 中的一些數學:

a = 2+2b = a/3c = a÷3 #\div tab completion, means integer divisiond = 4*5println([a;b;c;d])複製程式碼
output: [4.0, 1.33333, 1.0, 20.0]複製程式碼

此外,數值乘法在後面跟隨著變數的情況下允許不使用運算子 *,例如以下的計算可通過 Julia 程式碼完成:

α = 0.5∇f(u) = α*u; ∇f(2)sin(2π)複製程式碼
output: -2.4492935982947064e-16複製程式碼

型別穩定和程式碼自省

型別穩定,即從一種方法中只能輸出一種型別。例如,從 *(:: Float64,:: Float64) 輸出的合理型別是 Float64。無論你給它的是什麼,它都會反饋一個 Float64。這裡是一種多重分派(Multiple-Dispatch)機制:運算子 * 根據它看到的型別呼叫不同的方法。當它看到 floats 時,它會反饋 floats。Julia 提供程式碼自省(code introspection)巨集,以便你可以看到程式碼實際編譯的內容。因此 Julia 不僅僅是一種指令碼語言,它更是一種可以讓你處理彙編的指令碼語言!與許多語言一樣,Julia 編譯為 LLVM(LLVM 是一種可移植的組合語言)。

@code_llvm 2*5; Function *; Location: int.jl:54define i64 @"julia_*_33751"(i64, i64) {top:  %2 = mul i64 %1, %0  ret i64 %2}複製程式碼

這個輸出表示,執行浮點乘法運算並返回答案。我們甚至可以看一下彙編:

@code_llvm 2*5    .text; Function * {; Location: int.jl:54    imulq   %rsi, %rdi    movq    %rdi, %rax    retq    nopl    (%rax,%rax);}複製程式碼

這表示*函式已編譯為與 C / Fortran 中完全相同的操作,這意味著它實現了相同的效能(即使它是在 Julia 中定義的)。因此,不僅可以「接近」C 語言的效能,而且實際上可以獲得相同的 C 程式碼。那麼在什麼情況下會發生這種事情呢?

關於 Julia 的有趣之處在於,我們需要知道什麼情況下程式碼不能編譯成與 C / Fortran 一樣高效的運算?這裡的關鍵是型別穩定性。如果函式是型別穩定的,那麼編譯器可以知道函式中所有節點的型別,並巧妙地將其優化為與 C / Fortran 相同的程式集。如果它不是型別穩定的,Julia 必須新增昂貴的「boxing」以確保在操作之前找到或者已明確知道的型別。

這是 Julia 和其他指令碼語言之間最為關鍵的不同點!

好處是 Julia 的函式在型別穩定時基本上和 C / Fortran 函式一樣。因此^(取冪)很快,但既然 ^(:: Int64,:: Int64)是型別穩定的,那麼它應輸出什麼型別?

2^5複製程式碼
output: 32複製程式碼
2^-5複製程式碼
output: 0.03125複製程式碼

這裡我們得到一個錯誤。編譯器為了保證 ^ 返回一個 Int64,必須丟擲一個錯誤。如果在 MATLAB,Python 或 R 中執行這個操作,則不會丟擲錯誤,這是因為那些語言沒有圍繞型別穩定性構建整個語言。

當我們沒有型別穩定性時會發生什麼呢?我們來看看這段程式碼:

@code_native ^(2,5)    .text; Function ^ {; Location: intfuncs.jl:220    pushq   %rax    movabsq $power_by_squaring, %rax    callq   *%rax    popq    %rcx    retq    nop;}複製程式碼

現在讓我們定義對整數的取冪,讓它像其他指令碼語言中看到的那樣「安全」:

function expo(x,y)    if y>0        return x^y    else        x = convert(Float64,x)        return x^y    endend複製程式碼
output: expo (generic function with 1 method)複製程式碼

確保它有效:

println(expo(2,5))expo(2,-5)複製程式碼
output: 32 複製程式碼
0.03125複製程式碼

當我們檢查這段程式碼時會發生什麼?

@code_native expo(2,5).text; Function expo {; Location: In[8]:2    pushq   %rbx    movq    %rdi, %rbx; Function >; {; Location: operators.jl:286; Function <; {; Location: int.jl:49    testq   %rdx, %rdx;}}    jle L36; Location: In[8]:3; Function ^; {; Location: intfuncs.jl:220    movabsq $power_by_squaring, %rax    movq    %rsi, %rdi    movq    %rdx, %rsi    callq   *%rax;}    movq    %rax, (%rbx)    movb    $2, %dl    xorl    %eax, %eax    popq    %rbx    retq; Location: In[8]:5; Function convert; {; Location: number.jl:7; Function Type; {; Location: float.jl:60L36:    vcvtsi2sdq  %rsi, %xmm0, %xmm0;}}; Location: In[8]:6; Function ^; {; Location: math.jl:780; Function Type; {; Location: float.jl:60    vcvtsi2sdq  %rdx, %xmm1, %xmm1    movabsq $__pow, %rax;}    callq   *%rax;}    vmovsd  %xmm0, (%rbx)    movb    $1, %dl    xorl    %eax, %eax; Location: In[8]:3    popq    %rbx    retq    nopw    %cs:(%rax,%rax);}複製程式碼

這個演示非常直觀地說明了為什麼 Julia 使用型別推斷來實現能夠比其他指令碼語言有更高的效能。

核心觀念:多重分派+型別穩定性 => 速度+可讀性

型別穩定性(Type stability)是將 Julia 語言與其他指令碼語言區分開的一個重要特徵。實際上,Julia 的核心觀念如下所示:

(引用)多重分派(Multiple dispatch)允許語言將函式呼叫分派到型別穩定的函式。

這就是 Julia 語言所有特性的出發點,所以我們需要花些時間深入研究它。如果函式內部存在型別穩定性,即函式內的任何函式呼叫也是型別穩定的,那麼編譯器在每一步都能知道變數的型別。因為此時程式碼和 C/Fortran 程式碼基本相同,所以編譯器可以使用全部的優化方法編譯函式。

我們可以通過案例解釋多重分派,如果乘法運算子 * 為型別穩定的函式:它因輸入表示的不同而不同。但是如果編譯器在呼叫 * 之前知道 a 和 b 的型別,那麼它就知道哪一個 * 方法可以使用,因此編譯器也知道 c=a * b 的輸出型別。因此如果沿著不同的運算傳播型別資訊,那麼 Julia 將知道整個過程的型別,同時也允許實現完全的優化。多重分派允許每一次使用 * 時都表示正確的型別,也神奇地允許所有優化。

我們可以從中學習到很多東西。首先為了達到這種程度的執行優化,我們必須擁有型別穩定性。這並不是大多數程式語言標準庫所擁有的特性,只不過是令使用者體驗更容易而需要做的選擇。其次,函式的型別需要多重分派才能實現專有化,這樣才能允許指令碼語言變得「變得更明確,而不僅更易讀」。最後,我們還需要一個魯棒性的型別系統。為了構建型別不穩定的指數函式(可能用得上),我們也需要轉化器這樣的函式。

因此程式語言必須設計為具有多重分派的型別穩定性語言,並且還需要以魯棒性型別系統為中心,以便在保持指令碼語言的句法和易於使用的特性下實現底層語言的效能。我們可以在 Python 中嵌入 JIT,但如果需要嵌入到 Julia,我們需要真的把它成設計為 Julia 的一部分。

Julia 基準

Julia 網站上的 Julia 基準能測試程式語言的不同模組,從而希望獲取更快的速度。這並不意味著 Julia 基準會測試最快的實現,這也是我們對其主要的誤解。其它程式語言也有相同的方式:測試程式語言的基本模組,並看看它們到底有多快。

Julia 語言是建立在型別穩定函式的多重分派機制上的。因此即使是最初版的 Julia 也能讓編譯器快速優化到 C/Fortran 語言的效能。很明顯,基本大多數案例下 Julia 的效能都非常接近 C。但還有少量細節實際上並不能達到 C 語言的效能,首先是斐波那契數列問題,Julia 需要的時間是 C 的 2.11 倍。這主要是因為遞迴測試,Julia 並沒有完全優化遞迴運算,不過它在這個問題上仍然做得非常好。

用於這類遞迴問題的最快優化方法是 Tail-Call Optimization,Julia 語言可以隨時新增這類優化。但是 Julia 因為一些原因並沒有新增,主要是:任何需要使用 Tail-Call Optimization 的案例同時也可以使用迴圈語句。但是迴圈對於優化顯得更加魯棒,因為有很多遞迴都不能使用 Tail-Call 優化,因此 Julia 還是建議使用迴圈而不是使用不太穩定的 TCO。

Julia 還有一些案例並不能做得很好,例如 the rand_mat_stat 和 parse_int 測試。然而,這些很大程度上都歸因於一種名為邊界檢測(bounds checking)的特徵。在大多數指令碼語言中,如果我們對陣列的索引超過了索引邊界,那麼程式將報錯。Julia 語言預設會完成以下操作:

function test1()    a = zeros(3)    for i=1:4        a[i] = i    endendtest1()BoundsError: attempt to access 3-element Array{Float64,1} at index [4]Stacktrace: [1] setindex! at ./array.jl:769 [inlined] [2] test1() at ./In[11]:4 [3] top-level scope at In[11]:7複製程式碼

然而,Julia 語言允許我們使用 @inbounds 巨集關閉邊界檢測:

function test2()    a = zeros(3)    @inbounds for i=1:4        a[i] = i    endendtest2()複製程式碼

這會為我們帶來和 C/Fortran 相同的不安全行為,但是也能帶來相同的速度。如果我們將關閉邊界檢測的程式碼用於基準測試,我們能獲得與 C 語言相似的速度。這是 Julia 語言另一個比較有趣的特徵:它預設情況下允許和其它指令碼語言一樣獲得安全性,但是在特定情況下(測試和 Debug 後)關閉這些特徵可以獲得完全的效能。

核心概念的小擴充套件:嚴格型別形式

型別穩定性並不是唯一必須的,我們還需要嚴格的型別形式。在 Python 中,我們可以將任何型別資料放入陣列,但是在 Julia,我們只能將型別 T 放入到 Vector{T} 中。為了提供一般性,Julia 語言提供了各種非嚴格形式的型別。最明顯的案例就是 Any,任何滿足 T:<Any 的型別,在我們需要時都能建立 Vector{Any},例如:

a = Vector{Any}(undef,3)a[1] = 1.0a[2] = "hi!"a[3] = :Symbolica複製程式碼
output:  3-element Array{Any,1}:
 1.0       
 "hi!"    
 :Symbolic複製程式碼

抽象型別的一種不太極端的形式是 Union 型別,例如:

a = Vector{Union{Float64,Int}}(undef,3)a[1] = 1.0a[2] = 3a[3] = 1/4a複製程式碼
output: 3-element Array{Union{Float64, Int64},1}:
 1.0 
 3   
 0.25複製程式碼

該案例只接受浮點型和整型數值,然而它仍然是一種抽象型別。一般在抽象型別上呼叫函式並不能知道任何元素的具體型別,例如在以上案例中每一個元素可能是浮點型或整型。因此通過多重分派實現優化,編譯器並不能知道每一步的型別。因為不能完全優化,Julia 語言和其它指令碼語言一樣都會放慢速度。

這就是高效能原則:儘可能使用嚴格的型別。遵守這個原則還有其它優勢:一個嚴格的型別 Vector{Float64} 實際上與 C/Fortran 是位元組相容的(byte-compatible),因此它無需轉換就能直接用於 C/Fortran 程式。

高效能的成本

很明顯 Julia 語言做出了很明智的設計決策,因而在成為指令碼語言的同時實現它的效能目標。然而,它到底損失了些什麼?下一節將展示一些由該設計決策而產生的 Julia 特性,以及 Julia 語言各處的一些解決工具。

可選的效能

前面已經展示過,Julia 會通過很多方式實現高效能(例如 @inbounds),但它們並不一定需要使用。我們可以使用型別不穩定的函式,它會變得像 MATLAB/R/Python 那樣慢。如果我們並不需要頂尖的效能,我們可以使用這些便捷的方式。

檢測型別穩定性

因為型別穩定性極其重要,Julia 語言會提供一些工具以檢測函式的型別穩定性,這在 @code_warntype 巨集中是最重要的。下面我們可以檢測型別穩定性:

@code_warntype 2^5Body::Int64│220 1 ─ %1 = invoke Base.power_by_squaring(_2::Int64, _3::Int64)::Int64│    └──      return %1複製程式碼

注意這表明函式中的變數都是嚴格型別,那麼 expo 函式呢?

@code_warntype 2^5Body::Union{Float64, Int64}│╻╷ >2 1 ─ %1  = (Base.slt_int)(0, y)::Bool│    └──       goto #3 if not %1│  3 2 ─ %3  = π (x, Int64)│╻  ^  │   %4  = invoke Base.power_by_squaring(%3::Int64, _3::Int64)::Int64│    └──       return %4│  5 3 ─ %6  = π (x, Int64)││╻  Type  │   %7  = (Base.sitofp)(Float64, %6)::Float64│  6 │   %8  = π (%7, Float64)│╻  ^  │   %9  = (Base.sitofp)(Float64, y)::Float64││   │   %10 = $(Expr(:foreigncall, "llvm.pow.f64", Float64, svec(Float64, Float64), :(:llvmcall), 2, :(%8), :(%9), :(%9), :(%8)))::Float64│    └──       return %10複製程式碼

函式返回可能是 4% 和 10%,它們是不同的型別,所以返回的型別可以推斷為 Union{Float64,Int64}。為了準確追蹤不穩定性產生的位置,我們可以使用 Traceur.jl:

using Traceur@trace expo(2,5)┌ Warning: x is assigned as Int64└ @ In[8]:2┌ Warning: x is assigned as Float64└ @ In[8]:5┌ Warning: expo returns Union{Float64, Int64}└ @ In[8]:2複製程式碼
output: 32複製程式碼

這表明第 2 行 x 分派為整型 Int,而第 5 行它被分派為浮點型 Float64,所以型別可以推斷為 Union{Float64,Int64}。第 5 行是明確呼叫 convert 函式的位置,因此這為我們確定了問題所在。原文後面還介紹瞭如何處理不穩定型別,以及全域性變數 Globals 擁有比較差的效能,希望詳細瞭解的讀者可查閱原文。

結 論

設計上 Julia 很快。型別穩定性和多重分派對 Julia 的編譯做特化很有必要,使其工作效率非常高。此外,魯棒性的型別系統同樣還需要在細粒度水平的型別上正常執行,因此才能儘可能實現型別穩定性,並在型別不穩定的情況下儘可能獲得更高的優化。


相關文章